<?php
/**
 * Front-end directory renderer.
 *
 * @package LanternSpanPeopleDirectory
 */

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

/**
 * Renders the searchable directory and individual profiles.
 */
class LSPD_Directory {

	/**
	 * Number of directory instances rendered during the current request.
	 *
	 * @var int
	 */
	private $instance_count = 0;

	/**
	 * Whether a profile biography is currently being rendered.
	 *
	 * This guard prevents a Lantern Span People Directory block or shortcode placed inside a
	 * biography from recursively rendering another directory.
	 *
	 * @var bool
	 */
	private $is_rendering_biography = false;

	/**
	 * Registers front-end hooks.
	 */
	public function __construct() {
		add_shortcode( 'lantern_span_people_directory', array( $this, 'render_shortcode' ) );
		add_action( 'init', array( $this, 'register_stylesheet' ), 5 );
		add_action( 'wp_enqueue_scripts', array( $this, 'maybe_enqueue_assets' ) );
		add_filter( 'wp_robots', array( $this, 'filter_robots' ) );
		add_filter( 'posts_search', array( $this, 'filter_directory_search' ), 10, 2 );
	}

	/**
	 * Registers the front-end directory stylesheet.
	 *
	 * @return void
	 */
	public function register_stylesheet() {
		$stylesheet_path    = LSPD_PATH . 'assets/css/frontend.css';
		$stylesheet_version = LSPD_VERSION;

		if ( file_exists( $stylesheet_path ) ) {
			$modified_time = filemtime( $stylesheet_path );

			if ( false !== $modified_time ) {
				$stylesheet_version .= '.' . $modified_time;
			}
		}

		wp_register_style(
			'lspd-directory',
			LSPD_URL . 'assets/css/frontend.css',
			array(),
			$stylesheet_version
		);
	}

	/**
	 * Enqueues the front-end stylesheet on directory pages.
	 *
	 * @return void
	 */
	public function maybe_enqueue_assets() {
		if ( ! $this->is_directory_request() ) {
			return;
		}

		$this->enqueue_stylesheet();
	}

	/**
	 * Enqueues the directory stylesheet.
	 *
	 * @return void
	 */
	private function enqueue_stylesheet() {
		$this->register_stylesheet();
		wp_enqueue_style( 'lspd-directory' );
	}

	/**
	 * Determines whether the current page contains a Lantern Span People Directory directory.
	 *
	 * @return bool
	 */
	public function is_directory_request() {
		$settings = lspd_get_settings();
		$page_id  = absint( $settings['directory_page_id'] );

		if ( $page_id > 0 && is_page( $page_id ) ) {
			return true;
		}

		if ( ! is_singular() ) {
			return false;
		}

		$post = get_post();

		if ( ! ( $post instanceof WP_Post ) ) {
			return false;
		}

		return has_shortcode( $post->post_content, 'lantern_span_people_directory' ) || has_block( 'lantern-span-people-directory/directory', $post );
	}

	/**
	 * Prevents directory pages from being indexed or cached by search engines.
	 *
	 * @param array<string, bool|string> $robots Existing robots directives.
	 * @return array<string, bool|string>
	 */
	public function filter_robots( $robots ) {
		if ( ! $this->is_directory_request() ) {
			return $robots;
		}

		$robots['noindex']   = true;
		$robots['nofollow']  = true;
		$robots['noarchive'] = true;

		return $robots;
	}

	/**
	 * Extends the front-end directory search to names, email addresses, and
	 * phone numbers.
	 *
	 * The custom query flag prevents this filter from changing searches made by
	 * WordPress, themes, or other plugins. Phone searches compare both the saved
	 * display value and a punctuation-free value so that, for example,
	 * "4055551212" can match "(405) 555-1212".
	 *
	 * @param string   $search_sql Existing WordPress search SQL.
	 * @param WP_Query $query      Current query.
	 * @return string
	 */
	public function filter_directory_search( $search_sql, $query ) {
		if ( ! ( $query instanceof WP_Query ) ) {
			return $search_sql;
		}

		$search_term = $query->get( 'lantern_span_people_directory_search' );

		if ( ! is_string( $search_term ) ) {
			return $search_sql;
		}

		$search_term = trim( $search_term );

		if ( '' === $search_term ) {
			return $search_sql;
		}

		global $wpdb;

		if ( ! isset( $wpdb->posts, $wpdb->postmeta ) || ! method_exists( $wpdb, 'esc_like' ) || ! method_exists( $wpdb, 'prepare' ) ) {
			return $search_sql;
		}

		$like       = '%' . $wpdb->esc_like( $search_term ) . '%';
		$conditions = array(
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WordPress supplies the posts table name; the search value is prepared.
			$wpdb->prepare( "{$wpdb->posts}.post_title LIKE %s", $like ),
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WordPress supplies the posts and postmeta table names; the search value is prepared.
			$wpdb->prepare(
				"EXISTS (
					SELECT 1
					FROM {$wpdb->postmeta} AS lspd_email_meta
					WHERE lspd_email_meta.post_id = {$wpdb->posts}.ID
						AND lspd_email_meta.meta_key = '_lspd_email'
						AND lspd_email_meta.meta_value LIKE %s
				)",
				$like
			),
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WordPress supplies the posts and postmeta table names; the search value is prepared.
			$wpdb->prepare(
				"EXISTS (
					SELECT 1
					FROM {$wpdb->postmeta} AS lspd_phone_meta
					WHERE lspd_phone_meta.post_id = {$wpdb->posts}.ID
						AND lspd_phone_meta.meta_key = '_lspd_phone'
						AND lspd_phone_meta.meta_value LIKE %s
				)",
				$like
			),
		);

		$phone_digits = preg_replace( '/[^0-9]/', '', $search_term );

		if ( is_string( $phone_digits ) && strlen( $phone_digits ) >= 3 ) {
			$digits_like = '%' . $wpdb->esc_like( $phone_digits ) . '%';
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WordPress supplies the posts and postmeta table names; the search value is prepared.
			$conditions[] = $wpdb->prepare(
				"EXISTS (
					SELECT 1
					FROM {$wpdb->postmeta} AS lspd_phone_digits_meta
					WHERE lspd_phone_digits_meta.post_id = {$wpdb->posts}.ID
						AND lspd_phone_digits_meta.meta_key = '_lspd_phone'
						AND REPLACE(
							REPLACE(
								REPLACE(
									REPLACE(
										REPLACE(
											REPLACE(
												REPLACE( lspd_phone_digits_meta.meta_value, ' ', '' ),
												'-', ''
											),
											'(', ''
										),
										')', ''
									),
									'.', ''
								),
								'+', ''
							),
							'/', ''
						) LIKE %s
				)",
				$digits_like
			);
		}

		return ' AND (' . implode( ' OR ', $conditions ) . ') ';
	}

	/**
	 * Renders the directory shortcode.
	 *
	 * @param mixed $atts Shortcode attributes.
	 * @return string
	 */
	public function render_shortcode( $atts = array() ) {
		if ( $this->is_rendering_biography ) {
			return '';
		}

		$settings = lspd_get_settings();
		$atts     = is_array( $atts ) ? $atts : array();
		$atts     = shortcode_atts(
			array(
				'per_page' => $settings['per_page'],
				'sort_by'  => $settings['sort_by'],
			),
			$atts,
			'lantern_span_people_directory'
		);

		$per_page = isset( $atts['per_page'] ) && is_scalar( $atts['per_page'] ) ? absint( $atts['per_page'] ) : absint( $settings['per_page'] );
		$per_page = in_array( $per_page, array( 12, 24, 36, 48 ), true ) ? $per_page : absint( $settings['per_page'] );

		$sort_by = isset( $atts['sort_by'] ) && is_string( $atts['sort_by'] ) ? sanitize_key( $atts['sort_by'] ) : $settings['sort_by'];
		$sort_by = in_array( $sort_by, array( 'first_name', 'last_name' ), true ) ? $sort_by : $settings['sort_by'];

		$show_alphabet = ! empty( $settings['show_alphabet'] );
		++$this->instance_count;
		$instance_id = 'lspd-directory-' . $this->instance_count;

		// Enqueue here as a fallback for directories rendered outside normal page content.
		$this->enqueue_stylesheet();

		if ( 'logged_in' === $settings['access'] && ! is_user_logged_in() ) {
			return $this->render_private_message();
		}

		/*
		 * These public query arguments control read-only directory navigation.
		 * They do not change site data, so nonce verification is not applicable.
		 */
		// phpcs:disable WordPress.Security.NonceVerification.Recommended
		$person_id = 0;
		if ( isset( $_GET['lspd_person'] ) && is_scalar( $_GET['lspd_person'] ) ) {
			$person_id = absint( wp_unslash( $_GET['lspd_person'] ) );
		}

		$search = '';
		if ( isset( $_GET['lspd_search'] ) && is_string( $_GET['lspd_search'] ) ) {
			$search = sanitize_text_field( wp_unslash( $_GET['lspd_search'] ) );
		}

		$letter = '';
		if ( $show_alphabet && isset( $_GET['lspd_letter'] ) && is_string( $_GET['lspd_letter'] ) ) {
			$letter = strtoupper( sanitize_text_field( wp_unslash( $_GET['lspd_letter'] ) ) );
			$letter = ( '#' === $letter || 1 === preg_match( '/^[A-Z]$/', $letter ) ) ? $letter : '';
		}

		$current_page = 1;
		if ( isset( $_GET['lspd_page'] ) && is_scalar( $_GET['lspd_page'] ) ) {
			$current_page = max( 1, absint( wp_unslash( $_GET['lspd_page'] ) ) );
		}
		// phpcs:enable WordPress.Security.NonceVerification.Recommended

		$base_url = remove_query_arg(
			array( 'lspd_person', 'lspd_search', 'lspd_letter', 'lspd_page' ),
			lspd_get_current_page_url()
		);
		$back_url = $this->build_listing_url( $base_url, $search, $letter, $current_page );

		ob_start();
		?>
		<div id="<?php echo esc_attr( $instance_id ); ?>" class="lspd-directory lspd-layout-horizontal lspd-sort-<?php echo esc_attr( str_replace( '_', '-', $sort_by ) ); ?> <?php echo esc_attr( $show_alphabet ? 'lspd-alphabet-enabled' : 'lspd-alphabet-disabled' ); ?>">
			<?php if ( $person_id > 0 ) : ?>
				<?php
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- The profile renderer escapes every dynamic value before returning its trusted plugin markup.
				echo $this->render_profile( $person_id, $back_url );
				?>
			<?php else : ?>
				<?php
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- The directory renderer escapes every dynamic value before returning its trusted plugin markup, including its search form.
				echo $this->render_directory_grid( $search, $letter, $current_page, $per_page, $sort_by, $base_url, $show_alphabet, $instance_id );
				?>
			<?php endif; ?>
		</div>
		<?php
		return (string) ob_get_clean();
	}

	/**
	 * Renders the private-directory sign-in message.
	 *
	 * @return string
	 */
	private function render_private_message() {
		$login_url = wp_login_url( lspd_get_current_page_url() );

		ob_start();
		?>
		<div class="lspd-directory lspd-private-notice" role="status">
			<h2><?php esc_html_e( 'This directory is private', 'lantern-span-people-directory' ); ?></h2>
			<p><?php esc_html_e( 'Please sign in with your WordPress account to view the directory.', 'lantern-span-people-directory' ); ?></p>
			<p><a class="lspd-button" href="<?php echo esc_url( $login_url ); ?>"><?php esc_html_e( 'Sign In', 'lantern-span-people-directory' ); ?></a></p>
		</div>
		<?php
		return (string) ob_get_clean();
	}

	/**
	 * Renders the search form, optional alphabet navigation, cards, and pagination.
	 *
	 * @param string $search         Search query.
	 * @param string $letter         Current A-Z filter.
	 * @param int    $current_page   Current result page.
	 * @param int    $per_page       People per page.
	 * @param string $sort_by        first_name or last_name.
	 * @param string $base_url       Directory page URL without navigation arguments.
	 * @param bool   $show_alphabet  Whether to show A-Z navigation and grouped headings.
	 * @param string $instance_id    Unique ID for this rendered directory.
	 * @return string
	 */
	private function render_directory_grid( $search, $letter, $current_page, $per_page, $sort_by, $base_url, $show_alphabet, $instance_id ) {
		$person_index = $this->get_directory_person_index( $search );
		$person_index = $this->sort_person_index( $person_index, $sort_by );

		$available_initials = $show_alphabet ? $this->get_available_initials( $person_index, $sort_by ) : array();

		if ( $show_alphabet && '' !== $letter ) {
			$person_index = array_values(
				array_filter(
					$person_index,
					static function ( $entry ) use ( $sort_by, $letter ) {
						return is_array( $entry ) && isset( $entry['title'] ) && $letter === lspd_get_name_initial( $entry['title'], $sort_by );
					}
				)
			);
		}

		$total_people = count( $person_index );
		$total_pages  = $total_people > 0 ? (int) ceil( $total_people / $per_page ) : 0;

		if ( $total_pages > 0 ) {
			$current_page = min( max( 1, $current_page ), $total_pages );
		} else {
			$current_page = 1;
		}

		$offset      = ( $current_page - 1 ) * $per_page;
		$page_index  = array_slice( $person_index, $offset, $per_page );
		$page_people = $this->load_people_from_index( $page_index );
		$groups      = $show_alphabet ? $this->group_people_by_initial( $page_people, $sort_by ) : array();

		if ( 'first_name' === $sort_by ) {
			$order_text = $show_alphabet
				? __( 'Alphabetized by first name.', 'lantern-span-people-directory' )
				: __( 'Sorted by first name.', 'lantern-span-people-directory' );
		} else {
			$order_text = $show_alphabet
				? __( 'Alphabetized by last name.', 'lantern-span-people-directory' )
				: __( 'Sorted by last name.', 'lantern-span-people-directory' );
		}

		ob_start();
		?>
		<header class="lspd-directory-header">
			<div>
				<h2><?php esc_html_e( 'Directory', 'lantern-span-people-directory' ); ?></h2>
				<p class="lspd-order-summary"><?php echo esc_html( $order_text ); ?></p>
				<?php if ( '' !== $search ) : ?>
					<p class="lspd-search-summary">
						<?php
						/* translators: %s: directory search term. */
						echo esc_html( sprintf( __( 'Search results for “%s”', 'lantern-span-people-directory' ), $search ) );
						?>
					</p>
				<?php endif; ?>
			</div>

			<form class="lspd-search" method="get" action="<?php echo esc_url( $base_url ); ?>" role="search">
				<?php $search_input_id = $instance_id . '-search'; ?>
				<label class="screen-reader-text" for="<?php echo esc_attr( $search_input_id ); ?>"><?php esc_html_e( 'Search the directory by name, email, or phone number', 'lantern-span-people-directory' ); ?></label>
				<input id="<?php echo esc_attr( $search_input_id ); ?>" type="search" name="lspd_search" value="<?php echo esc_attr( $search ); ?>" placeholder="<?php echo esc_attr__( 'Search by name, email, or phone…', 'lantern-span-people-directory' ); ?>">
				<button type="submit"><?php esc_html_e( 'Search', 'lantern-span-people-directory' ); ?></button>
				<?php if ( '' !== $search || ( $show_alphabet && '' !== $letter ) ) : ?>
					<a class="lspd-clear-search" href="<?php echo esc_url( $base_url ); ?>"><?php esc_html_e( 'Clear', 'lantern-span-people-directory' ); ?></a>
				<?php endif; ?>
			</form>
		</header>

		<?php if ( $show_alphabet ) : ?>
			<?php
			// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Alphabet renderer escapes every dynamic value.
			echo $this->render_alphabet_navigation( $base_url, $search, $letter, $available_initials );
			?>
		<?php endif; ?>

		<?php if ( ! empty( $page_people ) ) : ?>
			<?php if ( $show_alphabet ) : ?>
				<div class="lspd-letter-groups">
					<?php foreach ( $groups as $group_initial => $group_people ) : ?>
						<?php $section_id = $instance_id . '-letter-' . ( '#' === $group_initial ? 'other' : strtolower( $group_initial ) ); ?>
						<section class="lspd-letter-section" aria-labelledby="<?php echo esc_attr( $section_id ); ?>">
							<h3 class="lspd-letter-heading" id="<?php echo esc_attr( $section_id ); ?>"><span><?php echo esc_html( $group_initial ); ?></span></h3>
							<div class="lspd-grid">
								<?php foreach ( $group_people as $person ) : ?>
									<?php
									// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Card renderer escapes every dynamic value.
									echo $this->render_card( $person, $base_url, $search, $letter, $current_page );
									?>
								<?php endforeach; ?>
							</div>
						</section>
					<?php endforeach; ?>
				</div>
			<?php else : ?>
				<div class="lspd-directory-cards">
					<div class="lspd-grid">
						<?php foreach ( $page_people as $person ) : ?>
							<?php
							// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Card renderer escapes every dynamic value.
							echo $this->render_card( $person, $base_url, $search, '', $current_page );
							?>
						<?php endforeach; ?>
					</div>
				</div>
			<?php endif; ?>

			<?php
			// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pagination renderer returns trusted escaped plugin markup.
			echo $this->render_pagination( $total_pages, $current_page, $base_url, $search, $show_alphabet ? $letter : '' );
			?>
		<?php else : ?>
			<div class="lspd-empty" role="status">
				<h3><?php esc_html_e( 'No directory entries found', 'lantern-span-people-directory' ); ?></h3>
				<?php if ( '' !== $search ) : ?>
					<p><?php esc_html_e( 'Try a different name, email address, or phone number, or clear the search.', 'lantern-span-people-directory' ); ?></p>
				<?php elseif ( $show_alphabet && '' !== $letter ) : ?>
					<p>
						<?php
						/* translators: %s: selected directory initial. */
						echo esc_html( sprintf( __( 'There are no published profiles in the %s section.', 'lantern-span-people-directory' ), $letter ) );
						?>
					</p>
				<?php else : ?>
					<p><?php esc_html_e( 'Published profiles will appear here.', 'lantern-span-people-directory' ); ?></p>
				<?php endif; ?>

				<?php if ( '' !== $search || ( $show_alphabet && '' !== $letter ) ) : ?>
					<p><a class="lspd-button lspd-button--secondary" href="<?php echo esc_url( $base_url ); ?>"><?php esc_html_e( 'View All Profiles', 'lantern-span-people-directory' ); ?></a></p>
				<?php endif; ?>
			</div>
		<?php endif; ?>
		<?php
		return (string) ob_get_clean();
	}

	/**
	 * Fetches a lightweight ID/title index of published people matching the
	 * optional directory search.
	 *
	 * WordPress is asked for IDs only. Titles are then loaded in a compact query
	 * so sorting the full directory does not load every biography into memory.
	 * Full post objects are loaded only for the current result page.
	 *
	 * @param string $search Search term.
	 * @return array<int, array{id:int,title:string}>
	 */
	private function get_directory_person_index( $search ) {
		$query_args = array(
			'post_type'                   => LSPD_Post_Type::POST_TYPE,
			'post_status'                 => 'publish',
			'posts_per_page'              => -1,
			'fields'                      => 'ids',
			'orderby'                     => 'none',
			'ignore_sticky_posts'         => true,
			'no_found_rows'               => true,
			'has_password'                => false,
			'update_post_meta_cache'      => false,
			'update_post_term_cache'      => false,
			'lantern_span_people_directory_search' => $search,
		);

		if ( '' !== $search ) {
			// Supplying `s` ensures WordPress runs the posts_search filter. The
			// filter above replaces the default title/content search with the
			// Lantern Span People Directory name/email/phone search.
			$query_args['s'] = $search;
		}

		$query = new WP_Query( $query_args );
		$ids   = array();

		if ( isset( $query->posts ) && is_array( $query->posts ) ) {
			foreach ( $query->posts as $query_post ) {
				$id = $query_post instanceof WP_Post ? absint( $query_post->ID ) : absint( $query_post );

				if ( $id > 0 ) {
					$ids[] = $id;
				}
			}
		}

		$ids = array_values( array_unique( $ids ) );

		if ( empty( $ids ) ) {
			return array();
		}

		$title_map = $this->get_person_title_map( $ids );
		$index     = array();

		foreach ( $ids as $id ) {
			if ( isset( $title_map[ $id ] ) && is_string( $title_map[ $id ] ) ) {
				$index[] = array(
					'id'    => $id,
					'title' => $title_map[ $id ],
				);
			}
		}

		return $index;
	}

	/**
	 * Loads post titles for a set of IDs without retrieving biography content.
	 *
	 * @param array<int, int> $ids Person post IDs.
	 * @return array<int, string>
	 */
	private function get_person_title_map( $ids ) {
		$title_map = array();
		$ids       = array_values( array_filter( array_map( 'absint', $ids ) ) );

		if ( empty( $ids ) ) {
			return $title_map;
		}

		global $wpdb;

		if ( isset( $wpdb->posts ) && method_exists( $wpdb, 'prepare' ) && method_exists( $wpdb, 'get_results' ) ) {
			foreach ( array_chunk( $ids, 500 ) as $id_chunk ) {
				$last_changed = function_exists( 'wp_cache_get_last_changed' ) ? wp_cache_get_last_changed( 'posts' ) : '';
				$cache_key    = 'person_titles_' . md5( implode( ',', $id_chunk ) . '|' . $last_changed );
				$rows         = wp_cache_get( $cache_key, 'lantern-span-people-directory' );

				if ( false === $rows ) {
					$placeholders = implode( ', ', array_fill( 0, count( $id_chunk ), '%d' ) );

					/*
					 * The table name is WordPress's trusted posts table, and the dynamic
					 * placeholder list contains only internally generated %d tokens.
					 * Every post ID is still passed through $wpdb->prepare().
					 */
					// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
					$sql = $wpdb->prepare(
						"SELECT ID, post_title FROM {$wpdb->posts} WHERE ID IN ({$placeholders})",
						...$id_chunk
					);
					// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare

					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- The query is prepared above and cached immediately below.
					$rows = $wpdb->get_results( $sql );
					$rows = is_array( $rows ) ? $rows : array();
					wp_cache_set( $cache_key, $rows, 'lantern-span-people-directory', HOUR_IN_SECONDS );
				}

				if ( ! is_array( $rows ) ) {
					continue;
				}

				foreach ( $rows as $row ) {
					if ( is_object( $row ) && isset( $row->ID, $row->post_title ) ) {
						$title_map[ absint( $row->ID ) ] = (string) $row->post_title;
					}
				}
			}
		}

		// Use the WordPress post API for any titles not returned by the compact query.
		foreach ( $ids as $id ) {
			if ( isset( $title_map[ $id ] ) ) {
				continue;
			}

			$person = get_post( $id );

			if ( $person instanceof WP_Post ) {
				$title_map[ $id ] = (string) $person->post_title;
			}
		}

		return $title_map;
	}

	/**
	 * Sorts a lightweight person index by the selected derived name field.
	 *
	 * @param array<int, array{id:int,title:string}> $person_index Person index.
	 * @param string                                 $sort_by      first_name or last_name.
	 * @return array<int, array{id:int,title:string}>
	 */
	private function sort_person_index( $person_index, $sort_by ) {
		usort(
			$person_index,
			static function ( $first_entry, $second_entry ) use ( $sort_by ) {
				$first_title  = is_array( $first_entry ) && isset( $first_entry['title'] ) ? (string) $first_entry['title'] : '';
				$second_title = is_array( $second_entry ) && isset( $second_entry['title'] ) ? (string) $second_entry['title'] : '';
				$comparison   = lspd_compare_names( $first_title, $second_title, $sort_by );

				if ( 0 !== $comparison ) {
					return $comparison;
				}

				$first_id  = is_array( $first_entry ) && isset( $first_entry['id'] ) ? absint( $first_entry['id'] ) : 0;
				$second_id = is_array( $second_entry ) && isset( $second_entry['id'] ) ? absint( $second_entry['id'] ) : 0;

				return $first_id <=> $second_id;
			}
		);

		return array_values( $person_index );
	}

	/**
	 * Returns initials that occur in the current result set.
	 *
	 * @param array<int, array{id:int,title:string}> $person_index Person index.
	 * @param string                                 $sort_by      first_name or last_name.
	 * @return array<string, bool>
	 */
	private function get_available_initials( $person_index, $sort_by ) {
		$initials = array();

		foreach ( $person_index as $entry ) {
			if ( is_array( $entry ) && isset( $entry['title'] ) ) {
				$initials[ lspd_get_name_initial( $entry['title'], $sort_by ) ] = true;
			}
		}

		return $initials;
	}

	/**
	 * Loads full post objects only for the people displayed on the current page.
	 *
	 * @param array<int, array{id:int,title:string}> $page_index Page index.
	 * @return array<int, WP_Post>
	 */
	private function load_people_from_index( $page_index ) {
		$people = array();

		foreach ( $page_index as $entry ) {
			$id     = is_array( $entry ) && isset( $entry['id'] ) ? absint( $entry['id'] ) : 0;
			$person = $id > 0 ? get_post( $id ) : null;

			if ( $person instanceof WP_Post && LSPD_Post_Type::POST_TYPE === $person->post_type && 'publish' === $person->post_status ) {
				$people[] = $person;
			}
		}

		return $people;
	}

	/**
	 * Groups one page of already sorted people under A-Z headings.
	 *
	 * @param array<int, WP_Post> $people  People on the current page.
	 * @param string              $sort_by first_name or last_name.
	 * @return array<string, array<int, WP_Post>>
	 */
	private function group_people_by_initial( $people, $sort_by ) {
		$groups = array();

		foreach ( $people as $person ) {
			if ( ! ( $person instanceof WP_Post ) ) {
				continue;
			}

			$initial = lspd_get_name_initial( $person->post_title, $sort_by );

			if ( ! isset( $groups[ $initial ] ) ) {
				$groups[ $initial ] = array();
			}

			$groups[ $initial ][] = $person;
		}

		return $groups;
	}

	/**
	 * Renders the All/A-Z directory navigation.
	 *
	 * Available letters are links that filter to that entire section. Letters
	 * absent from the current search result are displayed as disabled text.
	 *
	 * @param string              $base_url           Directory URL.
	 * @param string              $search             Search term.
	 * @param string              $current_letter     Selected letter.
	 * @param array<string, bool> $available_initials Available sections.
	 * @return string
	 */
	private function render_alphabet_navigation( $base_url, $search, $current_letter, $available_initials ) {
		$letters = range( 'A', 'Z' );

		if ( isset( $available_initials['#'] ) ) {
			$letters[] = '#';
		}

		$all_url     = $this->build_listing_url( $base_url, $search, '', 1 );
		$all_current = '' === $current_letter;
		$all_classes = 'lspd-alphabet__item lspd-alphabet__all';

		if ( $all_current ) {
			$all_classes .= ' is-active';
		}

		ob_start();
		?>
		<nav class="lspd-alphabet" aria-label="<?php echo esc_attr__( 'Filter directory by initial', 'lantern-span-people-directory' ); ?>">
			<a class="<?php echo esc_attr( $all_classes ); ?>" href="<?php echo esc_url( $all_url ); ?>"<?php if ( $all_current ) : ?> aria-current="page"<?php endif; ?>><?php esc_html_e( 'All', 'lantern-span-people-directory' ); ?></a>
			<?php foreach ( $letters as $alphabet_letter ) : ?>
				<?php if ( isset( $available_initials[ $alphabet_letter ] ) ) : ?>
					<?php
					$letter_url     = $this->build_listing_url( $base_url, $search, $alphabet_letter, 1 );
					$letter_current = $current_letter === $alphabet_letter;
					$letter_classes = 'lspd-alphabet__item';

					if ( $letter_current ) {
						$letter_classes .= ' is-active';
					}

					/* translators: %s: directory initial. */
					$letter_label = sprintf( __( 'Show profiles beginning with %s', 'lantern-span-people-directory' ), $alphabet_letter );
					?>
					<a class="<?php echo esc_attr( $letter_classes ); ?>" href="<?php echo esc_url( $letter_url ); ?>" aria-label="<?php echo esc_attr( $letter_label ); ?>"<?php if ( $letter_current ) : ?> aria-current="page"<?php endif; ?>><?php echo esc_html( $alphabet_letter ); ?></a>
				<?php else : ?>
					<span class="lspd-alphabet__item is-disabled" aria-disabled="true"><?php echo esc_html( $alphabet_letter ); ?></span>
				<?php endif; ?>
			<?php endforeach; ?>
		</nav>
		<?php
		return (string) ob_get_clean();
	}

	/**
	 * Renders one compact directory card.
	 *
	 * @param WP_Post $person       Person record.
	 * @param string  $base_url     Directory URL.
	 * @param string  $search       Current search.
	 * @param string  $letter       Current letter.
	 * @param int     $current_page Current page.
	 * @return string
	 */
	private function render_card( $person, $base_url, $search, $letter, $current_page ) {
		if ( ! ( $person instanceof WP_Post ) ) {
			return '';
		}

		$person_id = absint( $person->ID );
		$name      = is_string( $person->post_title ) ? $person->post_title : '';
		$role      = get_post_meta( $person_id, '_lspd_role', true );
		$email     = get_post_meta( $person_id, '_lspd_email', true );
		$phone     = get_post_meta( $person_id, '_lspd_phone', true );
		$role      = is_string( $role ) ? $role : '';
		$email     = is_string( $email ) && is_email( $email ) ? $email : '';
		$phone     = is_string( $phone ) ? $phone : '';
		$tel       = preg_replace( '/[^0-9+]/', '', $phone );
		$tel       = is_string( $tel ) ? $tel : '';
		$list_url  = $this->build_listing_url( $base_url, $search, $letter, $current_page );
		$url       = add_query_arg( 'lspd_person', $person_id, $list_url );

		/* translators: %s: person name. */
		$profile_label = sprintf( __( 'View profile for %s', 'lantern-span-people-directory' ), $name );

		ob_start();
		?>
		<article class="lspd-card lspd-card--horizontal">
			<a class="lspd-card__photo-link" href="<?php echo esc_url( $url ); ?>" aria-label="<?php echo esc_attr( $profile_label ); ?>">
				<div class="lspd-card__photo">
					<?php echo wp_kses_post( $this->render_photo( $person_id, $name, 'card' ) ); ?>
				</div>
			</a>

			<div class="lspd-card__body">
				<h4><a class="lspd-card__name-link" href="<?php echo esc_url( $url ); ?>"><?php echo esc_html( $name ); ?></a></h4>
				<?php if ( '' !== $role ) : ?>
					<p class="lspd-card__role"><?php echo esc_html( $role ); ?></p>
				<?php endif; ?>

				<?php if ( '' !== $email || '' !== $phone ) : ?>
					<div class="lspd-card__contact">
						<?php if ( '' !== $email ) : ?>
							<div class="lspd-card__contact-item">
								<span class="lspd-card__contact-label"><?php esc_html_e( 'Email', 'lantern-span-people-directory' ); ?></span>
								<a class="lspd-card__contact-value" href="<?php echo esc_url( 'mailto:' . $email ); ?>" title="<?php echo esc_attr( $email ); ?>"><?php echo wp_kses( $this->format_email_display( $email ), array( 'wbr' => array() ) ); ?></a>
							</div>
						<?php endif; ?>

						<?php if ( '' !== $phone ) : ?>
							<div class="lspd-card__contact-item">
								<span class="lspd-card__contact-label"><?php esc_html_e( 'Phone', 'lantern-span-people-directory' ); ?></span>
								<?php if ( '' !== $tel ) : ?>
									<a class="lspd-card__contact-value" href="<?php echo esc_url( 'tel:' . $tel ); ?>"><?php echo esc_html( $phone ); ?></a>
								<?php else : ?>
									<span class="lspd-card__contact-value"><?php echo esc_html( $phone ); ?></span>
								<?php endif; ?>
							</div>
						<?php endif; ?>
					</div>
				<?php endif; ?>

				<a class="lspd-card__view" href="<?php echo esc_url( $url ); ?>"><?php esc_html_e( 'View profile', 'lantern-span-people-directory' ); ?></a>
			</div>
		</article>
		<?php
		return (string) ob_get_clean();
	}

	/**
	 * Adds safe wrap opportunities after common email-address separators.
	 *
	 * The address remains unchanged in the mailto link. Only the visible card
	 * text receives word-break opportunities, which keeps long addresses from
	 * ending with awkward one- or two-character fragments when space permits a
	 * cleaner break after a period, at sign, underscore, plus, or hyphen.
	 *
	 * @param string $email Valid email address.
	 * @return string Escaped display text containing only optional wbr tags.
	 */
	private function format_email_display( $email ) {
		$escaped = esc_html( $email );

		return str_replace(
			array( '@', '.', '_', '+', '-' ),
			array( '@<wbr>', '.<wbr>', '_<wbr>', '+<wbr>', '-<wbr>' ),
			$escaped
		);
	}

	/**
	 * Builds a directory listing URL that preserves search, letter, and page.
	 *
	 * @param string $base_url Directory page URL.
	 * @param string $search   Search term.
	 * @param string $letter   Selected initial.
	 * @param int    $page     Result page.
	 * @return string
	 */
	private function build_listing_url( $base_url, $search, $letter, $page ) {
		$args = array();

		if ( '' !== $search ) {
			$args['lspd_search'] = $search;
		}

		if ( '' !== $letter ) {
			$args['lspd_letter'] = $letter;
		}

		if ( $page > 1 ) {
			$args['lspd_page'] = absint( $page );
		}

		return empty( $args ) ? $base_url : add_query_arg( $args, $base_url );
	}

	/**
	 * Renders an individual profile.
	 *
	 * @param int    $person_id Person post ID.
	 * @param string $back_url  Directory URL that preserves the visitor's place.
	 * @return string
	 */
	private function render_profile( $person_id, $back_url ) {
		$person = get_post( $person_id );

		if ( ! ( $person instanceof WP_Post ) || LSPD_Post_Type::POST_TYPE !== $person->post_type || 'publish' !== $person->post_status ) {
			ob_start();
			?>
			<div class="lspd-profile-not-found" role="status">
				<h2><?php esc_html_e( 'Profile not found', 'lantern-span-people-directory' ); ?></h2>
				<p><?php esc_html_e( 'This profile is unavailable or has not been published.', 'lantern-span-people-directory' ); ?></p>
				<p><a class="lspd-button lspd-button--secondary" href="<?php echo esc_url( $back_url ); ?>"><?php esc_html_e( 'Back to Directory', 'lantern-span-people-directory' ); ?></a></p>
			</div>
			<?php
			return (string) ob_get_clean();
		}

		$name    = $person->post_title;
		$role    = get_post_meta( $person_id, '_lspd_role', true );
		$email   = get_post_meta( $person_id, '_lspd_email', true );
		$phone   = get_post_meta( $person_id, '_lspd_phone', true );
		$address = get_post_meta( $person_id, '_lspd_address', true );

		$role    = is_string( $role ) ? $role : '';
		$email   = is_string( $email ) && is_email( $email ) ? $email : '';
		$phone   = is_string( $phone ) ? $phone : '';
		$address = is_string( $address ) ? $address : '';
		$bio     = $this->format_biography( $person->post_content );
		$tel     = preg_replace( '/[^0-9+]/', '', $phone );
		$tel     = is_string( $tel ) ? $tel : '';

		ob_start();
		?>
		<p class="lspd-back-link"><a href="<?php echo esc_url( $back_url ); ?>">&larr; <?php esc_html_e( 'Back to Directory', 'lantern-span-people-directory' ); ?></a></p>

		<article class="lspd-profile">
			<div class="lspd-profile__photo">
				<?php echo wp_kses_post( $this->render_photo( $person_id, $name, 'profile' ) ); ?>
			</div>

			<div class="lspd-profile__content">
				<header class="lspd-profile__header">
					<h2><?php echo esc_html( $name ); ?></h2>
					<?php if ( '' !== $role ) : ?>
						<p class="lspd-profile__role"><?php echo esc_html( $role ); ?></p>
					<?php endif; ?>
				</header>

				<?php if ( '' !== $email || '' !== $phone || '' !== $address ) : ?>
					<dl class="lspd-contact-list">
						<?php if ( '' !== $email ) : ?>
							<div class="lspd-contact-list__item">
								<dt><?php esc_html_e( 'Email', 'lantern-span-people-directory' ); ?></dt>
								<dd><a href="<?php echo esc_url( 'mailto:' . $email ); ?>"><?php echo esc_html( $email ); ?></a></dd>
							</div>
						<?php endif; ?>

						<?php if ( '' !== $phone ) : ?>
							<div class="lspd-contact-list__item">
								<dt><?php esc_html_e( 'Phone', 'lantern-span-people-directory' ); ?></dt>
								<dd>
									<?php if ( '' !== $tel ) : ?>
										<a href="<?php echo esc_url( 'tel:' . $tel ); ?>"><?php echo esc_html( $phone ); ?></a>
									<?php else : ?>
										<?php echo esc_html( $phone ); ?>
									<?php endif; ?>
								</dd>
							</div>
						<?php endif; ?>

						<?php if ( '' !== $address ) : ?>
							<div class="lspd-contact-list__item">
								<dt><?php esc_html_e( 'Address', 'lantern-span-people-directory' ); ?></dt>
								<dd><?php echo wp_kses( nl2br( esc_html( $address ) ), array( 'br' => array() ) ); ?></dd>
							</div>
						<?php endif; ?>
					</dl>
				<?php endif; ?>

				<?php if ( '' !== $bio ) : ?>
					<div class="lspd-profile__bio">
						<?php echo wp_kses_post( $bio ); ?>
					</div>
				<?php endif; ?>
			</div>
		</article>
		<?php
		return (string) ob_get_clean();
	}

	/**
	 * Formats biography text without recursively applying WordPress's global
	 * `the_content` filter.
	 *
	 * Calling `apply_filters( 'the_content', ... )` from inside a shortcode can
	 * cause page-builder, shortcode, or content-filter recursion and eventually
	 * trigger a critical memory error. Lantern Span People Directory instead applies only the core
	 * text formatting needed for long biographies. No arbitrary biography length
	 * limit is imposed here.
	 *
	 * @param mixed $content Stored post content.
	 * @return string
	 */
	private function format_biography( $content ) {
		if ( ! is_string( $content ) || '' === trim( $content ) ) {
			return '';
		}

		if ( has_blocks( $content ) ) {
			$this->is_rendering_biography = true;

			try {
				$formatted = do_blocks( $content );
			} finally {
				$this->is_rendering_biography = false;
			}

			return is_string( $formatted ) ? $formatted : '';
		}

		$formatted = wptexturize( $content );

		return wpautop( $formatted );
	}

	/**
	 * Renders a profile photo or initials placeholder.
	 *
	 * @param int    $person_id Person post ID.
	 * @param string $name      Person name.
	 * @param string $context   Card or profile context.
	 * @return string
	 */
	private function render_photo( $person_id, $name, $context ) {
		if ( has_post_thumbnail( $person_id ) ) {
			/* translators: %s: person name. */
			$alt  = sprintf( __( 'Photo of %s', 'lantern-span-people-directory' ), $name );
			$size = 'profile' === $context ? 'large' : 'medium';

			return get_the_post_thumbnail(
				$person_id,
				$size,
				array(
					'class' => 'lspd-photo lspd-photo--' . sanitize_key( $context ),
					'alt'   => $alt,
				)
			);
		}

		$initials = lspd_get_initials( $name );

		return '<span class="lspd-photo-placeholder lspd-photo-placeholder--' . esc_attr( sanitize_key( $context ) ) . '" aria-hidden="true">' . esc_html( $initials ) . '</span>';
	}

	/**
	 * Renders pagination links.
	 *
	 * @param int    $total_pages  Total result pages.
	 * @param int    $current_page Current page.
	 * @param string $base_url     Directory URL.
	 * @param string $search       Current search term.
	 * @param string $letter       Current initial filter.
	 * @return string
	 */
	private function render_pagination( $total_pages, $current_page, $base_url, $search, $letter ) {
		$total_pages = absint( $total_pages );

		if ( $total_pages < 2 ) {
			return '';
		}

		$placeholder = 999999999;
		$add_args    = array();

		if ( '' !== $search ) {
			$add_args['lspd_search'] = $search;
		}

		if ( '' !== $letter ) {
			$add_args['lspd_letter'] = $letter;
		}

		$pagination = paginate_links(
			array(
				'base'      => str_replace( (string) $placeholder, '%#%', add_query_arg( 'lspd_page', $placeholder, $base_url ) ),
				'format'    => '',
				'current'   => max( 1, $current_page ),
				'total'     => $total_pages,
				'mid_size'  => 1,
				'prev_text' => __( 'Previous', 'lantern-span-people-directory' ),
				'next_text' => __( 'Next', 'lantern-span-people-directory' ),
				'type'      => 'list',
				'add_args'  => empty( $add_args ) ? false : $add_args,
			)
		);

		if ( ! is_string( $pagination ) || '' === $pagination ) {
			return '';
		}

		return '<nav class="lspd-pagination" aria-label="' . esc_attr__( 'Directory pages', 'lantern-span-people-directory' ) . '">' . wp_kses_post( $pagination ) . '</nav>';
	}
}
