<?php
declare(strict_types=1);

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

/**
 * Marquee block renderer (shared frontend + editor).
 *
 * Continuous horizontal strips of hand-authored, clickable cards
 * (icon / title / subtitle / link). 1-3 rows, each drifting left or
 * right. The drift is a pure CSS keyframe animation: each row is a
 * `.impulse-marquee-track` holding TWO identical card sets, animated
 * translateX(0) to -50%, so the loop runs on the compositor and its wrap
 * is seamless with no main-thread transform jump. (This replaced the
 * shared JS carousel-init engine, whose per-loop transform reset flashed
 * the whole row.) Opposing rows just flip the keyframe direction.
 *
 * Seamless loop: -50% lands the track exactly one set back, which is why
 * the two sets must be identical. Each set repeats the author's cards
 * enough times to clear ~4000px (wider than a 4K viewport) so a set is
 * never narrower than the row. Only the first author pass is exposed to
 * assistive tech / keyboard; every repeat + clone is aria-hidden +
 * non-tabbable. The animation also runs in the editor now.
 *
 * Settings (stored on the element):
 *   speed       1-10, drift pace (default 5)
 *   card_width  120-360, drives padding/icon/type proportionally (default 200)
 *   card_height min-height px (0 = size to content)
 *   card_radius corner rounding px
 *   row_count   1-3 rows (default 2)
 *   dir_0/1/2   'left' | 'right' per row (default alternating)
 *   edge_fade   bool - faded strip edges (default true)
 *   pause_hover bool - pause the row under the pointer (default true)
 *   hover_reveal bool - cross-fade a card front to its 'back' detail on hover
 *   hover_zoom  bool - IMAGE MODE ONLY: lift and enlarge the card under the
 *               pointer. A strip that only pauses on hover reads as a stall.
 *   hover_zoom_scale  1.05-3.0 (default 1.55)
 *   card_bg/text_color/icon_color/border_color  card colours
 *   cards       JSON array: [{icon,title,subtitle,link,new_tab,back,row}, ...]
 *
 * Free for everyone.
 *
 * @param array|object $el       Element node.
 * @param array        $settings Resolved (editor) or raw (frontend) settings.
 * @param string       $context  'frontend' or 'editor'.
 * @return string Inner HTML.
 *
 * @sideeffect Frontend enqueues instinctor-marquee-css (self-contained;
 *             no JS engine).
 */
function impulse_element_render_marquee($el, array $settings, string $context): string {
    if (is_object($el)) $el = (array) $el;
    $isEditor = ($context === 'editor');

    // PER-SERVICE WORK STRIPS. One titled strip per service that HAS photos, and none
    // for services that do not: a handyman with pictures for two of his six services
    // gets two strips, not six with four empty (Sergei 2026-07-28). The other four
    // still appear in the services list, which is a different section.
    //
    // Rendered by calling this same function once per service, so each strip inherits
    // every card size, colour and speed the author chose rather than reimplementing
    // them. source is unset on the way in, or it would recurse forever.
    if ((string) ($settings['source'] ?? '') === 'service_photos') {
        $groups = impulse_marquee_service_photos();

        // THREE STATES, IN ORDER OF TRUTH.
        //   1. the owner's own photos, grouped by service   -> per-service strips
        //   2. none yet, but the element carries cards      -> render them normally
        //   3. none at all                                  -> titled placeholders
        //
        // State 2 is the DEMO. Services do not exist until publish, so a demo has no
        // service photos by definition, and returning early here would leave the
        // recording with no portfolio at all. The demo's pooled images live on the
        // element itself and are stripped at publish (bakePublishedElements), so a
        // real site never inherits them and falls to state 3 (2026-07-28).
        if (!$groups) {
            $seeded = $settings['cards'] ?? null;
            if (is_string($seeded)) $seeded = json_decode($seeded, true);
            if (is_array($seeded) && $seeded) {
                $pass = $settings;
                unset($pass['source']);
                return impulse_element_render_marquee($el, $pass, $context);
            }
        }

        // EMPTY STATE IS AN EDITOR STATE ONLY (revised 2026-07-28). The owner still
        // sees placeholder tiles while building, so they discover that their work
        // goes here, but a LIVE site never shows a strip of blank tiles: an empty
        // portfolio on a published page reads as broken, not as an invitation. The
        // section that frames this carries requires=service_photos, so on the
        // frontend the heading disappears with the strip rather than stranding it.
        if (!$groups) {
            if ($context !== 'editor') return '';
            $groups = impulse_marquee_service_placeholders();
        }
        $outHtml = '';
        foreach ($groups as $g) {
            $sub = $settings;
            unset($sub['source']);
            $sub['mode']        = 'image';
            // Photo, not logo: mq-img-logo greys the image to 70% and contains it,
            // which is right for a wall of client logos and wrong for a trade showing
            // its own work. Captions off, which is the part that read as cleaner on the
            // test page: the strip is pictures, the service name is the heading above it.
            $sub['image_style'] = 'photo';
            $sub['caption_pos'] = 'none';
            $sub['row_count']   = 1;
            $sub['hover_zoom']  = 1;
            $sub['cards']       = [];
            foreach ($g['photos'] as $u) {
                $sub['cards'][] = ['image' => $u, 'caption' => '', 'row' => 0];
            }
            $outHtml .= '<div class="impulse-mq-service">'
                . '<h3 class="impulse-mq-service-title">' . esc_html($g['name']) . '</h3>'
                . impulse_element_render_marquee($el, $sub, $context)
                . '</div>';
        }
        return $outHtml;
    }

    // Card size drives the card width; padding, icon, and type scale off it so
    // cards stay proportioned from Compact to Showcase on one slider. Replaces
    // the old Calm/Standard/Brisk/Showcase presets (they mostly varied speed,
    // which now has its own slider, plus size, which this is).
    $cardW = (int) ($settings['card_width'] ?? 200);
    if ($cardW < 120) $cardW = 120;
    if ($cardW > 360) $cardW = 360;
    $icoSize   = (int) round($cardW * 0.10);
    $titleSize = (int) round(12 + $cardW * 0.018);
    $subSize   = max(10, $titleSize - 3);
    $padV      = (int) round($cardW * 0.07);
    $padH      = (int) round($cardW * 0.08);
    $gap       = (int) round($cardW * 0.07);
    $rowGap    = (int) round(10 + $cardW * 0.02);
    $fade      = 12;

    $speed = (float) ($settings['speed'] ?? 5);
    if ($speed < 1)  $speed = 1;
    if ($speed > 10) $speed = 10;
    // Speed -> px/sec is applied per row (duration = one set width / px-per-sec).

    $pauseHover = ($settings['pause_hover'] ?? true) !== false;
    // Faded edges is per-device (Sergei 2026-07-25): on desktop it softens the
    // strip; on a narrow phone it eats the first and last card. The editor
    // resolves the previewed device here; the frontend emits @media overrides
    // below so one render serves all breakpoints.
    $_mqDevice  = $GLOBALS['impulse_preview_device'] ?? 'desktop';
    $_mqFadeRaw = function_exists('impulse_device_val')
        ? impulse_device_val($settings, 'edge_fade', $_mqDevice, true)
        : ($settings['edge_fade'] ?? true);
    $edgeFade   = ($_mqFadeRaw !== false && $_mqFadeRaw !== '0' && $_mqFadeRaw !== 0);
    // Stable per-element id: the frontend @media overrides below target it,
    // so a tablet/mobile Faded-edges choice reaches the DOM.
    $_mqId = 'imp-mq-' . preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($el['id'] ?? '0'));
    $rowCount   = max(1, min(3, intval($settings['row_count'] ?? 2)));

    // Card mode: icon (default) | image (logo/photo strip) | testimonial.
    $mode = (string) ($settings['mode'] ?? 'icon');
    if (!in_array($mode, ['icon', 'image', 'testimonial'], true)) $mode = 'icon';
    $imageStyle = ((string) ($settings['image_style'] ?? 'photo') === 'logo') ? 'logo' : 'photo';
    $captionPos = (string) ($settings['caption_pos'] ?? 'below');
    if (!in_array($captionPos, ['below', 'overlay', 'hover', 'none'], true)) $captionPos = 'below';

    $cards = $settings['cards'] ?? null;
    if (is_string($cards)) $cards = json_decode($cards, true);
    if (!is_array($cards)) $cards = [];

    // The reviews marquee (demo_role=reviews): the owner's OWN reviews, typed into
    // /dashboard/reviews, replace the sample cards the moment they exist. Same rule as
    // team.php's staff-wins: the template cards are only the empty-state, because sample
    // reviews on a real business's site are fake testimonials with that owner's name
    // over them. Rows cycle to match the template's own strip count, so a two-strip
    // marquee stays a two-strip marquee.
    if (($settings['demo_role'] ?? '') === 'reviews') {
        $mine = impulse_marquee_owner_reviews();
        if ($mine) {
            $stripRows = [];
            foreach ($cards as $c) { if (is_array($c)) $stripRows[(int) ($c['row'] ?? 0)] = true; }
            $nStrips = max(1, count($stripRows));
            $cards = [];
            foreach ($mine as $i => $rv) {
                $cards[] = [
                    'name'   => (string) ($rv['name'] ?? ''),
                    'role'   => '',
                    'quote'  => (string) ($rv['quote'] ?? ''),
                    'rating' => (int) ($rv['rating'] ?? 5),
                    'row'    => $i % $nStrips,
                ];
            }
        }
    }
    if (empty($cards)) $cards = impulse_marquee_default_cards();

    // Bucket cards into their rows (out-of-range folds into the last row).
    $rows = array_fill(0, $rowCount, []);
    foreach ($cards as $c) {
        if (!is_array($c)) continue;
        $r = intval($c['row'] ?? 0);
        if ($r < 0) $r = 0;
        if ($r >= $rowCount) $r = $rowCount - 1;
        $rows[$r][] = $c;
    }

    $hasContent = false;
    foreach ($rows as $rc) { if (!empty($rc)) { $hasContent = true; break; } }
    if (!$hasContent && !$isEditor) return '';

    if (!$isEditor && class_exists('\\Instinctor\\Core\\Assets')) {
        \Instinctor\Core\Assets::enqueue('instinctor-marquee-css');
        // Motion is a pure CSS keyframe (see impulse-marquee.css) — no JS engine.
    }

    $advance = $cardW + $gap; // per-card horizontal advance

    // Optional card-style overrides. Blank colours fall back to the neutral
    // CSS defaults that read on both light and dark pages; colours accept any
    // CSS colour string. Height 0 = auto (fit content). Radius default 12.
    $cardBg      = trim((string) ($settings['card_bg']      ?? ''));
    $textColor   = trim((string) ($settings['text_color']   ?? ''));
    $iconColor   = trim((string) ($settings['icon_color']   ?? ''));
    $borderColor = trim((string) ($settings['border_color'] ?? ''));
    $cardHeight  = max(0, intval($settings['card_height'] ?? 0));
    $cardRadius  = isset($settings['card_radius']) ? max(0, intval($settings['card_radius'])) : 12;

    $wrapStyle = '--mq-card-w:' . $cardW . 'px'
        . ';--mq-gap:' . $gap . 'px'
        . ';--mq-pad-v:' . $padV . 'px'
        . ';--mq-pad-h:' . $padH . 'px'
        . ';--mq-title-size:' . $titleSize . 'px'
        . ';--mq-sub-size:' . $subSize . 'px'
        . ';--mq-fade:' . $fade . '%'
        . ';--mq-radius:' . $cardRadius . 'px'
        . ';--mq-row-gap:' . $rowGap . 'px';
    // Block width as % of the SCREEN: a centered viewport-relative band that
    // escapes the section container (width:Nvw + margin-left:calc(50% - N/2vw)),
    // so 100 = true full-bleed even inside a 1200px container. Applied only
    // when the setting EXISTS - untouched marquees keep the classic container
    // width (no retro layout shift). Legacy max_width px saves still honored.
    // Sergei 2026-07-19.
    if (isset($settings['width']) && $settings['width'] !== '') {
        $mqWpct = max(30, min(100, intval($settings['width'])));
        $wrapStyle .= ';width:' . $mqWpct . 'vw;margin-left:calc(50% - ' . ($mqWpct / 2) . 'vw);margin-right:0;max-width:none';
    } else {
        $mqMaxW = max(0, intval($settings['max_width'] ?? 0));
        if ($mqMaxW > 0) {
            $wrapStyle .= ';max-width:' . $mqMaxW . 'px;margin-left:auto;margin-right:auto';
        } else {
            // No width key at all: render the panel's DEFAULT (100%) as a full
            // viewport band. Without this an untouched marquee sat at container
            // width while the slider read 100, so the first slider touch made
            // the block visibly jump to full width (Sergei 2026-07-20).
            $wrapStyle .= ';width:100vw;margin-left:calc(50% - 50vw);margin-right:0;max-width:none';
        }
    }
    // Blank card_bg renders WHITE - the panel swatch's default. The old CSS
    // fallback (rgba grey ~transparent) meant the swatch read white while the
    // cards rendered see-through until the swatch was touched (Sergei 2026-07-20).
    $wrapStyle .= ';--mq-card-bg:' . ($cardBg !== '' ? $cardBg : '#ffffff');
    if ($textColor   !== '') $wrapStyle .= ';--mq-text:' . $textColor;
    if ($iconColor   !== '') $wrapStyle .= ';--mq-icon-color:' . $iconColor;
    if ($borderColor !== '') $wrapStyle .= ';--mq-border:' . $borderColor;
    if ($cardHeight  > 0)    $wrapStyle .= ';--mq-card-h:' . $cardHeight . 'px';

    ob_start();

    if ($isEditor && function_exists('impulse_visibility_badge')) {
        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- impulse_visibility_badge() composes its markup from esc_attr()'d values internally.
        echo wp_kses(impulse_visibility_badge($settings), impulse_svg_allowed());
    }

    $hoverReveal = !empty($settings['hover_reveal']);

    // Hover zoom: lift the card under the pointer so a trade's work can be seen
    // without a lightbox. Image mode only, because scaling an icon card just makes
    // a blurry icon. Opt-in, so every marquee already on a live page is untouched.
    $hoverZoom = !empty($settings['hover_zoom']) && $mode === 'image';
    $zoomScale = (float) ($settings['hover_zoom_scale'] ?? 1.55);
    if ($zoomScale < 1.05 || $zoomScale > 3.0) $zoomScale = 1.55;

    $modeClass = ' impulse-marquee--' . $mode . ($mode === 'image' ? ' mq-img-' . $imageStyle . ' mq-cap-' . $captionPos : '');
    echo '<div class="impulse-marquee' . esc_attr($modeClass) . '"'
       . ' data-pause="' . ($pauseHover ? '1' : '0') . '"'
       . ($hoverReveal ? ' data-reveal="1"' : '')
       . ($hoverZoom ? ' data-zoom="1"' : '')
       . ' style="' . esc_attr($wrapStyle)
       . ($hoverZoom ? ';--mq-zoom:' . esc_attr((string) $zoomScale) : '') . '">';

    $rendered = 0;
    foreach ($rows as $i => $rowCards) {
        if (empty($rowCards)) continue;
        $rendered++;

        $d = strtolower((string) ($settings['dir_' . $i] ?? ''));
        if ($d !== 'left' && $d !== 'right') $d = ($i % 2 === 0) ? 'left' : 'right';

        $nUser = count($rowCards);
        // Repeat the author's cards until ONE "set" is wider than the widest
        // likely viewport (~4000px covers up to 4K). translateX(-50%) reveals up
        // to one set past the start, so a set narrower than the viewport would
        // expose empty track near the loop point; a wider set keeps it full.
        $reps  = max(1, (int) ceil(4000 / max(1, $nUser * $advance)));
        $perSet = $nUser * $reps; // cards in one set

        $keyframe = ($d === 'right') ? 'impulse-mq-right' : 'impulse-mq-left';
        // CSS keyframe drift: translateX(-50%) equals exactly one set (the track
        // holds two identical sets), so the loop point is seamless AND runs on
        // the compositor with no main-thread transform jump. Duration = time to
        // cross one set at the chosen speed.
        $pxPerSec = 20 + (int) round($speed) * 12;   // speed 1..10 -> 32..140 px/s
        $duration = max(4, (int) round(($perSet * $advance) / max(1, $pxPerSec)));

        echo '<div id="' . esc_attr($_mqId . '-r' . $i) . '" class="impulse-marquee-row' . ($edgeFade ? ' mq-fade' : '') . '">';
        echo '<div class="impulse-marquee-track" style="animation:'
           . esc_attr($keyframe . ' ' . $duration . 's linear infinite') . '">';

        // Two identical sets of [author cards x reps] -> translateX(-50%) is one
        // set. The first author pass is exposed; the rest are hidden clones so
        // each card is announced / tabbed exactly once.
        $slideIdx = 0;
        for ($set = 0; $set < 2; $set++) {
            for ($rep = 0; $rep < $reps; $rep++) {
                foreach ($rowCards as $c) {
                    $clone = ($slideIdx >= $nUser);
                    impulse_marquee_card($c, $icoSize, $isEditor, $clone, $hoverReveal, $mode);
                    $slideIdx++;
                }
            }
        }

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

    if ($rendered === 0 && $isEditor) {
        echo '<div style="padding:24px;text-align:center;font-size:13px;color:#999;border:1px dashed #d0d0d0;border-radius:8px">Add cards to the marquee.</div>';
    }

    echo '</div>';

    // Per-device Faded edges (frontend): one render serves every breakpoint, so
    // tablet/mobile overrides ride @media rules keyed to each row's id. The mask
    // shorthand is repeated rather than toggling the class, because the class is
    // baked at render time from the DESKTOP value.
    if (!$isEditor && function_exists('impulse_responsive_css') && function_exists('wp_add_inline_style')) {
        $_mqFadeCss = '';
        foreach (['tablet' => 1024, 'mobile' => 767] as $_dev => $_bp) {
            $_k = 'edge_fade_' . $_dev;
            if (!isset($settings[$_k]) || $settings[$_k] === '' || $settings[$_k] === null) continue;
            $_on = ($settings[$_k] !== false && $settings[$_k] !== '0' && $settings[$_k] !== 0);
            $_decl = $_on
                ? '-webkit-mask-image:linear-gradient(90deg,transparent 0,#000 var(--mq-fade,12%),#000 calc(100% - var(--mq-fade,12%)),transparent 100%)!important;mask-image:linear-gradient(90deg,transparent 0,#000 var(--mq-fade,12%),#000 calc(100% - var(--mq-fade,12%)),transparent 100%)!important;'
                : '-webkit-mask-image:none!important;mask-image:none!important;';
            $_sel = [];
            for ($_r = 0; $_r < 6; $_r++) $_sel[] = '#' . $_mqId . '-r' . $_r;
            $_mqFadeCss .= '@media(max-width:' . $_bp . 'px){' . implode(',', $_sel) . '{' . $_decl . '}}';
        }
        if ($_mqFadeCss !== '') wp_add_inline_style('instinctor-page-inline', $_mqFadeCss);
    }

    return (string) ob_get_clean();
}

/**
 * Echo one marquee card, dispatching on the block's card mode. Clones (loop
 * repeats) are hidden from assistive tech + keyboard so each real card is
 * announced and tabbed exactly once. Modes: 'icon' (icon + title + subtitle,
 * optional hover-reveal detail), 'image' (logo/photo strip), 'testimonial'
 * (quote + stars + avatar/name/role). The card frame + link are shared; only
 * the inner body differs by mode.
 */
function impulse_marquee_card(array $c, int $icoSize, bool $isEditor, bool $clone, bool $reveal = false, string $mode = 'icon'): void {
    $link   = (string) ($c['link'] ?? '');
    $newTab = !empty($c['new_tab']);
    $isLink = ($link !== '' && !$isEditor);

    echo $isLink
        ? '<a class="impulse-marquee-card" href="' . esc_url($link) . '"'
            . ($newTab ? ' target="_blank" rel="noopener"' : '')
            . ($clone ? ' aria-hidden="true" tabindex="-1"' : '') . '>'
        : '<div class="impulse-marquee-card"' . ($clone ? ' aria-hidden="true"' : '') . '>';

    if ($mode === 'image') {
        impulse_marquee_card_image($c);
    } elseif ($mode === 'testimonial') {
        impulse_marquee_card_testimonial($c);
    } else {
        impulse_marquee_card_icon($c, $icoSize, $reveal);
    }

    echo $isLink ? '</a>' : '</div>';
}

/** Icon-card body: icon + title + subtitle, optional hover-reveal 'back' detail. */
function impulse_marquee_card_icon(array $c, int $icoSize, bool $reveal): void {
    $icon  = (string) ($c['icon']     ?? '');
    $title = (string) ($c['title']    ?? '');
    $sub   = (string) ($c['subtitle'] ?? '');
    $back  = (string) ($c['back']     ?? '');
    $svg   = ($icon !== '' && function_exists('impulse_lucide_svg')) ? impulse_lucide_svg($icon, $icoSize) : '';
    echo '<span class="impulse-marquee-front">';
    if ($svg !== '') echo '<span class="impulse-marquee-ico">' . wp_kses($svg, impulse_svg_allowed()) . '</span>';
    echo '<span class="impulse-marquee-tt">';
    if ($title !== '') echo '<span class="impulse-marquee-title">' . esc_html($title) . '</span>';
    if ($sub   !== '') echo '<span class="impulse-marquee-sub">' . esc_html($sub) . '</span>';
    echo '</span></span>';
    if ($reveal && $back !== '') echo '<span class="impulse-marquee-back">' . esc_html($back) . '</span>';
}

/** Image-card body: a logo or photo (logo/photo style is a block-level class), optional caption. */
function impulse_marquee_card_image(array $c): void {
    $url = impulse_marquee_img_url($c, 'image');
    $cap = (string) ($c['caption'] ?? '');
    if ($url !== '') {
        echo '<img class="impulse-marquee-img" src="' . esc_url($url) . '" alt="' . esc_attr($cap) . '" loading="lazy">';
    } else {
        echo '<span class="impulse-marquee-img impulse-marquee-img-ph" aria-hidden="true"></span>';
    }
    if ($cap !== '') echo '<span class="impulse-marquee-cap">' . esc_html($cap) . '</span>';
}

/** Testimonial-card body: star rating + quote + avatar/name/role. */
function impulse_marquee_card_testimonial(array $c): void {
    $avatar = impulse_marquee_img_url($c, 'avatar');
    $name   = (string) ($c['name']  ?? '');
    $role   = (string) ($c['role']  ?? '');
    $quote  = (string) ($c['quote'] ?? '');
    $rating = (int) round((float) ($c['rating'] ?? 5));
    if ($rating < 0) $rating = 0;
    if ($rating > 5) $rating = 5;
    if ($rating > 0) impulse_marquee_stars($rating);
    if ($quote !== '') echo '<span class="impulse-marquee-quote">' . esc_html($quote) . '</span>';
    echo '<span class="impulse-marquee-person">';
    if ($avatar !== '') echo '<img class="impulse-marquee-avatar" src="' . esc_url($avatar) . '" alt="' . esc_attr($name) . '" loading="lazy">';
    echo '<span class="impulse-marquee-person-tt">';
    if ($name !== '') echo '<span class="impulse-marquee-name">' . esc_html($name) . '</span>';
    if ($role !== '') echo '<span class="impulse-marquee-role">' . esc_html($role) . '</span>';
    echo '</span></span>';
}

/** Resolve a card image/avatar URL: prefer a fresh resolve from the stored id
 *  (handles the standalone library + WP attachment fallback), else the saved URL. */
function impulse_marquee_img_url(array $c, string $key): string {
    $id = intval($c[$key . '_id'] ?? 0);
    if ($id > 0 && function_exists('impulse_resolve_image_url')) {
        $u = impulse_resolve_image_url($id, 'large');
        if ($u !== '') return $u;
    }
    return (string) ($c[$key] ?? '');
}

/** Echo a 5-star row, gold-filled to $rating (star colour is CSS-driven). */
function impulse_marquee_stars(int $rating): void {
    $star = wp_kses(
        '<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14l-5-4.87 6.91-1.01L12 2z"/></svg>',
        impulse_svg_allowed()
    );
    echo '<span class="impulse-marquee-stars" role="img" aria-label="' . esc_attr($rating . ' out of 5 stars') . '">';
    for ($i = 1; $i <= 5; $i++) {
        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $star is a static wp_kses'd literal SVG.
        echo '<span class="impulse-marquee-star' . ($i <= $rating ? ' on' : '') . '">' . $star . '</span>';
    }
    echo '</span>';
}

/** Default demo cards for a freshly-inserted block — two opposing rows. */
/**
 * The owner's own reviews, typed into /dashboard/reviews.
 *
 * ownerOnly, never Config::get: the key falls back to the GLOBAL value for a tenant who
 * has not saved any, which would put the VENDOR's reviews on a tenant's site. Owner-scoped
 * on the shared app, so one business's reviews can never surface on another's page.
 *
 * @return array<int,array{name:string,quote:string,rating:int}>
 */
/**
 * A trade's work photos, grouped by the service they belong to.
 *
 * Only services that HAVE photos come back: a handyman with pictures of two of his six
 * services gets two strips, not six, four of them empty (Sergei 2026-07-28). The other
 * four still appear in the services list, which is a different section.
 *
 * Capped at MAX_STRIPS services and MAX_PER_STRIP photos each. The cap is here as well
 * as at upload because an owner who uploaded before the limit existed, or through some
 * other path, must not be able to put a hundred images on one page.
 */
/**
 * The empty state: up to MAX_STRIPS of the owner's services, each with placeholder
 * tiles. Only ever used when NO service has photos yet.
 */
function impulse_marquee_service_placeholders(): array
{
    if (!class_exists('\Instinctor\Core\DB')) return [];
    try {
        $rows = \Instinctor\Core\DB::get_results(
            "SELECT name FROM " . \Instinctor\Core\DB::prefix() . "instinctor_services
              WHERE status = 'active' AND " . \Instinctor\Core\Tenant::scopeSql('owner_id') . "
              ORDER BY sort_order ASC, id ASC LIMIT 4"
        );
    } catch (\Throwable $e) {
        return [];
    }
    $out = [];
    foreach ((array) $rows as $r) {
        $name = trim((string) ($r->name ?? ''));
        if ($name === '') continue;
        // Six blanks: enough to read as a strip waiting to be filled, few enough that
        // it does not look like something failed to load.
        $out[] = ['name' => $name, 'photos' => array_fill(0, 6, '')];
    }
    return $out;
}

function impulse_marquee_service_photos(): array
{
    if (!class_exists('\Instinctor\Core\DB')) return [];
    // Plan-aware: free shows ONE complete strip, Pro shows four. The cap is storage,
    // and a strip with two photos cycles as smoothly as one with twelve, so the free
    // row is a working feature rather than a crippled one (Sergei 2026-07-28).
    $MAX_STRIPS = class_exists('\Instinctor\Services\ServicePhotos')
        ? \Instinctor\Services\ServicePhotos::maxServices()
        : 1;
    $MAX_PER_STRIP = 12;

    try {
        $rows = \Instinctor\Core\DB::get_results(
            "SELECT name, photos FROM " . \Instinctor\Core\DB::prefix() . "instinctor_services
              WHERE status = 'active' AND photos IS NOT NULL AND photos <> ''
                AND " . \Instinctor\Core\Tenant::scopeSql('owner_id') . "
              ORDER BY sort_order ASC, id ASC"
        );
    } catch (\Throwable $e) {
        return [];
    }

    $out = [];
    foreach ((array) $rows as $r) {
        $name = trim((string) ($r->name ?? ''));
        if ($name === '') continue;
        $pics = json_decode((string) ($r->photos ?? ''), true);
        if (!is_array($pics)) continue;
        $urls = [];
        foreach ($pics as $u) {
            $u = trim((string) (is_array($u) ? ($u['url'] ?? '') : $u));
            // Storage-relative, or absolute ON THIS SITE. The first version of this
            // check allowed any https:// URL while the comment claimed otherwise, so a
            // stored off-site address would have been rendered and hotlinked (caught by
            // the off-site test, 2026-07-28).
            if ($u === '') continue;
            if ($u[0] !== '/') {
                $host = strtolower((string) parse_url($u, PHP_URL_HOST));
                $mine = strtolower((string) parse_url((string) (function_exists('home_url') ? home_url('/') : ''), PHP_URL_HOST));
                if ($host === '' || $mine === '' || $host !== $mine) continue;
            }
            $urls[] = $u;
            if (count($urls) >= $MAX_PER_STRIP) break;
        }
        if (!$urls) continue;               // a service with an empty array gets no strip
        $out[] = ['name' => $name, 'photos' => $urls];
        if (count($out) >= $MAX_STRIPS) break;
    }
    return $out;
}

function impulse_marquee_owner_reviews(): array
{
    if (!class_exists('\\Instinctor\\Core\\Config')) return [];
    $raw = \Instinctor\Core\Config::ownerOnly(
        (int) (\Instinctor\Core\Tenant::currentOwnerId() ?? 0),
        'instinctor_site_reviews',
        ''
    );
    if (is_string($raw) && $raw !== '') $raw = json_decode($raw, true);
    if (!is_array($raw)) return [];
    $out = [];
    foreach ($raw as $rv) {
        if (!is_array($rv)) continue;
        $quote = trim((string) ($rv['quote'] ?? ''));
        if ($quote === '') continue;
        $out[] = [
            'name'   => trim((string) ($rv['name'] ?? '')),
            'quote'  => $quote,
            'rating' => max(1, min(5, (int) ($rv['rating'] ?? 5))),
        ];
    }
    return $out;
}

function impulse_marquee_default_cards(): array {
    return [
        ['icon' => 'search',         'title' => 'Found on Google', 'subtitle' => 'Built-in SEO',       'link' => '', 'new_tab' => false, 'row' => 0],
        ['icon' => 'shopping-bag',   'title' => 'Storefront',      'subtitle' => 'Sell online',        'link' => '', 'new_tab' => false, 'row' => 0],
        ['icon' => 'clipboard-list', 'title' => 'Live menus',      'subtitle' => 'Update everywhere',  'link' => '', 'new_tab' => false, 'row' => 0],
        ['icon' => 'credit-card',    'title' => 'Payments',        'subtitle' => 'Cards and wallets',  'link' => '', 'new_tab' => false, 'row' => 0],
        ['icon' => 'calendar',       'title' => 'Bookings',        'subtitle' => 'Schedule and pay',   'link' => '', 'new_tab' => false, 'row' => 1],
        ['icon' => 'message-circle', 'title' => 'Smart chat',      'subtitle' => 'SMS and WhatsApp',   'link' => '', 'new_tab' => false, 'row' => 1],
        ['icon' => 'star',           'title' => 'Reviews',         'subtitle' => 'Social proof',       'link' => '', 'new_tab' => false, 'row' => 1],
        ['icon' => 'database',       'title' => 'Your data',       'subtitle' => 'All in one place',   'link' => '', 'new_tab' => false, 'row' => 1],
    ];
}
