<?php
/**
 * Brentwood Images — front-end render (single image card).
 *
 * One image card: an image with an optional number badge, a label + chevron /
 * dash overlay (positioned top / center / bottom), and a caption below. On click
 * the card either links somewhere (optionally a new tab) or opens a popup dialog
 * whose content is the block's own InnerBlocks — exactly like bw/interlinking.
 *
 * Drop several of these into a Row / Columns layout to build a grid.
 *
 * @var array    $attributes Block attributes.
 * @var string   $content    Inner content (the popup InnerBlocks, when modal).
 * @var WP_Block $block      Block instance.
 */

defined( 'ABSPATH' ) || exit;

if ( ! function_exists( 'bw_cg_color' ) ) {
	/**
	 * Accept a CSS colour only if it is a hex, rgb()/rgba() or var() value, else
	 * fall back to the supplied default.
	 */
	function bw_cg_color( $value, $default = '' ) {
		$value = trim( (string) $value );
		if ( '' === $value ) {
			return $default;
		}
		if ( preg_match( '/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i', $value )
			|| preg_match( '/^rgba?\(\s*[0-9.,%\s\/]+\)$/i', $value )
			|| preg_match( '/^var\(\s*--[a-z0-9\-]+\s*\)$/i', $value ) ) {
			return $value;
		}
		return $default;
	}
}

if ( ! function_exists( 'bw_cg_chevron' ) ) {
	/**
	 * The inline chevron drawn right after the label (FontAwesome angle-right,
	 * matching brentwood.ca's link photo blocks).
	 *
	 * @param string $color Chevron colour.
	 * @return string HTML.
	 */
	function bw_cg_chevron( $color ) {
		return '<span class="bw-cg-card__chevron" style="color:' . esc_attr( $color ) . '" aria-hidden="true">'
			. '<svg viewBox="0 0 320 512" role="img" focusable="false"><path fill="currentColor" d="M278.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-160 160c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L210.7 256 73.4 118.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l160 160z"></path></svg>'
			. '</span>';
	}
}

if ( ! function_exists( 'bw_cg_magnifier' ) ) {
	/**
	 * The magnifying-glass icon shown bottom-right on a lightbox card (FontAwesome
	 * magnifying-glass), signalling the image opens in a lightbox on click.
	 *
	 * @return string HTML.
	 */
	function bw_cg_magnifier() {
		// FontAwesome search-plus (magnifying-glass-plus) — matches the live
		// brentwood.ca photo "enlarge" icon.
		return '<span class="bw-cg-card__zoom" aria-hidden="true">'
			. '<svg viewBox="0 0 512 512" role="img" focusable="false"><path fill="currentColor" d="M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM184 296c0 13.3 10.7 24 24 24s24-10.7 24-24V232h64c13.3 0 24-10.7 24-24s-10.7-24-24-24H232V120c0-13.3-10.7-24-24-24s-24 10.7-24 24v64H120c-13.3 0-24 10.7-24 24s10.7 24 24 24h64v64z"></path></svg>'
			. '</span>';
	}
}

if ( ! function_exists( 'bw_cg_dash' ) ) {
	/**
	 * The dash divider drawn on its own line below the label — the live site's
	 * dash.svg (a flat 64×4 bar), as a CSS-coloured rule so it can be recoloured.
	 *
	 * @param string $color Dash colour.
	 * @return string HTML.
	 */
	function bw_cg_dash( $color ) {
		return '<span class="bw-cg-card__dash" style="background:' . esc_attr( $color ) . '" aria-hidden="true"></span>';
	}
}

if ( ! function_exists( 'bw_cg_arrow' ) ) {
	/**
	 * A prev / next arrow for the slider (FontAwesome angle-right; the prev arrow is
	 * flipped in CSS). Same icon family as the inline chevron.
	 *
	 * @return string HTML.
	 */
	function bw_cg_arrow() {
		return '<svg viewBox="0 0 320 512" role="img" aria-hidden="true" focusable="false"><path fill="currentColor" d="M278.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-160 160c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L210.7 256 73.4 118.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l160 160z"></path></svg>';
	}
}

// ── sanitize attributes ──────────────────────────────────────────────────────
$image_id  = (int) ( $attributes['imageId'] ?? 0 );
$image_url = esc_url_raw( $attributes['imageUrl'] ?? '' );
$alt       = sanitize_text_field( $attributes['imageAlt'] ?? '' );
$number    = sanitize_text_field( $attributes['number'] ?? '' );
$label     = sanitize_text_field( $attributes['label'] ?? '' );
$caption   = sanitize_text_field( $attributes['caption'] ?? '' );

// Nothing to show.
$has_slides = ! empty( $attributes['images'] ) && is_array( $attributes['images'] );
if ( '' === $image_url && 0 === $image_id && '' === $label && '' === $number && '' === $caption && ! $has_slides ) {
	return;
}

$layout       = in_array( $attributes['layout'] ?? 'label', array( 'label', 'caption', 'slider', 'lightbox' ), true ) ? $attributes['layout'] : 'label';
// Overlay style: "default" = small corner number badge + big label; "stat" =
// the number shown large on the first line with the label as the caption below
// (mirrors the live brentwood.ca "72% / Accepted into their first choice
// university" figures). The previous default style is unchanged.
$overlay_style = in_array( $attributes['overlayStyle'] ?? 'default', array( 'default', 'stat' ), true ) ? ( $attributes['overlayStyle'] ?? 'default' ) : 'default';
$is_stat      = ( 'stat' === $overlay_style );
$show_label   = ! isset( $attributes['showLabel'] ) || false !== $attributes['showLabel'];
$show_chevron = ! empty( $attributes['showChevron'] );
$show_dash    = ! empty( $attributes['showDash'] );
$valign       = in_array( $attributes['valign'] ?? 'bottom', array( 'top', 'center', 'bottom' ), true ) ? $attributes['valign'] : 'bottom';
$halign       = in_array( $attributes['halign'] ?? 'left', array( 'left', 'center', 'right' ), true ) ? $attributes['halign'] : 'left';
$cap_align    = in_array( $attributes['captionAlign'] ?? 'left', array( 'left', 'center', 'right' ), true ) ? $attributes['captionAlign'] : 'left';
$cap_width    = ( 'full' === ( $attributes['captionWidth'] ?? 'fit' ) ) ? 'full' : 'fit';
// Defined here (not next to $caption_html below) because the slider loop needs it
// too — each slide carries its own caption text and this is the shared on/off.
$show_caption = ! isset( $attributes['showCaption'] ) || false !== $attributes['showCaption'];
$behavior     = ( 'modal' === ( $attributes['behavior'] ?? 'link' ) ) ? 'modal' : 'link';

$label_color   = bw_cg_color( $attributes['labelColor'] ?? '', '#ffffff' );
$chevron_color = bw_cg_color( $attributes['chevronColor'] ?? '', '#ffffff' );
$dash_color    = bw_cg_color( $attributes['dashColor'] ?? '', '#ffffff' );

$radius  = max( 0, min( 60, (int) ( $attributes['radius'] ?? 4 ) ) );
$ratio   = trim( (string) ( $attributes['ratio'] ?? '1 / 1' ) );
// "fill"     — the card follows its section / column height IN FLOW, so it can
//              also define the row height (a shorter neighbour stretches to it).
// "fill-col" — the card also matches the column height but stays OUT OF FLOW, so
//              it can NEVER define the row height: a taller neighbour sizes the
//              row and the image conforms/crops to it, without stretching it.
$is_fill     = ( 'fill' === $ratio );
$is_fill_col = ( 'fill-col' === $ratio );
if ( ! $is_fill && ! $is_fill_col && ! preg_match( '/^[0-9]+(\.[0-9]+)?\s*\/\s*[0-9]+(\.[0-9]+)?$/', $ratio ) ) {
	$ratio = '1 / 1';
}

// "Show whole image" — object-fit:contain, so the image keeps its own ratio
// inside the frame and the leftover area shows the frame background colour.
$fit            = ( 'contain' === ( $attributes['imageFit'] ?? 'cover' ) ) ? 'contain' : 'cover';
$frame_bg       = bw_cg_color( $attributes['frameBg'] ?? '', '' );
$frame_bg_hover = bw_cg_color( $attributes['frameBgHover'] ?? '', '' );

// Focal point → object-position.
$fp  = is_array( $attributes['focalPoint'] ?? null ) ? $attributes['focalPoint'] : array();
$fx  = isset( $fp['x'] ) ? max( 0, min( 1, (float) $fp['x'] ) ) : 0.5;
$fy  = isset( $fp['y'] ) ? max( 0, min( 1, (float) $fp['y'] ) ) : 0.5;
$obj = round( $fx * 100, 2 ) . '% ' . round( $fy * 100, 2 ) . '%';

// Image source (a single large source, scaled by object-fit:cover).
$src = '';
if ( $image_id > 0 ) {
	$src = wp_get_attachment_image_url( $image_id, 'large' );
	if ( ! $src ) {
		$src = wp_get_attachment_image_url( $image_id, 'full' );
	}
	if ( '' === $alt ) {
		$alt = (string) get_post_meta( $image_id, '_wp_attachment_image_alt', true );
	}
}
if ( ! $src ) {
	$src = $image_url;
}

// Full-size source for the lightbox layout (falls back to the display source).
$full_src = '';
if ( $image_id > 0 ) {
	$full_src = wp_get_attachment_image_url( $image_id, 'full' );
}
if ( ! $full_src ) {
	$full_src = ( '' !== $src ) ? $src : $image_url;
}

$img_html = ( '' !== $src )
	? '<img class="bw-cg-card__img" src="' . esc_url( $src ) . '" alt="' . esc_attr( $alt ) . '" loading="lazy" decoding="async" style="object-position:' . esc_attr( $obj ) . '" />'
	: '<span class="bw-cg-card__img bw-cg-card__img--empty" aria-hidden="true"></span>';

// ── slider slides (only used by the "slider" layout) ─────────────────────────
$slides_html = '';
$dots_html   = '';
$arrows_html = '';
$slide_count = 0;
// Set when at least one slide has caption text — the box then gets a class that
// lifts the dots / timer clear of the caption bar (they share the bottom edge).
$has_slide_caption = false;
if ( 'slider' === $layout ) {
	$slides = $has_slides ? $attributes['images'] : array();
	// Fall back to the single image if the gallery is empty.
	if ( empty( $slides ) && '' !== $src ) {
		$slides = array( array( 'id' => $image_id, 'url' => $src, 'alt' => $alt ) );
	}
	$slide_items = '';
	$dot_items   = '';
	foreach ( $slides as $slide ) {
		$s_id  = (int) ( $slide['id'] ?? 0 );
		$s_url = esc_url_raw( $slide['url'] ?? '' );
		$s_alt = sanitize_text_field( $slide['alt'] ?? '' );
		$s_src = '';
		if ( $s_id > 0 ) {
			$s_src = wp_get_attachment_image_url( $s_id, 'large' );
			if ( ! $s_src ) {
				$s_src = wp_get_attachment_image_url( $s_id, 'full' );
			}
			if ( '' === $s_alt ) {
				$s_alt = (string) get_post_meta( $s_id, '_wp_attachment_image_alt', true );
			}
		}
		if ( ! $s_src ) {
			$s_src = $s_url;
		}
		if ( '' === $s_src ) {
			continue;
		}
		// Per-slide caption. It lives INSIDE the slide, so it fades in and out with
		// its own image (the .is-active opacity transition) — view.js needs no
		// changes. Width / alignment come from the shared caption attributes, so
		// every slide's bar looks the same; only the text differs.
		$s_cap      = sanitize_text_field( $slide['caption'] ?? '' );
		$s_cap_html = ( $show_caption && '' !== $s_cap )
			? '<span class="bw-cg-card__caption bw-cg-card__caption--' . esc_attr( $cap_width )
				. ' bw-cg-card__caption--align-' . esc_attr( $cap_align ) . '">' . esc_html( $s_cap ) . '</span>'
			: '';
		if ( '' !== $s_cap_html ) {
			$has_slide_caption = true;
		}

		$active       = ( 0 === $slide_count ) ? ' is-active' : '';
		$slide_items .= '<div class="bw-cg-card__slide' . $active . '" data-index="' . $slide_count . '">'
			. '<img class="bw-cg-card__img" src="' . esc_url( $s_src ) . '" alt="' . esc_attr( $s_alt ) . '"'
			. ( $slide_count > 0 ? ' loading="lazy"' : '' ) . ' decoding="async" />'
			. $s_cap_html
			. '</div>';
		$dot_items .= '<button type="button" class="bw-cg-card__dot' . $active . '" data-index="' . $slide_count . '"'
			. ' aria-label="' . esc_attr( sprintf( 'Go to slide %d', $slide_count + 1 ) ) . '"></button>';
		$slide_count++;
	}
	if ( $slide_count > 0 ) {
		$slides_html = '<div class="bw-cg-card__slides">' . $slide_items . '</div>';
		if ( $slide_count > 1 ) {
			// Dots show by default; hide them via the "Show dots" toggle.
			$show_dots = ! isset( $attributes['sliderDots'] ) || false !== $attributes['sliderDots'];
			if ( $show_dots ) {
				$dots_html = '<div class="bw-cg-card__dots">' . $dot_items . '</div>';
			}
			if ( ! empty( $attributes['sliderArrows'] ) ) {
				$arrows_html = '<button type="button" class="bw-cg-card__arrow bw-cg-card__arrow--prev" aria-label="Previous slide">' . bw_cg_arrow() . '</button>'
					. '<button type="button" class="bw-cg-card__arrow bw-cg-card__arrow--next" aria-label="Next slide">' . bw_cg_arrow() . '</button>';
			}
		}
	}
}

$show_number = ! isset( $attributes['showNumber'] ) || false !== $attributes['showNumber'];
// The corner badge is only drawn in the default style — in "stat" mode the same
// number is rendered large inside the overlay instead (below), so it isn't
// duplicated in the corner.
$num_html    = ( ! $is_stat && $show_number && '' !== $number )
	? '<span class="bw-cg-card__num">' . esc_html( $number ) . '</span>'
	: '';

// The label sits on its own line with the chevron inline right after it; the dash
// is a divider on the line below (mirrors brentwood.ca's photo blocks). The label
// text can be hidden (e.g. a slider that only wants the number badge) while the
// chevron stays under its own toggle.
$label_span = ( $show_label && '' !== $label )
	? '<span class="bw-cg-card__label" style="color:' . esc_attr( $label_color ) . '">' . esc_html( $label ) . '</span>'
	: '';
$chevron_span = $show_chevron ? bw_cg_chevron( $chevron_color ) : '';
$line_html    = ( '' !== $label_span || '' !== $chevron_span )
	? '<span class="bw-cg-card__line">' . $label_span . $chevron_span . '</span>'
	: '';
$dash_html = $show_dash ? bw_cg_dash( $dash_color ) : '';

// Stat number — the big first line (uses the same colour as the label so both
// lines match). Only in "stat" mode, and only when a number is set + shown.
$statnum_html = ( $is_stat && $show_number && '' !== $number )
	? '<span class="bw-cg-card__statnum" style="color:' . esc_attr( $label_color ) . '">' . esc_html( $number ) . '</span>'
	: '';

$overlay_html = '';
if ( '' !== $statnum_html || '' !== $line_html || '' !== $dash_html ) {
	$overlay_class = 'bw-cg-card__overlay bw-cg-card__overlay--' . $valign . ' bw-cg-card__overlay--h-' . $halign;
	if ( $is_stat ) {
		$overlay_class .= ' bw-cg-card__overlay--stat';
	}
	$overlay_html = '<span class="' . esc_attr( $overlay_class ) . '">'
		. $statnum_html . $line_html . $dash_html
		. '</span>';
}

// Caption — a bottom overlay ON the image (black box, white text), mirroring
// brentwood.ca's photo "description" line and the video-text layout-6 caption.
// Lives inside the image box. The background can fit the text (--fit) or span the
// full width (--full), and the text aligns left / center / right.
// ($show_caption is defined near the top — the slider loop above needs it too.)
$caption_html = ( $show_caption && '' !== $caption )
	? '<span class="bw-cg-card__caption bw-cg-card__caption--' . esc_attr( $cap_width )
		. ' bw-cg-card__caption--align-' . esc_attr( $cap_align ) . '">' . esc_html( $caption ) . '</span>'
	: '';

// "Image + Caption" drops the number badge and the label / chevron / dash overlay.
// "Slider" swaps the single image for a slideshow of slides + dots (+ optional
// arrows), keeping the same number / label overlay. "Image + Label" keeps them all.
if ( 'caption' === $layout ) {
	$box_inner = $img_html . $caption_html;
} elseif ( 'slider' === $layout ) {
	$box_inner = $slides_html . $num_html . $overlay_html . $arrows_html . $dots_html;
} elseif ( 'lightbox' === $layout ) {
	// The image + a magnifying-glass hint; clicking opens the lightbox. An
	// optional caption bar can sit along the bottom (same as the caption layout).
	$box_inner = $img_html . $caption_html . bw_cg_magnifier();
} else {
	$box_inner = $img_html . $num_html . $overlay_html . $caption_html;
}
$aria = ( '' !== $label ) ? $label : ( '' !== $caption ? $caption : ( '' !== $alt ? $alt : 'View image' ) );

$dialog_html = '';
if ( 'lightbox' === $layout ) {
	// A button that opens the full-size image in the shared lightbox (view.js).
	$has_caption_cls = ( '' !== $caption_html ) ? ' bw-cg-card__box--has-caption' : '';
	$box = '<button type="button" class="bw-cg-card__box bw-cg-card__box--lightbox' . $has_caption_cls . '"'
		. ' data-bw-cg-lightbox data-full="' . esc_url( $full_src ) . '"'
		. ' aria-label="' . esc_attr( $aria ) . '">' . $box_inner . '</button>';
} elseif ( 'slider' === $layout ) {
	// The slider keeps its on-click behaviour, but the link / modal trigger can't
	// WRAP the dots / arrows (they're <button>s — invalid inside <a>/<button>, and
	// it would swallow their clicks). Instead a full-cover "hit" layer sits ABOVE
	// the image but BELOW the dots: clicking the photo navigates / opens the popup,
	// clicking a dot still changes slide.
	$autoplay = ! empty( $attributes['sliderAutoplay'] ) ? '1' : '0';
	$interval = max( 2, min( 20, (int) ( $attributes['sliderInterval'] ?? 5 ) ) );

	$hit_html = '';
	if ( 'modal' === $behavior ) {
		$uid        = wp_unique_id( 'bw-cg-modal-' );
		$modal_size = in_array( $attributes['modalSize'] ?? 'medium', array( 'small', 'medium', 'large', 'full' ), true ) ? $attributes['modalSize'] : 'medium';

		$hit_html = '<button type="button" class="bw-cg-card__hit" aria-haspopup="dialog"'
			. ' data-bw-cg-modal="' . esc_attr( $uid ) . '" aria-label="' . esc_attr( $aria ) . '"></button>';

		$close = '<button type="button" class="bw-cg-modal__close" aria-label="Close" data-bw-cg-modal-close>'
			. '<svg viewBox="0 0 24 24" role="img" aria-hidden="true" focusable="false"><path fill="currentColor" d="M18.3 5.71a1 1 0 0 0-1.41 0L12 10.59 7.11 5.7A1 1 0 1 0 5.7 7.11L10.59 12 5.7 16.89a1 1 0 1 0 1.41 1.41L12 13.41l4.89 4.89a1 1 0 0 0 1.41-1.41L13.41 12l4.89-4.89a1 1 0 0 0 0-1.4z"></path></svg>'
			. '</button>';

		$dialog_html = '<dialog id="' . esc_attr( $uid ) . '" class="bw-cg-modal bw-cg-modal--' . esc_attr( $modal_size ) . '"'
			. ( ( '' !== $label ) ? ' aria-label="' . esc_attr( $label ) . '"' : '' ) . '>'
			. '<div class="bw-cg-modal__inner">' . $close
			. '<div class="bw-cg-modal__content">' . $content . '</div>'
			. '</div></dialog>';
	} else {
		$link_url = esc_url_raw( $attributes['linkUrl'] ?? '' );
		if ( '' !== $link_url ) {
			$target   = ! empty( $attributes['linkNewTab'] ) ? ' target="_blank" rel="noopener noreferrer"' : '';
			$hit_html = '<a class="bw-cg-card__hit" href="' . esc_url( $link_url ) . '"' . $target
				. ' aria-label="' . esc_attr( $aria ) . '"></a>';
		}
	}

	// Autoplay progress line at the bottom of the image (mirrors brentwood.ca's
	// slideshow-timer: a white/20% bar that scales 0 → 1 over the interval). Only
	// when autoplay is on and there is more than one slide.
	$timer_html = ( '1' === $autoplay && $slide_count > 1 )
		? '<div class="bw-cg-card__timer" aria-hidden="true">'
			. '<span class="bw-cg-card__timer-track"></span>'
			. '<span class="bw-cg-card__timer-fill" style="animation-duration:' . esc_attr( $interval ) . 's"></span>'
			. '</div>'
		: '';

	$box = '<div class="bw-cg-card__box bw-cg-card__box--slider' . ( '' === $hit_html ? ' bw-cg-card__box--static' : '' )
		. ( $has_slide_caption ? ' bw-cg-card__box--has-caption' : '' ) . '"'
		. ' data-bw-cg-slider data-autoplay="' . esc_attr( $autoplay ) . '" data-interval="' . esc_attr( $interval ) . '">'
		. $slides_html . $hit_html . $num_html . $overlay_html . $arrows_html . $dots_html . $timer_html . '</div>';
} elseif ( 'modal' === $behavior ) {
	$uid        = wp_unique_id( 'bw-cg-modal-' );
	$modal_size = in_array( $attributes['modalSize'] ?? 'medium', array( 'small', 'medium', 'large', 'full' ), true ) ? $attributes['modalSize'] : 'medium';

	$box = '<button type="button" class="bw-cg-card__box bw-cg-card__box--modal" aria-haspopup="dialog"'
		. ' data-bw-cg-modal="' . esc_attr( $uid ) . '" aria-label="' . esc_attr( $aria ) . '">'
		. $box_inner . '</button>';

	$close = '<button type="button" class="bw-cg-modal__close" aria-label="Close" data-bw-cg-modal-close>'
		. '<svg viewBox="0 0 24 24" role="img" aria-hidden="true" focusable="false"><path fill="currentColor" d="M18.3 5.71a1 1 0 0 0-1.41 0L12 10.59 7.11 5.7A1 1 0 1 0 5.7 7.11L10.59 12 5.7 16.89a1 1 0 1 0 1.41 1.41L12 13.41l4.89 4.89a1 1 0 0 0 1.41-1.41L13.41 12l4.89-4.89a1 1 0 0 0 0-1.4z"></path></svg>'
		. '</button>';

	$dialog_html = '<dialog id="' . esc_attr( $uid ) . '" class="bw-cg-modal bw-cg-modal--' . esc_attr( $modal_size ) . '"'
		. ( ( '' !== $label ) ? ' aria-label="' . esc_attr( $label ) . '"' : '' ) . '>'
		. '<div class="bw-cg-modal__inner">' . $close
		. '<div class="bw-cg-modal__content">' . $content . '</div>'
		. '</div></dialog>';
} else {
	$link_url = esc_url_raw( $attributes['linkUrl'] ?? '' );
	if ( '' !== $link_url ) {
		$target = ! empty( $attributes['linkNewTab'] ) ? ' target="_blank" rel="noopener noreferrer"' : '';
		$box    = '<a class="bw-cg-card__box" href="' . esc_url( $link_url ) . '"' . $target
			. ' aria-label="' . esc_attr( $aria ) . '">' . $box_inner . '</a>';
	} else {
		$box = '<div class="bw-cg-card__box bw-cg-card__box--static">' . $box_inner . '</div>';
	}
}

$style = '--bw-cg-radius:' . $radius . 'px;';
// No fixed ratio for the two fill modes. fill-col still needs a shape on mobile
// (stacked, no sibling) — the CSS falls back to 1 / 1 via var(--bw-cg-ratio,…).
if ( ! $is_fill && ! $is_fill_col ) {
	$style .= '--bw-cg-ratio:' . esc_attr( $ratio ) . ';';

	// Responsive per-breakpoint overrides. Only valid "<n> / <n>" ratios are
	// emitted; the CSS media queries pick these up (tablet ≤1024, mobile ≤767)
	// and fall back mobile → tablet → desktop when a breakpoint is left unset.
	$ratio_re = '/^[0-9]+(\.[0-9]+)?\s*\/\s*[0-9]+(\.[0-9]+)?$/';
	$ratio_t  = trim( (string) ( $attributes['ratioTablet'] ?? '' ) );
	$ratio_m  = trim( (string) ( $attributes['ratioMobile'] ?? '' ) );
	// Skip the ratio var when that breakpoint uses "fill column" (it overrides it).
	if ( empty( $attributes['fillColTablet'] ) && '' !== $ratio_t && preg_match( $ratio_re, $ratio_t ) ) {
		$style .= '--bw-cg-ratio-tablet:' . esc_attr( $ratio_t ) . ';';
	}
	if ( empty( $attributes['fillColMobile'] ) && '' !== $ratio_m && preg_match( $ratio_re, $ratio_m ) ) {
		$style .= '--bw-cg-ratio-mobile:' . esc_attr( $ratio_m ) . ';';
	}
}
if ( 'contain' === $fit ) {
	$style .= '--bw-cg-fit:contain;';
	// Default to white (not the block's dark #1a1a1a) so "Show whole image" never
	// looks like dark mode unless the author picks a colour.
	$style .= '--bw-cg-frame-bg:' . ( '' !== $frame_bg ? $frame_bg : '#ffffff' ) . ';';
	if ( '' !== $frame_bg_hover ) {
		$style .= '--bw-cg-frame-bg-hover:' . $frame_bg_hover . ';';
	}
}
$wrap_class = 'bw-cg-card';
if ( $is_fill ) {
	$wrap_class .= ' bw-cg-card--fill';
} elseif ( $is_fill_col ) {
	$wrap_class .= ' bw-cg-card--fill-col';
}
// Opt-in per-breakpoint "fill column" (independent of the desktop shape).
if ( ! empty( $attributes['fillColTablet'] ) ) {
	$wrap_class .= ' bw-cg-card--fill-col-t';
}
if ( ! empty( $attributes['fillColMobile'] ) ) {
	$wrap_class .= ' bw-cg-card--fill-col-m';
}

printf(
	'<div %s>%s%s</div>',
	get_block_wrapper_attributes( array( 'class' => $wrap_class, 'style' => $style ) ), // phpcs:ignore WordPress.Security.EscapeOutput
	$box,        // phpcs:ignore WordPress.Security.EscapeOutput -- assembled from escaped parts (caption now lives inside the box)
	$dialog_html // phpcs:ignore WordPress.Security.EscapeOutput -- close is literal, content is InnerBlocks (self-escaped)
);
