<?php
/**
 * Brentwood 100 — query helper + modal-body builder + cache invalidation.
 *
 * Powers the bw/hundred-grid block: one query over the `bw_hundred` CPT
 * (ordered by badge number via menu_order), a builder for the modal pop-up body
 * (title + number + gallery + story), and a transient bust on any edit so the
 * cached grid HTML stays fresh. The grid itself is cached in the block render.
 *
 * @package Kadence-Child
 */

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

/**
 * All published Brentwood 100 items, in badge-number order.
 *
 * @return WP_Post[]
 */
function bw_hundred_query() {
	if ( ! post_type_exists( 'bw_hundred' ) ) {
		return array();
	}
	$q = new WP_Query(
		array(
			'post_type'           => 'bw_hundred',
			'post_status'         => 'publish',
			'posts_per_page'      => -1,
			'orderby'             => array( 'menu_order' => 'ASC', 'title' => 'ASC' ),
			'no_found_rows'       => true,
			'ignore_sticky_posts' => true,
		)
	);
	return $q->posts;
}

/**
 * Ordered attachment IDs for an item's modal gallery: featured image first,
 * then the ACF gallery (de-duplicated).
 *
 * @param int $post_id Item ID.
 * @return int[]
 */
function bw_hundred_image_ids( $post_id ) {
	$ids = array();
	if ( has_post_thumbnail( $post_id ) ) {
		$ids[] = (int) get_post_thumbnail_id( $post_id );
	}
	foreach ( (array) get_field( 'hundred_gallery', $post_id ) as $gid ) {
		$gid = (int) $gid;
		if ( $gid && ! in_array( $gid, $ids, true ) ) {
			$ids[] = $gid;
		}
	}
	return $ids;
}

/**
 * Everything the grid and the packer need about one item.
 *
 * The two constraint sets an author fills in — which shapes a tile may take, and
 * how it may arrange text and media — can contradict each other AND the tile's
 * actual content. A tile set to "media only" with no media would render an empty
 * box; one set to square with only side-by-side layouts could never be drawn at
 * all. So the rules are resolved here, once, in a fixed order:
 *
 *   1. What the tile HAS wins. No media ⇒ text only. No text ⇒ media only.
 *   2. Then the author's chosen layouts, intersected with what's possible.
 *   3. Then orientation. A square has no room for text beside media, so a tile
 *      that can't render text-only or media-only can't be square.
 *
 * If an intersection empties out, the content-derived answer is used rather than
 * dropping the tile — a visible tile in an unintended shape beats a tile that
 * silently vanishes from a grid of 89.
 *
 * @param WP_Post $p Item.
 * @return array
 */
function bw_hundred_tile_data( $p ) {
	$all_layouts = array( 'text_media', 'media_text', 'text_only', 'media_only' );
	$all_shapes  = array( 'square', 'tall', 'wide' );

	$text = trim( (string) get_field( 'hundred_text', $p->ID ) );
	$type = (string) get_field( 'hundred_media_type', $p->ID );
	$type = ( 'video' === $type ) ? 'video' : 'images';

	$images = (array) get_field( 'hundred_images', $p->ID );
	$images = array_values( array_filter( array_map(
		function ( $i ) { return is_array( $i ) ? (int) ( $i['ID'] ?? 0 ) : (int) $i; },
		$images
	) ) );

	$video_file = '';
	$vf = get_field( 'hundred_video_file', $p->ID );
	if ( is_array( $vf ) && ! empty( $vf['url'] ) ) {
		$video_file = (string) $vf['url'];
	} elseif ( is_string( $vf ) && '' !== $vf ) {
		$video_file = $vf;
	}
	$video_url = (string) get_field( 'hundred_video_url', $p->ID );

	$poster = '';
	$vp = get_field( 'hundred_video_poster', $p->ID );
	if ( is_array( $vp ) && ! empty( $vp['url'] ) ) {
		$poster = (string) $vp['url'];
	}

	$has_text  = '' !== trim( wp_strip_all_tags( $text ) );
	$has_media = ( 'video' === $type )
		? ( '' !== $video_file || '' !== $video_url || '' !== $poster )
		: ! empty( $images );

	/* Which shape+layout pairings this tile may be drawn as.
	 *
	 * Stored as an explicit LIST OF PAIRS rather than as separate shape and layout
	 * lists. The two-list version implied every combination of the two, so you
	 * could not keep "Wide · Text | Media" while dropping "Wide · Media only" —
	 * unticking Media only took the square one with it. Pairs let each permutation
	 * be judged on its own, which is what the preview panel is for.
	 *
	 * Anything the CONTENT can't support is filtered out regardless, and an empty
	 * selection is read as "all" — that can only be a slip, and a tile that
	 * silently vanishes from a grid of 89 is worse than one in an unintended shape.
	 */
	$possible = array_keys( bw_hundred_all_combos( $has_text, $has_media ) );
	$stored   = (array) get_post_meta( $p->ID, 'hundred_combos', true );
	$combos   = array_values( array_intersect( $stored, $possible ) );
	if ( ! $combos ) {
		$combos = $possible;
	}

	$orientations = array();
	$layouts      = array();
	foreach ( $combos as $c ) {
		list( $shape, $layout ) = explode( ':', $c );
		$orientations[ $shape ] = true;
		$layouts[ $layout ]     = true;
	}
	$orientations = array_keys( $orientations );
	$layouts      = array_keys( $layouts );

	return array(
		'combos'       => $combos,
		'text'         => $text,
		'media_type'   => $type,
		'images'       => $images,
		'cover_id'     => $images ? $images[0] : 0,
		'video_file'   => $video_file,
		'video_url'    => $video_url,
		'poster'       => $poster,
		'has_text'     => $has_text,
		'has_media'    => $has_media,
		'layouts'      => $layouts,
		'orientations' => $orientations,
	);
}

/**
 * Every shape × layout an item's CONTENT can produce, as "shape:layout" keys.
 *
 * Content rules first (no media ⇒ text only, no text ⇒ media only), then the
 * square rule: one cell has no room to sit text beside media.
 *
 * @param bool $has_text  Item has words.
 * @param bool $has_media Item has a picture or video.
 * @return array<string,string> key => human label.
 */
function bw_hundred_all_combos( $has_text, $has_media ) {
	if ( $has_text && $has_media ) {
		$layouts = array( 'media_only', 'text_only', 'text_media', 'media_text' );
	} elseif ( $has_media ) {
		$layouts = array( 'media_only' );
	} elseif ( $has_text ) {
		$layouts = array( 'text_only' );
	} else {
		return array();
	}

	$lnames = array(
		'media_only' => __( 'Media only', 'kadence-child' ),
		'text_only'  => __( 'Text only', 'kadence-child' ),
		'text_media' => __( 'Text | Media', 'kadence-child' ),
		'media_text' => __( 'Media | Text', 'kadence-child' ),
	);
	$snames = array(
		'square' => __( 'Square', 'kadence-child' ),
		'wide'   => __( 'Wide', 'kadence-child' ),
		'tall'   => __( 'Tall', 'kadence-child' ),
	);

	$out = array();
	foreach ( array( 'square', 'wide', 'tall' ) as $shape ) {
		foreach ( $layouts as $layout ) {
			if ( 'square' === $shape && ! in_array( $layout, array( 'text_only', 'media_only' ), true ) ) {
				continue;
			}
			$out[ $shape . ':' . $layout ] = $snames[ $shape ] . ' · ' . $lnames[ $layout ];
		}
	}
	return $out;
}

/**
 * The inside of a tile: its media, its text, and the title in whichever of the
 * two it belongs to.
 *
 * Shared by the grid and the edit-screen preview so the two cannot drift — a
 * preview that doesn't match the real tile is worse than no preview.
 *
 * Both halves are always rendered; CSS shows whichever the chosen layout calls
 * for. The title appears twice for the same reason (over the media, and heading
 * the text), with the media copy `aria-hidden` because the tile's button already
 * carries the title as its accessible name.
 *
 * @param WP_Post    $p    Item.
 * @param array|null $tile Pre-computed tile data, to avoid recomputing.
 * @return string HTML.
 */
function bw_hundred_tile_face( $p, $tile = null ) {
	$t     = $tile ? $tile : bw_hundred_tile_data( $p );
	$title = get_the_title( $p );

	$media = '';
	if ( $t['has_media'] ) {
		if ( 'video' === $t['media_type'] && $t['video_file'] ) {
			$media = '<video class="bw100__video" muted loop playsinline preload="none"'
				. ( $t['poster'] ? ' poster="' . esc_url( $t['poster'] ) . '"' : '' )
				. ' data-bw100-video><source src="' . esc_url( $t['video_file'] ) . '" type="video/mp4"></video>';
		} elseif ( $t['images'] ) {
			// Every image is rendered and stacked; the carousel just changes which
			// one is on top. Only the first is worth fetching up front.
			foreach ( $t['images'] as $i => $gid ) {
				$media .= wp_get_attachment_image(
					$gid,
					'large',
					false,
					array(
						'class'    => 'bw100__img' . ( 0 === $i ? ' is-active' : '' ),
						'loading'  => 0 === $i ? 'lazy' : 'lazy',
						'decoding' => 'async',
						'aria-hidden' => 0 === $i ? 'false' : 'true',
					)
				);
			}
		} elseif ( $t['poster'] ) {
			$media = '<img class="bw100__img is-active" src="' . esc_url( $t['poster'] ) . '" alt="" loading="lazy" decoding="async">';
		}
		if ( 'video' === $t['media_type'] && ! $t['video_file'] ) {
			$media .= '<span class="bw100__play" aria-hidden="true"></span>';
		}
	}

	/* Dots for a multi-image tile. They live INSIDE the media element, which is
	   only possible because they are a plain indicator — the arrows they replaced
	   were buttons, and a <button> may not contain other buttons, which is what
	   forced the old controls out to the cell and then onto the wrong half of a
	   split tile. Being inside the media, they are always over the picture. */
	$dots = '';
	if ( 'video' !== $t['media_type'] && count( $t['images'] ) > 1 ) {
		foreach ( $t['images'] as $i => $gid ) {
			$dots .= '<span class="bw100__dot' . ( 0 === $i ? ' is-active' : '' ) . '"></span>';
		}
		$dots = '<span class="bw100__dots" aria-hidden="true">' . $dots . '</span>';
	}

	$face = '';
	if ( '' !== $media ) {
		$face .= '<div class="bw100__media"' . ( '' !== $dots ? ' data-bw100-carousel' : '' ) . '>' . $media
			. '<span class="bw100__media-title" aria-hidden="true">' . esc_html( $title ) . '</span>'
			. $dots
			. '</div>';
	}
	if ( $t['has_text'] ) {
		$face .= '<div class="bw100__text"><h3 class="bw100__title">' . esc_html( $title ) . '</h3>'
			. '<div class="bw100__body">' . wp_kses_post( $t['text'] ) . '</div>'
			. '</div>';
	} elseif ( '' === $media ) {
		// Neither text nor media: still show the title so the tile isn't blank.
		$face .= '<div class="bw100__text"><h3 class="bw100__title">' . esc_html( $title ) . '</h3></div>';
	}

	return $face;
}

/**
 * A YouTube/Vimeo watch URL turned into an embeddable one.
 *
 * Only used inside the pop-up. Accepts the forms people actually paste — a full
 * watch link, a youtu.be short link, /shorts/, or a bare ID.
 *
 * @param string $url Raw URL.
 * @return string Embed URL, or '' if unrecognised.
 */
function bw_hundred_embed_url( $url ) {
	$url = trim( $url );
	if ( '' === $url ) {
		return '';
	}
	if ( preg_match( '~(?:youtube\.com/(?:watch\?v=|embed/|shorts/)|youtu\.be/)([A-Za-z0-9_-]{6,})~i', $url, $m ) ) {
		return 'https://www.youtube-nocookie.com/embed/' . $m[1];
	}
	if ( preg_match( '~vimeo\.com/(?:video/)?(\d+)~i', $url, $m ) ) {
		return 'https://player.vimeo.com/video/' . $m[1];
	}
	if ( preg_match( '~^[A-Za-z0-9_-]{11}$~', $url ) ) {
		return 'https://www.youtube-nocookie.com/embed/' . $url;
	}
	return '';
}

/**
 * The modal pop-up body for one item (cloned into the shared bw-tl modal).
 *
 * Always shows everything the item has, whatever the tile chose to display —
 * that is the point of every tile opening: the grid is a teaser, this is the
 * content. Video does NOT autoplay here; the tile is where it plays silently.
 *
 * @param WP_Post $p Item.
 * @return string HTML.
 */
function bw_hundred_modal_html( $p ) {
	$t = bw_hundred_tile_data( $p );

	$media = '';
	if ( 'video' === $t['media_type'] ) {
		$embed = bw_hundred_embed_url( $t['video_url'] );
		if ( $embed ) {
			$media = '<div class="bw100-modal__video"><iframe src="' . esc_url( $embed )
				. '" title="' . esc_attr( get_the_title( $p ) ) . '" loading="lazy" allowfullscreen'
				. ' allow="accelerometer; encrypted-media; gyroscope; picture-in-picture"></iframe></div>';
		} elseif ( $t['video_file'] ) {
			$media = '<video class="bw100-modal__video" controls preload="metadata"'
				. ( $t['poster'] ? ' poster="' . esc_url( $t['poster'] ) . '"' : '' )
				. '><source src="' . esc_url( $t['video_file'] ) . '" type="video/mp4"></video>';
		}
	} else {
		foreach ( $t['images'] as $gid ) {
			$media .= '<figure class="bw100-modal__figure">'
				. wp_get_attachment_image( $gid, 'large', false, array( 'class' => 'bw100-modal__img', 'loading' => 'lazy' ) )
				. '</figure>';
		}
		if ( '' !== $media ) {
			$media = '<div class="bw100-modal__gallery">' . $media . '</div>';
		}
	}

	return '<article class="bw100-modal">'
		. '<h2 class="bw100-modal__title">' . esc_html( get_the_title( $p ) ) . '</h2>'
		. $media
		. ( $t['has_text'] ? '<div class="bw100-modal__body">' . wp_kses_post( $t['text'] ) . '</div>' : '' )
		. '</article>';
}

/**
 * Bust the cached grid HTML whenever an item is saved, trashed or deleted.
 */
function bw_hundred_flush_cache() {
	delete_transient( 'bw_hundred_grid_v2' );
	foreach ( array( 2, 3, 4, 5, 6 ) as $cols ) {
		delete_transient( 'bw_hundred_grid_c' . $cols ); // legacy grid, still comparable via ?b100=legacy
	}
}
add_action( 'save_post_bw_hundred', 'bw_hundred_flush_cache' );
/* Dragging in Post Types Order rewrites menu_order over AJAX without saving any
   post, so save_post never fires — and since the badge number IS the position,
   a stale cache would show every tile the wrong number. */
add_action( 'PTO/order_update_complete', 'bw_hundred_flush_cache' );
add_action( 'trashed_post', function ( $id ) {
	if ( 'bw_hundred' === get_post_type( $id ) ) {
		bw_hundred_flush_cache();
	}
} );
add_action( 'deleted_post', function ( $id ) {
	if ( 'bw_hundred' === get_post_type( $id ) ) {
		bw_hundred_flush_cache();
	}
} );
