<?php
/**
 * Event management module.
 *
 * @package NonprofitManager
 */

defined( 'ABSPATH' ) || exit;

/**
 * Register event post type and taxonomy on init.
 *
 * @return void
 */
function npmp_register_events_module() {
	npmp_register_event_post_type();
	npmp_register_event_taxonomy();
}
add_action( 'init', 'npmp_register_events_module' );

/**
 * Register the Events admin menu alongside other plugin modules.
 *
 * @return void
 */
function npmp_register_events_admin_menu() {
	add_menu_page(
		__( 'Events', 'nonprofit-manager' ),
		__( 'Events', 'nonprofit-manager' ),
		'edit_posts',
		'npmp-events',
		'npmp_render_events_dashboard',
		'dashicons-calendar-alt',
		3.15
	);

	add_submenu_page(
		'npmp-events',
		__( 'Overview', 'nonprofit-manager' ),
		__( 'Overview', 'nonprofit-manager' ),
		'edit_posts',
		'npmp-events',
		'npmp_render_events_dashboard'
	);

	add_submenu_page(
		'npmp-events',
		__( 'All Events', 'nonprofit-manager' ),
		__( 'All Events', 'nonprofit-manager' ),
		'edit_posts',
		'edit.php?post_type=npmp_event'
	);

	add_submenu_page(
		'npmp-events',
		__( 'Add New', 'nonprofit-manager' ),
		__( 'Add New', 'nonprofit-manager' ),
		'edit_posts',
		'post-new.php?post_type=npmp_event'
	);

	add_submenu_page(
		'npmp-events',
		__( 'Calendar Settings', 'nonprofit-manager' ),
		__( 'Settings', 'nonprofit-manager' ),
		'manage_options',
		'npmp_event_settings',
		'npmp_render_event_settings_page'
	);
}
add_action( 'admin_menu', 'npmp_register_events_admin_menu', 9 );

/**
 * Register the custom post type for events.
 *
 * @return void
 */
function npmp_register_event_post_type() {
	if ( post_type_exists( 'npmp_event' ) ) {
		return;
	}

	$labels = array(
		'name'                  => __( 'Events', 'nonprofit-manager' ),
		'singular_name'         => __( 'Event', 'nonprofit-manager' ),
		'add_new'               => __( 'Add New', 'nonprofit-manager' ),
		'add_new_item'          => __( 'Add New Event', 'nonprofit-manager' ),
		'edit_item'             => __( 'Edit Event', 'nonprofit-manager' ),
		'new_item'              => __( 'New Event', 'nonprofit-manager' ),
		'view_item'             => __( 'View Event', 'nonprofit-manager' ),
		'search_items'          => __( 'Search Events', 'nonprofit-manager' ),
		'not_found'             => __( 'No events found.', 'nonprofit-manager' ),
		'not_found_in_trash'    => __( 'No events found in Trash.', 'nonprofit-manager' ),
		'all_items'             => __( 'All Events', 'nonprofit-manager' ),
		'archives'              => __( 'Event Archives', 'nonprofit-manager' ),
		'attributes'            => __( 'Event Attributes', 'nonprofit-manager' ),
		'insert_into_item'      => __( 'Insert into event', 'nonprofit-manager' ),
		'uploaded_to_this_item' => __( 'Uploaded to this event', 'nonprofit-manager' ),
		'item_updated'          => __( 'Event updated.', 'nonprofit-manager' ),
	);

	$args = array(
		'labels'             => $labels,
		'public'             => true,
		'show_in_menu'       => false,
		// Events live under the custom Nonprofit Manager menu, so show_in_menu is
		// false. WordPress then defaults show_in_admin_bar to false too, which
		// hides the front-end "Edit Event" toolbar button that posts and pages
		// get by default. Force it on so admins can jump straight to the editor.
		'show_in_admin_bar'  => true,
		'show_in_rest'       => true,
		'has_archive'        => true,
		'rewrite'            => array( 'slug' => 'events' ),
		'menu_icon'          => 'dashicons-calendar-alt',
		'supports'           => array( 'title', 'editor', 'excerpt', 'thumbnail', 'author' ),
		'capability_type'    => 'post',
		'map_meta_cap'       => true,
	);

	register_post_type( 'npmp_event', $args );
}

/**
 * Register event categories taxonomy.
 *
 * @return void
 */
function npmp_register_event_taxonomy() {
	if ( taxonomy_exists( 'npmp_event_category' ) ) {
		return;
	}

	$labels = array(
		'name'              => __( 'Event Categories', 'nonprofit-manager' ),
		'singular_name'     => __( 'Event Category', 'nonprofit-manager' ),
		'search_items'      => __( 'Search Event Categories', 'nonprofit-manager' ),
		'all_items'         => __( 'All Event Categories', 'nonprofit-manager' ),
		'parent_item'       => __( 'Parent Event Category', 'nonprofit-manager' ),
		'parent_item_colon' => __( 'Parent Event Category:', 'nonprofit-manager' ),
		'edit_item'         => __( 'Edit Event Category', 'nonprofit-manager' ),
		'update_item'       => __( 'Update Event Category', 'nonprofit-manager' ),
		'add_new_item'      => __( 'Add New Event Category', 'nonprofit-manager' ),
		'new_item_name'     => __( 'New Event Category Name', 'nonprofit-manager' ),
		'menu_name'         => __( 'Event Categories', 'nonprofit-manager' ),
	);

	$args = array(
		'labels'            => $labels,
		'hierarchical'      => true,
		'show_ui'           => true,
		'show_in_rest'      => true,
		'show_admin_column' => true,
		'rewrite'           => array( 'slug' => 'event-category' ),
	);

	register_taxonomy( 'npmp_event_category', array( 'npmp_event' ), $args );
}

/**
 * Events module dashboard page.
 *
 * @return void
 */
function npmp_render_events_dashboard() {

	if ( ! current_user_can( 'edit_posts' ) ) {
		wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'nonprofit-manager' ) );
	}

	$is_pro       = npmp_is_pro();
	$current_time = current_time( 'mysql' );

	// Query upcoming events
	$upcoming_query = new WP_Query(
		array(
			'post_type'      => 'npmp_event',
			// Overview widget: cap the upcoming list. Unbounded -1 grew without
			// limit over time; the full list lives in the Events admin screen.
			'posts_per_page' => 50,
			'orderby'        => 'meta_value',
			'meta_key'       => '_npmp_event_start', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Event scheduling relies on an indexed meta key.
			'order'          => 'ASC',
			'post_status'    => 'publish',
			// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Event scheduling relies on indexed meta keys.
			'meta_query'     => array(
				array(
					'key'     => '_npmp_event_start',
					'value'   => $current_time,
					'compare' => '>=',
					'type'    => 'DATETIME',
				),
			),
		)
	);

	// Query past events
	$past_query = new WP_Query(
		array(
			'post_type'      => 'npmp_event',
			'posts_per_page' => 10,
			'orderby'        => 'meta_value',
			'meta_key'       => '_npmp_event_start', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Event scheduling relies on an indexed meta key.
			'order'          => 'DESC',
			'post_status'    => 'publish',
			// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Event scheduling relies on indexed meta keys.
			'meta_query'     => array(
				array(
					'key'     => '_npmp_event_start',
					'value'   => $current_time,
					'compare' => '<',
					'type'    => 'DATETIME',
				),
			),
		)
	);

	$calendar_page_id = absint( get_option( 'npmp_calendar_page_id', 0 ) );
	$calendar_link    = $calendar_page_id ? get_permalink( $calendar_page_id ) : '';

	echo '<div class="wrap">';
	echo '<h1>' . esc_html__( 'Events Overview', 'nonprofit-manager' ) . '</h1>';
	echo '<p>' . esc_html__( 'Create in-person or virtual events, promote them on the calendar, and keep your community up to date.', 'nonprofit-manager' ) . '</p>';

	echo '<p><a class="button button-primary" href="' . esc_url( admin_url( 'post-new.php?post_type=npmp_event' ) ) . '">' . esc_html__( 'Add New Event', 'nonprofit-manager' ) . '</a> ';
	echo '<a class="button" href="' . esc_url( admin_url( 'edit.php?post_type=npmp_event' ) ) . '">' . esc_html__( 'Manage All Events', 'nonprofit-manager' ) . '</a></p>';

	if ( $calendar_link ) {
		echo '<p>' . sprintf(
			wp_kses_post(
				/* translators: %1$s: Public calendar page URL. */
				__( 'Your public calendar page: <a href="%1$s" target="_blank" rel="noopener">%1$s</a>', 'nonprofit-manager' )
			),
			esc_url( $calendar_link )
		) . '</p>';
	} else {
		echo '<p>' . esc_html__( 'Tip: Assign a default calendar page so visitors can always find your schedule.', 'nonprofit-manager' ) . '</p>';
	}

	// Upcoming Events Card
	echo '<div class="card" style="max-width:900px; margin-bottom: 20px;">';
	echo '<h2 class="title">' . esc_html__( 'Upcoming Events', 'nonprofit-manager' ) . '</h2>';

	if ( $upcoming_query->have_posts() ) {
		echo '<table class="widefat striped">';
		echo '<thead><tr>';
		echo '<th scope="col">' . esc_html__( 'Event', 'nonprofit-manager' ) . '</th>';
		echo '<th scope="col">' . esc_html__( 'When', 'nonprofit-manager' ) . '</th>';
		echo '<th scope="col">' . esc_html__( 'Location', 'nonprofit-manager' ) . '</th>';
		echo '</tr></thead><tbody>';

		while ( $upcoming_query->have_posts() ) {
			$upcoming_query->the_post();
			// npmp_get_event_details() already fetches location meta into
			// $details['location']; re-fetching it separately was redundant.
			$details  = npmp_get_event_details( get_the_ID() );
			$location = $details['location'];

			echo '<tr>';
			echo '<td><a href="' . esc_url( get_edit_post_link() ) . '">' . esc_html( get_the_title() ) . '</a></td>';
			echo '<td>' . wp_kses_post( npmp_format_event_datetime( $details ) ) . '</td>';
			echo '<td>' . esc_html( $location ? $location : '—' ) . '</td>';


			echo '</tr>';
		}
		echo '</tbody></table>';
	} else {
		echo '<p>' . esc_html__( 'No upcoming events scheduled yet. Start by creating one above.', 'nonprofit-manager' ) . '</p>';
	}
	wp_reset_postdata();
	echo '</div>';

	// Past Events Card
	echo '<div class="card" style="max-width:900px; margin-bottom: 20px;">';
	echo '<h2 class="title">' . esc_html__( 'Recent Past Events', 'nonprofit-manager' ) . '</h2>';

	if ( $past_query->have_posts() ) {
		echo '<table class="widefat striped">';
		echo '<thead><tr>';
		echo '<th scope="col">' . esc_html__( 'Event', 'nonprofit-manager' ) . '</th>';
		echo '<th scope="col">' . esc_html__( 'When', 'nonprofit-manager' ) . '</th>';
		echo '<th scope="col">' . esc_html__( 'Location', 'nonprofit-manager' ) . '</th>';
		echo '</tr></thead><tbody>';

		while ( $past_query->have_posts() ) {
			$past_query->the_post();
			// npmp_get_event_details() already fetches location meta into
			// $details['location']; re-fetching it separately was redundant.
			$details  = npmp_get_event_details( get_the_ID() );
			$location = $details['location'];

			echo '<tr>';
			echo '<td><a href="' . esc_url( get_edit_post_link() ) . '">' . esc_html( get_the_title() ) . '</a></td>';
			echo '<td>' . wp_kses_post( npmp_format_event_datetime( $details ) ) . '</td>';
			echo '<td>' . esc_html( $location ? $location : '—' ) . '</td>';


			echo '</tr>';
		}
		echo '</tbody></table>';
		echo '<p class="description">' . esc_html__( 'Showing up to 10 recent past events.', 'nonprofit-manager' ) . '</p>';
	} else {
		echo '<p>' . esc_html__( 'No past events found.', 'nonprofit-manager' ) . '</p>';
	}
	wp_reset_postdata();
	echo '</div>';

	// The event-registration upsell card and Registrations column were
	// removed: no registration feature exists in either plugin, so the card
	// advertised (and Pro customers saw a permanently empty column for) a
	// capability nobody could get.

	echo '</div>';
}

/**
 * Register event details meta box.
 *
 * @return void
 */
function npmp_register_event_meta_boxes() {
	add_meta_box(
		'npmp-event-details',
		__( 'Event Details', 'nonprofit-manager' ),
		'npmp_render_event_meta_box',
		'npmp_event',
		'normal',
		'high'
	);
}
add_action( 'add_meta_boxes_npmp_event', 'npmp_register_event_meta_boxes' );

/**
 * Render the event details meta box.
 *
 * @param WP_Post $post Current post object.
 * @return void
 */
function npmp_render_event_meta_box( $post ) {
	wp_nonce_field( 'npmp_save_event_details', 'npmp_event_details_nonce' );

	$start        = get_post_meta( $post->ID, '_npmp_event_start', true );
	$end          = get_post_meta( $post->ID, '_npmp_event_end', true );
	$location     = get_post_meta( $post->ID, '_npmp_event_location', true );
	$external_url = get_post_meta( $post->ID, '_npmp_event_url', true );

	// Back-compat with legacy meta.
	if ( empty( $start ) ) {
		$legacy_date = get_post_meta( $post->ID, '_npmp_event_date', true );
		if ( $legacy_date ) {
			$start = $legacy_date . ' 00:00:00';
		}
	}

	$start_date = $start ? substr( $start, 0, 10 ) : '';
	$start_time = $start && strlen( $start ) > 10 ? substr( $start, 11, 5 ) : '';
	$end_date   = $end ? substr( $end, 0, 10 ) : '';
	$end_time   = $end && strlen( $end ) > 10 ? substr( $end, 11, 5 ) : '';

	echo '<p><label for="npmp_event_start_date"><strong>' . esc_html__( 'Start Date', 'nonprofit-manager' ) . '</strong></label><br>';
	echo '<input type="date" id="npmp_event_start_date" name="npmp_event_start_date" value="' . esc_attr( $start_date ) . '" class="regular-text" required></p>';

	echo '<p><label for="npmp_event_start_time">' . esc_html__( 'Start Time', 'nonprofit-manager' ) . '</label><br>';
	echo '<input type="time" id="npmp_event_start_time" name="npmp_event_start_time" value="' . esc_attr( $start_time ) . '" class="regular-text"></p>';

	echo '<hr>';

	echo '<p><label for="npmp_event_end_date"><strong>' . esc_html__( 'End Date (optional)', 'nonprofit-manager' ) . '</strong></label><br>';
	echo '<input type="date" id="npmp_event_end_date" name="npmp_event_end_date" value="' . esc_attr( $end_date ) . '" class="regular-text"></p>';

	echo '<p><label for="npmp_event_end_time">' . esc_html__( 'End Time (optional)', 'nonprofit-manager' ) . '</label><br>';
	echo '<input type="time" id="npmp_event_end_time" name="npmp_event_end_time" value="' . esc_attr( $end_time ) . '" class="regular-text"></p>';

	echo '<hr>';

	echo '<p><label for="npmp_event_location"><strong>' . esc_html__( 'Location', 'nonprofit-manager' ) . '</strong></label><br>';
	echo '<input type="text" id="npmp_event_location" name="npmp_event_location" value="' . esc_attr( $location ) . '" class="widefat" placeholder="' . esc_attr__( 'Venue name or address', 'nonprofit-manager' ) . '"></p>';

	echo '<p><label for="npmp_event_url"><strong>' . esc_html__( 'Event Link', 'nonprofit-manager' ) . '</strong></label><br>';
	echo '<input type="url" id="npmp_event_url" name="npmp_event_url" value="' . esc_attr( $external_url ) . '" class="widefat" placeholder="' . esc_attr__( 'https://example.org', 'nonprofit-manager' ) . '"></p>';
}

/**
 * Persist event meta data.
 *
 * @param int $post_id Post ID.
 * @return void
 */
function npmp_save_event_meta( $post_id ) {
	if ( ! isset( $_POST['npmp_event_details_nonce'] ) ||
		! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['npmp_event_details_nonce'] ) ), 'npmp_save_event_details' )
	) {
		return;
	}

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

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

	$start_date = sanitize_text_field( wp_unslash( $_POST['npmp_event_start_date'] ?? '' ) );
	$start_time = sanitize_text_field( wp_unslash( $_POST['npmp_event_start_time'] ?? '' ) );
	$end_date   = sanitize_text_field( wp_unslash( $_POST['npmp_event_end_date'] ?? '' ) );
	$end_time   = sanitize_text_field( wp_unslash( $_POST['npmp_event_end_time'] ?? '' ) );
	$location   = sanitize_text_field( wp_unslash( $_POST['npmp_event_location'] ?? '' ) );
	$url        = esc_url_raw( wp_unslash( $_POST['npmp_event_url'] ?? '' ) );

	$start = '';
	if ( $start_date ) {
		$start_time = $start_time ?: '00:00';
		$start      = $start_date . ' ' . $start_time . ':00';
	}

	$end = '';
	if ( $end_date ) {
		$end_time = $end_time ?: '23:59';
		$end      = $end_date . ' ' . $end_time . ':00';
	}

	if ( $start ) {
		update_post_meta( $post_id, '_npmp_event_start', $start );
	} else {
		delete_post_meta( $post_id, '_npmp_event_start' );
	}

	if ( $end ) {
		update_post_meta( $post_id, '_npmp_event_end', $end );
	} else {
		delete_post_meta( $post_id, '_npmp_event_end' );
	}

	if ( $location ) {
		update_post_meta( $post_id, '_npmp_event_location', $location );
	} else {
		delete_post_meta( $post_id, '_npmp_event_location' );
	}

	if ( $url ) {
		update_post_meta( $post_id, '_npmp_event_url', $url );
	} else {
		delete_post_meta( $post_id, '_npmp_event_url' );
	}
}
add_action( 'save_post_npmp_event', 'npmp_save_event_meta' );

/**
 * Retrieve structured event details.
 *
 * @param int $post_id Event ID.
 * @return array<string,string>
 */
function npmp_get_event_details( $post_id ) {
	$details = array(
		'start'    => get_post_meta( $post_id, '_npmp_event_start', true ),
		'end'      => get_post_meta( $post_id, '_npmp_event_end', true ),
		'location' => get_post_meta( $post_id, '_npmp_event_location', true ),
		'url'      => get_post_meta( $post_id, '_npmp_event_url', true ),
	);

	if ( empty( $details['start'] ) ) {
		$legacy_date = get_post_meta( $post_id, '_npmp_event_date', true );
		if ( $legacy_date ) {
			$details['start'] = $legacy_date . ' 00:00:00';
		}
	}

	return $details;
}

/**
 * Convert a stored "Y-m-d H:i:s" event date/time (entered by the admin as a
 * local wall-clock value in the site's configured WordPress timezone — see
 * npmp_save_event_meta()) into a true Unix timestamp.
 *
 * WordPress forces PHP's default timezone to UTC on every request (see
 * wp-settings.php), so a bare strtotime() on this naive string always parses
 * it as UTC rather than as the site's real timezone. Formatting that result
 * with wp_date()/$timezone then shifts the displayed time by the site's UTC
 * offset — e.g. an event entered as "10:00" on a UTC-7 site would display as
 * "3:00". Parsing explicitly against wp_timezone() avoids that shift.
 *
 * @param string $mysql_datetime Stored "Y-m-d H:i:s" value.
 * @return int|false Unix timestamp, or false if the value can't be parsed.
 */
function npmp_event_datetime_to_timestamp( $mysql_datetime ) {
	if ( empty( $mysql_datetime ) ) {
		return false;
	}

	try {
		$date = new DateTimeImmutable( $mysql_datetime, wp_timezone() );
	} catch ( Exception $e ) {
		return false;
	}

	return $date->getTimestamp();
}

/**
 * Render formatted event date/time for lists.
 *
 * @param array $details Event details.
 * @return string
 */
function npmp_format_event_datetime( $details ) {
	$start = ! empty( $details['start'] ) ? npmp_event_datetime_to_timestamp( $details['start'] ) : false;
	$end   = ! empty( $details['end'] ) ? npmp_event_datetime_to_timestamp( $details['end'] ) : false;

	if ( ! $start ) {
		return '';
	}

	// wp_date(), not date_i18n(), because $start/$end are now true Unix
	// timestamps (see npmp_event_datetime_to_timestamp()). date_i18n()
	// assumes a timestamp already carries the site's UTC offset baked in and
	// re-applies the offset itself, which would double-shift a real one.
	$timezone    = wp_timezone();
	$date_format = get_option( 'date_format' );
	$time_format = get_option( 'time_format' );

	$formatted = wp_date( $date_format, $start, $timezone );

	if ( $time_format ) {
		$formatted .= ' ' . wp_date( $time_format, $start, $timezone );
	}

	if ( $end ) {
		$formatted .= ' &ndash; ';
		$formatted .= wp_date( $date_format, $end, $timezone );
		if ( $time_format ) {
			$formatted .= ' ' . wp_date( $time_format, $end, $timezone );
		}
	}

	return $formatted;
}

/**
 * Shortcode handler for the calendar. Renders a month grid, a single-week grid,
 * or an upcoming-events list, with a toolbar to switch views and move between
 * periods. The view and anchor date come from the URL (npmp_view / npmp_date),
 * then the shortcode attributes, then the admin default. Navigation links are
 * read-only and shareable, so no nonce is used; all values are validated.
 *
 * @param array $atts Shortcode attributes.
 * @return string
 */
function npmp_calendar_shortcode( $atts ) {
	$atts = shortcode_atts(
		array(
			'view'     => '',
			'month'    => '',
			'year'     => '',
			'category' => '',
		),
		$atts,
		'npmp_calendar'
	);

	$timezone = wp_timezone();
	$category = sanitize_text_field( $atts['category'] );
	$allowed  = array( 'month', 'week', 'list' );

	// Resolve the view: URL param > shortcode attribute > admin default > month.
	$view = '';
	if ( isset( $_GET['npmp_view'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display switch; value allowlisted below.
		$maybe = sanitize_key( wp_unslash( $_GET['npmp_view'] ) );
		if ( in_array( $maybe, $allowed, true ) ) {
			$view = $maybe;
		}
	}
	if ( '' === $view && in_array( $atts['view'], $allowed, true ) ) {
		$view = $atts['view'];
	}
	if ( '' === $view ) {
		$default = get_option( 'npmp_calendar_default_view', 'month' );
		$view    = in_array( $default, $allowed, true ) ? $default : 'month';
	}

	// Resolve the anchor date (strictly validated; npmp_date preferred, npmp_month legacy).
	$anchor = new DateTimeImmutable( 'today', $timezone );
	if ( isset( $_GET['npmp_date'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only date navigation, validated below.
		$raw = sanitize_text_field( wp_unslash( $_GET['npmp_date'] ) );
		if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $raw ) ) {
			$maybe = DateTimeImmutable::createFromFormat( 'Y-m-d', $raw, $timezone );
			if ( $maybe instanceof DateTimeImmutable ) {
				$anchor = $maybe;
			}
		}
	} elseif ( isset( $_GET['npmp_month'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Legacy read-only nav param, validated below.
		$raw = sanitize_text_field( wp_unslash( $_GET['npmp_month'] ) );
		if ( preg_match( '/^\d{4}-\d{2}$/', $raw ) ) {
			$maybe = DateTimeImmutable::createFromFormat( 'Y-m-d', $raw . '-01', $timezone );
			if ( $maybe instanceof DateTimeImmutable ) {
				$anchor = $maybe;
			}
		}
	} elseif ( '' !== $atts['month'] && '' !== $atts['year'] ) {
		$mm = absint( $atts['month'] );
		$yy = absint( $atts['year'] );
		if ( $mm >= 1 && $mm <= 12 && $yy >= 1970 ) {
			$maybe = DateTimeImmutable::createFromFormat( 'Y-m-d', sprintf( '%04d-%02d-01', $yy, $mm ), $timezone );
			if ( $maybe instanceof DateTimeImmutable ) {
				$anchor = $maybe;
			}
		}
	}

	$accent = sanitize_hex_color( (string) get_option( 'npmp_calendar_accent', '#2271b1' ) );
	$style  = $accent ? ' style="--npmp-cal-accent:' . esc_attr( $accent ) . '"' : '';

	$out  = '<div class="npmp-calendar-wrapper" data-view="' . esc_attr( $view ) . '"' . $style . '>';
	$out .= npmp_calendar_render_toolbar( $view, $anchor, $timezone, $category );

	if ( 'list' === $view ) {
		$out .= npmp_calendar_render_list_view( $category );
	} elseif ( 'week' === $view ) {
		$out .= npmp_calendar_render_week_view( $anchor, $timezone, $category );
	} else {
		$out .= npmp_calendar_render_month_view( $anchor, $timezone, $category );
	}

	$out .= '</div>';

	return $out;
}

/**
 * Query events overlapping a date range and bucket them by Y-m-d.
 *
 * @param DateTimeImmutable $start    Range start.
 * @param DateTimeImmutable $end      Range end.
 * @param string            $category Optional category slug filter.
 * @param DateTimeZone      $timezone Site timezone.
 * @return array<string, array<int, array>>
 */
function npmp_calendar_query_events_by_day( $start, $end, $category, $timezone ) {
	$range_start = $start->setTime( 0, 0, 0 );
	$range_end   = $end->setTime( 23, 59, 59 );
	$start_sql   = $range_start->format( 'Y-m-d H:i:s' );
	$end_sql     = $range_end->format( 'Y-m-d H:i:s' );

	$query_args = array(
		'post_type'      => 'npmp_event',
		'post_status'    => 'publish',
		'posts_per_page' => -1,
		'orderby'        => 'meta_value',
		'meta_key'       => '_npmp_event_start', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Calendar ordering relies on event start timestamps.
		'order'          => 'ASC',
		// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Calendar filtering needs date-range meta comparisons.
		'meta_query'     => array(
			'relation' => 'OR',
			array(
				'key'     => '_npmp_event_start',
				'value'   => array( $start_sql, $end_sql ),
				'compare' => 'BETWEEN',
				'type'    => 'DATETIME',
			),
			array(
				'relation' => 'AND',
				array(
					'key'     => '_npmp_event_start',
					'value'   => $start_sql,
					'compare' => '<',
					'type'    => 'DATETIME',
				),
				array(
					'key'     => '_npmp_event_end',
					'value'   => $end_sql,
					'compare' => '>=',
					'type'    => 'DATETIME',
				),
			),
		),
	);

	if ( '' !== $category ) {
		// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Event categories are taxonomy filters.
		$query_args['tax_query'] = array(
			array(
				'taxonomy' => 'npmp_event_category',
				'field'    => 'slug',
				'terms'    => array_map( 'sanitize_title', explode( ',', $category ) ),
			),
		);
	}

	$events_query  = new WP_Query( $query_args );
	$events_by_day = array();
	$start_key     = $range_start->format( 'Y-m-d' );
	$end_key       = $range_end->format( 'Y-m-d' );

	if ( $events_query->have_posts() ) {
		while ( $events_query->have_posts() ) {
			$events_query->the_post();
			$details  = npmp_get_event_details( get_the_ID() );
			$start_ts = ! empty( $details['start'] ) ? npmp_event_datetime_to_timestamp( $details['start'] ) : 0;
			$end_ts   = ! empty( $details['end'] ) ? npmp_event_datetime_to_timestamp( $details['end'] ) : $start_ts;

			if ( ! $start_ts ) {
				continue;
			}
			if ( $end_ts < $start_ts ) {
				$end_ts = $start_ts;
			}

			$event_start_key = wp_date( 'Y-m-d', $start_ts, $timezone );
			$event_end_key   = wp_date( 'Y-m-d', $end_ts, $timezone );

			$event_data = array(
				'title'     => get_the_title(),
				'permalink' => get_permalink(),
				'start_ts'  => $start_ts,
				'end_ts'    => $end_ts,
				'start_key' => $event_start_key,
			);

			// Step one calendar day at a time in the site's own timezone
			// (not with raw +86400s arithmetic, which would misalign across
			// a DST transition) from the event's first day through its last
			// day, comparing calendar-day keys rather than full timestamps.
			// Comparing timestamps instead (as this used to) drops a
			// multi-day event's last day whenever its end time-of-day is
			// earlier than its start time-of-day — e.g. a Friday 6pm to
			// Sunday 2pm event would stop at Saturday, since "start + 2
			// days" (Sunday 6pm) already falls after the Sunday 2pm end.
			$loop_date = ( new DateTimeImmutable( '@' . $start_ts ) )->setTimezone( $timezone );
			$loop_key  = $event_start_key;
			while ( $loop_key <= $event_end_key ) {
				if ( $loop_key >= $start_key && $loop_key <= $end_key ) {
					$events_by_day[ $loop_key ][] = $event_data;
				}
				$loop_date = $loop_date->modify( '+1 day' );
				$loop_key  = $loop_date->format( 'Y-m-d' );
			}
		}
	}
	wp_reset_postdata();

	foreach ( $events_by_day as &$day_events ) {
		usort(
			$day_events,
			static function ( $a, $b ) {
				return $a['start_ts'] <=> $b['start_ts'];
			}
		);
	}
	unset( $day_events );

	return $events_by_day;
}

/**
 * Render the events for a single day as a list.
 *
 * @param array        $day_events List of event arrays.
 * @param string       $day_key    Y-m-d for the cell.
 * @param DateTimeZone $timezone   Site timezone.
 * @return string
 */
function npmp_calendar_day_events_html( $day_events, $day_key, $timezone ) {
	if ( empty( $day_events ) ) {
		return '';
	}

	$show_times  = (bool) get_option( 'npmp_calendar_show_times', 1 );
	$time_format = get_option( 'time_format' );
	$html        = '<ul class="npmp-calendar-events">';

	foreach ( $day_events as $event ) {
		$is_start_day = ( $event['start_key'] === $day_key );
		$time_label   = '';

		if ( $show_times && $is_start_day && $time_format ) {
			$time_label = wp_date( $time_format, $event['start_ts'], $timezone );
		} elseif ( ! $is_start_day && $event['start_ts'] !== $event['end_ts'] ) {
			$time_label = __( 'Continues', 'nonprofit-manager' );
		}

		$html .= '<li><a href="' . esc_url( $event['permalink'] ) . '">';
		if ( $time_label ) {
			$html .= '<span class="npmp-calendar-event-time">' . esc_html( $time_label ) . '</span> ';
		}
		$html .= esc_html( $event['title'] );
		$html .= '</a></li>';
	}

	$html .= '</ul>';

	return $html;
}

/**
 * Build a navigation URL for a view + anchor date, preserving the current page.
 *
 * @param string            $view   month|week|list.
 * @param DateTimeImmutable $anchor Anchor date.
 * @return string
 */
function npmp_calendar_nav_url( $view, $anchor ) {
	$base = remove_query_arg( array( 'npmp_view', 'npmp_date', 'npmp_month', 'npmp_calendar_nonce' ) );
	return add_query_arg(
		array(
			'npmp_view' => $view,
			'npmp_date' => $anchor->format( 'Y-m-d' ),
		),
		$base
	);
}

/**
 * First day of the week containing $date, honoring the WordPress start_of_week.
 *
 * @param DateTimeImmutable $date          Any date in the target week.
 * @param int               $start_of_week 0 (Sun) – 6 (Sat).
 * @return DateTimeImmutable
 */
function npmp_calendar_week_start( $date, $start_of_week ) {
	$offset = ( 7 + (int) $date->format( 'w' ) - $start_of_week ) % 7;
	return $date->setTime( 0, 0, 0 )->modify( '-' . $offset . ' days' );
}

/**
 * Localized weekday labels indexed 0 (Sunday) – 6 (Saturday).
 *
 * @return array
 */
function npmp_calendar_weekday_labels() {
	return array(
		__( 'Sunday', 'nonprofit-manager' ),
		__( 'Monday', 'nonprofit-manager' ),
		__( 'Tuesday', 'nonprofit-manager' ),
		__( 'Wednesday', 'nonprofit-manager' ),
		__( 'Thursday', 'nonprofit-manager' ),
		__( 'Friday', 'nonprofit-manager' ),
		__( 'Saturday', 'nonprofit-manager' ),
	);
}

/**
 * Render the toolbar: date navigation, period title, and view-switch tabs.
 *
 * @param string            $view     Current view.
 * @param DateTimeImmutable $anchor   Anchor date.
 * @param DateTimeZone      $timezone Site timezone.
 * @param string            $category Active category.
 * @return string
 */
function npmp_calendar_render_toolbar( $view, $anchor, $timezone, $category ) {
	$today = new DateTimeImmutable( 'today', $timezone );

	if ( 'week' === $view ) {
		$start_of_week = (int) get_option( 'start_of_week', 0 );
		$week_start    = npmp_calendar_week_start( $anchor, $start_of_week );
		$week_end      = $week_start->modify( '+6 days' );
		$title         = wp_date( 'M j', $week_start->getTimestamp(), $timezone ) . ' – ' . wp_date( 'M j, Y', $week_end->getTimestamp(), $timezone );
		$prev          = $anchor->modify( '-1 week' );
		$next          = $anchor->modify( '+1 week' );
	} elseif ( 'list' === $view ) {
		$title = __( 'Upcoming events', 'nonprofit-manager' );
		$prev  = null;
		$next  = null;
	} else {
		$title = wp_date( 'F Y', $anchor->getTimestamp(), $timezone );
		$prev  = $anchor->modify( 'first day of -1 month' );
		$next  = $anchor->modify( 'first day of +1 month' );
	}

	$views = array(
		'month' => __( 'Month', 'nonprofit-manager' ),
		'week'  => __( 'Week', 'nonprofit-manager' ),
		'list'  => __( 'List', 'nonprofit-manager' ),
	);

	$html = '<div class="npmp-calendar-toolbar">';

	if ( null !== $prev && null !== $next ) {
		$prev_year = $anchor->modify( '-1 year' );
		$next_year = $anchor->modify( '+1 year' );
		$html     .= '<div class="npmp-calendar-nav">';
		$html     .= '<a href="' . esc_url( npmp_calendar_nav_url( $view, $prev_year ) ) . '" aria-label="' . esc_attr__( 'Previous year', 'nonprofit-manager' ) . '" title="' . esc_attr__( 'Previous year', 'nonprofit-manager' ) . '">&laquo;</a>';
		$html     .= '<a href="' . esc_url( npmp_calendar_nav_url( $view, $prev ) ) . '" aria-label="' . esc_attr__( 'Previous', 'nonprofit-manager' ) . '" title="' . esc_attr__( 'Previous', 'nonprofit-manager' ) . '">&lsaquo;</a>';
		$html     .= '<a class="npmp-cal-today" href="' . esc_url( npmp_calendar_nav_url( $view, $today ) ) . '">' . esc_html__( 'Today', 'nonprofit-manager' ) . '</a>';
		$html     .= '<a href="' . esc_url( npmp_calendar_nav_url( $view, $next ) ) . '" aria-label="' . esc_attr__( 'Next', 'nonprofit-manager' ) . '" title="' . esc_attr__( 'Next', 'nonprofit-manager' ) . '">&rsaquo;</a>';
		$html     .= '<a href="' . esc_url( npmp_calendar_nav_url( $view, $next_year ) ) . '" aria-label="' . esc_attr__( 'Next year', 'nonprofit-manager' ) . '" title="' . esc_attr__( 'Next year', 'nonprofit-manager' ) . '">&raquo;</a>';
		$html     .= '</div>';
	}

	$html .= '<h2 class="npmp-calendar-title">' . esc_html( $title ) . '</h2>';

	$html .= '<div class="npmp-calendar-views">';
	foreach ( $views as $key => $label ) {
		$current = ( $key === $view ) ? ' aria-current="true"' : '';
		$html   .= '<a href="' . esc_url( npmp_calendar_nav_url( $key, $anchor ) ) . '"' . $current . '>' . esc_html( $label ) . '</a>';
	}
	$html .= '</div>';

	$html .= '</div>';

	return $html;
}

/**
 * Render the month grid.
 *
 * @param DateTimeImmutable $anchor   Any date in the target month.
 * @param DateTimeZone      $timezone Site timezone.
 * @param string            $category Optional category filter.
 * @return string
 */
function npmp_calendar_render_month_view( $anchor, $timezone, $category ) {
	$first         = $anchor->modify( 'first day of this month' )->setTime( 0, 0, 0 );
	$last          = $anchor->modify( 'last day of this month' );
	$events_by_day = npmp_calendar_query_events_by_day( $first, $last, $category, $timezone );

	$today_key     = wp_date( 'Y-m-d', time(), $timezone );
	$start_of_week = (int) get_option( 'start_of_week', 0 );
	$days_in_month = (int) $anchor->format( 't' );
	$month_key     = $anchor->format( 'Y-m' );
	$labels        = npmp_calendar_weekday_labels();

	$offset   = ( 7 + (int) $first->format( 'w' ) - $start_of_week ) % 7;
	$headings = array();
	for ( $i = 0; $i < 7; $i++ ) {
		$headings[] = $labels[ ( $start_of_week + $i ) % 7 ];
	}

	$html = '<table class="npmp-calendar-table"><thead><tr>';
	foreach ( $headings as $heading ) {
		$html .= '<th scope="col">' . esc_html( $heading ) . '</th>';
	}
	$html .= '</tr></thead><tbody><tr>';

	for ( $blank = 0; $blank < $offset; $blank++ ) {
		$html .= '<td class="npmp-calendar-day npmp-calendar-day--pad"></td>';
	}

	$cell = $offset;
	for ( $day = 1; $day <= $days_in_month; $day++ ) {
		$day_key = sprintf( '%s-%02d', $month_key, $day );
		$classes = array( 'npmp-calendar-day' );
		if ( ! empty( $events_by_day[ $day_key ] ) ) {
			$classes[] = 'npmp-calendar-day--has-events';
		}
		if ( $day_key === $today_key ) {
			$classes[] = 'npmp-calendar-day--today';
		}

		$html .= '<td class="' . esc_attr( implode( ' ', $classes ) ) . '">';
		$html .= '<div class="npmp-calendar-day-number">' . esc_html( $day ) . '</div>';
		$html .= npmp_calendar_day_events_html( $events_by_day[ $day_key ] ?? array(), $day_key, $timezone );
		$html .= '</td>';

		$cell++;
		if ( 0 === $cell % 7 && $day < $days_in_month ) {
			$html .= '</tr><tr>';
		}
	}

	if ( 0 !== $cell % 7 ) {
		for ( $pad = $cell % 7; $pad < 7; $pad++ ) {
			$html .= '<td class="npmp-calendar-day npmp-calendar-day--pad"></td>';
		}
	}

	$html .= '</tr></tbody></table>';

	return $html;
}

/**
 * Render a single-week grid.
 *
 * @param DateTimeImmutable $anchor   Any date in the target week.
 * @param DateTimeZone      $timezone Site timezone.
 * @param string            $category Optional category filter.
 * @return string
 */
function npmp_calendar_render_week_view( $anchor, $timezone, $category ) {
	$start_of_week = (int) get_option( 'start_of_week', 0 );
	$week_start    = npmp_calendar_week_start( $anchor, $start_of_week );
	$week_end      = $week_start->modify( '+6 days' );
	$events_by_day = npmp_calendar_query_events_by_day( $week_start, $week_end, $category, $timezone );

	$today_key = wp_date( 'Y-m-d', time(), $timezone );
	$labels    = npmp_calendar_weekday_labels();

	$html = '<table class="npmp-calendar-table"><thead><tr>';
	for ( $i = 0; $i < 7; $i++ ) {
		$html .= '<th scope="col">' . esc_html( $labels[ ( $start_of_week + $i ) % 7 ] ) . '</th>';
	}
	$html .= '</tr></thead><tbody><tr>';

	for ( $i = 0; $i < 7; $i++ ) {
		$day     = $week_start->modify( '+' . $i . ' days' );
		$day_key = $day->format( 'Y-m-d' );
		$classes = array( 'npmp-calendar-day' );
		if ( ! empty( $events_by_day[ $day_key ] ) ) {
			$classes[] = 'npmp-calendar-day--has-events';
		}
		if ( $day_key === $today_key ) {
			$classes[] = 'npmp-calendar-day--today';
		}

		$html .= '<td class="' . esc_attr( implode( ' ', $classes ) ) . '">';
		$html .= '<div class="npmp-calendar-day-number">';
		$html .= '<span class="npmp-calendar-weekday-name">' . esc_html( wp_date( 'D', $day->getTimestamp(), $timezone ) ) . '</span>';
		$html .= '<span class="npmp-calendar-weekday-date">' . esc_html( wp_date( 'j', $day->getTimestamp(), $timezone ) ) . '</span>';
		$html .= '</div>';
		$html .= npmp_calendar_day_events_html( $events_by_day[ $day_key ] ?? array(), $day_key, $timezone );
		$html .= '</td>';
	}

	$html .= '</tr></tbody></table>';

	return $html;
}

/**
 * Render the upcoming-events list view, honoring the admin display options.
 *
 * @param string $category Optional category filter.
 * @return string
 */
function npmp_calendar_render_list_view( $category ) {
	$limit = (int) get_option( 'npmp_calendar_list_limit', 10 );
	if ( $limit < 1 ) {
		$limit = 10;
	}
	$past = get_option( 'npmp_calendar_list_show_past', 0 ) ? 'true' : 'false';

	return npmp_events_shortcode(
		array(
			'category' => $category,
			'limit'    => $limit,
			'past'     => $past,
		)
	);
}

/**
 * Shortcode handler for displaying events.
 *
 * @param array $atts Shortcode attributes.
 * @return string
 */
function npmp_events_shortcode( $atts ) {
	$atts = shortcode_atts(
		array(
			'category' => '',
			'limit'    => -1,
			'past'     => 'false',
		),
		$atts,
		'npmp_events'
	);

	$query_args = array(
		'post_type'      => 'npmp_event',
		'posts_per_page' => intval( $atts['limit'] ),
		'orderby'        => 'meta_value',
		// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Event listings are sorted by a meta key.
		'meta_key'       => '_npmp_event_start',
		'order'          => 'ASC',
		'post_status'    => 'publish',
	);

	$now = current_time( 'mysql' );
	if ( 'true' !== strtolower( $atts['past'] ) ) {
		// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Upcoming event filtering requires meta comparisons.
		$query_args['meta_query'] = array(
			array(
				'key'     => '_npmp_event_start',
				'value'   => $now,
				'compare' => '>=',
				'type'    => 'DATETIME',
			),
		);
	}

	if ( ! empty( $atts['category'] ) ) {
		// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Event categories rely on taxonomy queries.
		$query_args['tax_query'] = array(
			array(
				'taxonomy' => 'npmp_event_category',
				'field'    => 'slug',
				'terms'    => array_map( 'sanitize_title', explode( ',', $atts['category'] ) ),
			),
		);
	}

	$events = new WP_Query( $query_args );

	if ( ! $events->have_posts() ) {
		return '<div class="npmp-events-list npmp-events-empty">' . esc_html__( 'No events found.', 'nonprofit-manager' ) . '</div>';
	}

	ob_start();

	echo '<div class="npmp-events-list">';

	while ( $events->have_posts() ) {
		$events->the_post();
		$details    = npmp_get_event_details( get_the_ID() );
		$date_label = npmp_format_event_datetime( $details );

		echo '<article class="npmp-event">';
		echo '<h3 class="npmp-event-title"><a href="' . esc_url( get_permalink() ) . '">' . esc_html( get_the_title() ) . '</a></h3>';

		if ( $date_label ) {
			echo '<p class="npmp-event-date">' . wp_kses_post( $date_label ) . '</p>';
		}

		$location = $details['location'] ?? '';
		if ( $location ) {
			echo '<p class="npmp-event-location">' . esc_html( $location ) . '</p>';
		}

		echo '<div class="npmp-event-excerpt">' . wp_kses_post( wpautop( get_the_excerpt() ) ) . '</div>';
		echo '</article>';
	}

	wp_reset_postdata();

	echo '</div>';

	return ob_get_clean();
}
add_shortcode( 'npmp_events', 'npmp_events_shortcode' );
add_shortcode( 'npmp_calendar', 'npmp_calendar_shortcode' );

/**
 * Enqueue the front-end calendar stylesheet, but only on pages that actually
 * show a calendar or event list, so it never loads site-wide.
 *
 * @return void
 */
function npmp_calendar_enqueue_styles() {
	if ( is_admin() ) {
		return;
	}

	$load = is_singular( 'npmp_event' ) || is_post_type_archive( 'npmp_event' );

	if ( ! $load && is_singular() ) {
		$post = get_post();
		if ( $post instanceof WP_Post ) {
			if (
				has_shortcode( $post->post_content, 'npmp_calendar' )
				|| has_shortcode( $post->post_content, 'npmp_events' )
				|| has_block( 'nonprofit-manager/calendar', $post )
				|| has_block( 'nonprofit-manager/events', $post )
			) {
				$load = true;
			} elseif ( (int) get_option( 'npmp_calendar_page_id', 0 ) === $post->ID ) {
				$load = true;
			}
		}
	}

	if ( ! $load ) {
		return;
	}

	$rel         = 'assets/css/npmp-calendar.css';
	$plugin_file = dirname( __DIR__ ) . '/nonprofit-manager.php';
	wp_enqueue_style(
		'npmp-calendar',
		plugins_url( $rel, $plugin_file ),
		array(),
		function_exists( 'npmp_get_asset_version' ) ? npmp_get_asset_version( $rel ) : null
	);
}
add_action( 'wp_enqueue_scripts', 'npmp_calendar_enqueue_styles' );

/**
 * Append event details on single event pages.
 *
 * @param string $content Post content.
 * @return string
 */
function npmp_append_event_details_to_content( $content ) {
	if ( ! is_singular( 'npmp_event' ) || ! in_the_loop() || ! is_main_query() ) {
		return $content;
	}

	$details = npmp_get_event_details( get_the_ID() );

	$date_label = npmp_format_event_datetime( $details );
	$location   = $details['location'] ?? '';
	$url        = $details['url'] ?? '';

	$meta = '<section class="npmp-event-details">';
	$meta .= '<h2>' . esc_html__( 'Event Details', 'nonprofit-manager' ) . '</h2><ul>';

	if ( $date_label ) {
		$meta .= '<li><strong>' . esc_html__( 'When:', 'nonprofit-manager' ) . '</strong> ' . wp_kses_post( $date_label ) . '</li>';
	}

	if ( $location ) {
		$meta .= '<li><strong>' . esc_html__( 'Where:', 'nonprofit-manager' ) . '</strong> ' . esc_html( $location ) . '</li>';
	}

	if ( $url ) {
		$meta .= '<li><strong>' . esc_html__( 'More information:', 'nonprofit-manager' ) . '</strong> <a href="' . esc_url( $url ) . '" target="_blank" rel="noopener">' . esc_html( $url ) . '</a></li>';
	}

	$meta .= '</ul></section>';

	return $content . $meta;
}
add_filter( 'the_content', 'npmp_append_event_details_to_content' );

/**
 * Output an iCal feed when requested via ?npmp-ical=1.
 *
 * @return void
 */
function npmp_maybe_render_ical_feed() {
	// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public iCal feed intentionally responds to a query parameter.
	if ( empty( $_GET['npmp-ical'] ) || '1' !== sanitize_text_field( wp_unslash( $_GET['npmp-ical'] ) ) ) {
		return;
	}

	$args = array(
		'post_type'      => 'npmp_event',
		'post_status'    => 'publish',
		'posts_per_page' => -1,
		'orderby'        => 'meta_value',
		// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- iCal feed ordering relies on event start meta.
		'meta_key'       => '_npmp_event_start',
		'order'          => 'ASC',
	);

	// This endpoint is public and rebuilt the full feed from an unbounded
	// query on every hit. Calendar apps poll iCal URLs on a schedule, so
	// serve a short-lived cached copy instead.
	$cached = get_transient( 'npmp_ical_feed' );
	if ( false !== $cached ) {
		header( 'Content-Type: text/calendar; charset=utf-8' );
		header( 'Content-Disposition: attachment; filename=events.ics' );
		echo $cached; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-built iCal payload assembled from escaped fields below.
		exit;
	}

	$events = get_posts( $args );

	header( 'Content-Type: text/calendar; charset=utf-8' );
	header( 'Content-Disposition: attachment; filename=events.ics' );

	$output = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Nonprofit Manager//EN\r\n";

	$host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
	$host = preg_replace( '/[^A-Za-z0-9\.\-]/', '', $host );

	foreach ( $events as $event ) {
		$details = npmp_get_event_details( $event->ID );
		$start   = ! empty( $details['start'] ) ? npmp_event_datetime_to_timestamp( $details['start'] ) : false;
		$end     = ! empty( $details['end'] ) ? npmp_event_datetime_to_timestamp( $details['end'] ) : $start;

		if ( ! $start ) {
			continue;
		}

		$output .= "BEGIN:VEVENT\r\n";
		$output .= 'UID:' . absint( $event->ID ) . '@' . $host . "\r\n";
		$output .= 'DTSTAMP:' . gmdate( 'Ymd\THis\Z', $start ) . "\r\n";
		$output .= 'DTSTART:' . gmdate( 'Ymd\THis\Z', $start ) . "\r\n";
		if ( $end ) {
			$output .= 'DTEND:' . gmdate( 'Ymd\THis\Z', $end ) . "\r\n";
		}
		$output .= 'SUMMARY:' . npmp_ics_escape_text( get_the_title( $event ) ) . "\r\n";
		if ( ! empty( $details['location'] ) ) {
			$output .= 'LOCATION:' . npmp_ics_escape_text( $details['location'] ) . "\r\n";
		}
		$output .= "END:VEVENT\r\n";
	}

	$output .= "END:VCALENDAR\r\n";

	// Cache for 15 minutes; saving any event clears it immediately.
	set_transient( 'npmp_ical_feed', $output, 15 * MINUTE_IN_SECONDS );
echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
	exit;
}
add_action( 'init', 'npmp_maybe_render_ical_feed' );

/**
 * Render the Calendar settings page.
 *
 * @return void
 */
function npmp_render_event_settings_page() {

	if ( ! current_user_can( 'manage_options' ) ) {
		wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'nonprofit-manager' ) );
	}

	$notices             = array();
	$current_calendar_id = absint( get_option( 'npmp_calendar_page_id', 0 ) );

	if (
		isset( $_POST['npmp_save_calendar_settings'] ) &&
		isset( $_POST['npmp_event_settings_nonce'] ) &&
		wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['npmp_event_settings_nonce'] ) ), 'npmp_save_calendar_settings' )
	) {
		$selected = isset( $_POST['npmp_calendar_page_id'] ) ? sanitize_text_field( wp_unslash( $_POST['npmp_calendar_page_id'] ) ) : '0';

		// Check if user selected "create_new"
		if ( 'create_new' === $selected ) {
			// Create a new page
			$page_id = wp_insert_post(
				array(
					'post_title'   => __( 'Events Calendar', 'nonprofit-manager' ),
					'post_status'  => 'publish',
					'post_type'    => 'page',
					'post_content' => "[npmp_calendar]\n\n[npmp_events limit=\"10\"]",
				)
			);

			if ( is_wp_error( $page_id ) ) {
				$notices[] = array(
					'type'    => 'error',
					'message' => $page_id->get_error_message(),
				);
			} else {
				update_option( 'npmp_calendar_page_id', absint( $page_id ) );
				$current_calendar_id = absint( $page_id );
				$notices[]           = array(
					'type'    => 'success',
					'message' => __( 'A new calendar page was created and set as the default.', 'nonprofit-manager' ),
				);
			}
		} else {
			update_option( 'npmp_calendar_page_id', absint( $selected ) );
			$current_calendar_id = absint( $selected );
			$notices[]           = array(
				'type'    => 'success',
				'message' => __( 'Calendar settings saved.', 'nonprofit-manager' ),
			);
		}

		// Display options (saved on every settings submit; nonce verified above).
		$view_choice = isset( $_POST['npmp_calendar_default_view'] ) ? sanitize_key( wp_unslash( $_POST['npmp_calendar_default_view'] ) ) : 'month';
		update_option( 'npmp_calendar_default_view', in_array( $view_choice, array( 'month', 'week', 'list' ), true ) ? $view_choice : 'month' );
		update_option( 'npmp_calendar_show_times', empty( $_POST['npmp_calendar_show_times'] ) ? 0 : 1 );
		$accent_choice = isset( $_POST['npmp_calendar_accent'] ) ? sanitize_hex_color( wp_unslash( $_POST['npmp_calendar_accent'] ) ) : '';
		update_option( 'npmp_calendar_accent', $accent_choice ? $accent_choice : '#2271b1' );
		update_option( 'npmp_calendar_list_limit', max( 1, absint( wp_unslash( $_POST['npmp_calendar_list_limit'] ?? 10 ) ) ) );
		update_option( 'npmp_calendar_list_show_past', empty( $_POST['npmp_calendar_list_show_past'] ) ? 0 : 1 );
	}

	if (
		isset( $_POST['npmp_create_calendar_page'] ) &&
		isset( $_POST['npmp_event_create_nonce'] ) &&
		wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['npmp_event_create_nonce'] ) ), 'npmp_create_calendar_page' )
	) {
		$title = sanitize_text_field( wp_unslash( $_POST['npmp_calendar_page_title'] ?? __( 'Events Calendar', 'nonprofit-manager' ) ) );
		if ( '' === $title ) {
			$title = __( 'Events Calendar', 'nonprofit-manager' );
		}

		$page_id = wp_insert_post(
			array(
				'post_title'   => $title,
				'post_status'  => 'publish',
				'post_type'    => 'page',
				'post_content' => "[npmp_calendar]\n\n[npmp_events limit=\"10\"]",
			)
		);

		if ( is_wp_error( $page_id ) ) {
			$notices[] = array(
				'type'    => 'error',
				'message' => $page_id->get_error_message(),
			);
		} else {
			update_option( 'npmp_calendar_page_id', absint( $page_id ) );
			$current_calendar_id = absint( $page_id );
			$notices[]           = array(
				'type'    => 'success',
				'message' => __( 'A calendar page was created and set as the default destination.', 'nonprofit-manager' ),
			);
		}
	}

	echo '<div class="wrap">';
	echo '<h1>' . esc_html__( 'Calendar Settings', 'nonprofit-manager' ) . '</h1>';

	foreach ( $notices as $notice ) {
		$class = 'notice';
		if ( 'success' === $notice['type'] ) {
			$class .= ' notice-success';
		} elseif ( 'error' === $notice['type'] ) {
			$class .= ' notice-error';
		} else {
			$class .= ' notice-info';
		}
		echo '<div class="' . esc_attr( $class ) . '"><p>' . esc_html( $notice['message'] ) . '</p></div>';
	}

	// Calendar Settings Card
	echo '<div class="card" style="max-width:720px; margin-bottom: 20px;">';
	echo '<h2 class="title">' . esc_html__( 'Calendar Settings', 'nonprofit-manager' ) . '</h2>';

	echo '<form method="post">';
	wp_nonce_field( 'npmp_save_calendar_settings', 'npmp_event_settings_nonce' );

	echo '<table class="form-table"><tbody>';
	echo '<tr>';
	echo '<th scope="row"><label for="npmp_calendar_page_id">' . esc_html__( 'Default Calendar Page', 'nonprofit-manager' ) . '</label></th>';
	echo '<td>';
	echo '<p class="description" style="margin-top: 0;">' . esc_html__( 'Select the WordPress page that should display your public events calendar.', 'nonprofit-manager' ) . '</p>';

	echo '<select name="npmp_calendar_page_id" id="npmp_calendar_page_id">';
	echo '<option value="0">' . esc_html__( '— Select a page —', 'nonprofit-manager' ) . '</option>';
	echo '<option value="create_new" style="font-weight: bold;">' . esc_html__( '+ New Page', 'nonprofit-manager' ) . '</option>';

	$pages = get_pages();
	foreach ( $pages as $page ) {
		$selected = ( absint( $current_calendar_id ) === $page->ID ) ? ' selected="selected"' : '';
		echo '<option value="' . esc_attr( $page->ID ) . '"' . $selected . '>' . esc_html( $page->post_title ) . '</option>';
	}
	echo '</select>';

	if ( $current_calendar_id ) {
		$calendar_url = get_permalink( $current_calendar_id );
		if ( $calendar_url ) {
			echo '<p class="description">' . sprintf(
				/* translators: %s: Calendar page URL */
				wp_kses_post( __( 'View your calendar: <a href="%s" target="_blank">%s</a>', 'nonprofit-manager' ) ),
				esc_url( $calendar_url ),
				esc_url( $calendar_url )
			) . '</p>';
		}
	}

	echo '</td>';
	echo '</tr>';

	// --- Display options ---
	$opt_view    = get_option( 'npmp_calendar_default_view', 'month' );
	$opt_times   = (int) get_option( 'npmp_calendar_show_times', 1 );
	$opt_accent  = sanitize_hex_color( (string) get_option( 'npmp_calendar_accent', '#2271b1' ) );
	$opt_accent  = $opt_accent ? $opt_accent : '#2271b1';
	$opt_limit   = (int) get_option( 'npmp_calendar_list_limit', 10 );
	$opt_past    = (int) get_option( 'npmp_calendar_list_show_past', 0 );
	$view_labels = array(
		'month' => __( 'Month grid', 'nonprofit-manager' ),
		'week'  => __( 'Week', 'nonprofit-manager' ),
		'list'  => __( 'List of upcoming events', 'nonprofit-manager' ),
	);

	echo '<tr>';
	echo '<th scope="row"><label for="npmp_calendar_default_view">' . esc_html__( 'Default view', 'nonprofit-manager' ) . '</label></th>';
	echo '<td><select name="npmp_calendar_default_view" id="npmp_calendar_default_view">';
	foreach ( $view_labels as $key => $label ) {
		echo '<option value="' . esc_attr( $key ) . '"' . selected( $opt_view, $key, false ) . '>' . esc_html( $label ) . '</option>';
	}
	echo '</select>';
	echo '<p class="description">' . esc_html__( 'How the calendar opens. Visitors can still switch with the Month / Week / List buttons.', 'nonprofit-manager' ) . '</p></td>';
	echo '</tr>';

	echo '<tr>';
	echo '<th scope="row">' . esc_html__( 'Event times', 'nonprofit-manager' ) . '</th>';
	echo '<td><label><input type="checkbox" name="npmp_calendar_show_times" value="1"' . checked( $opt_times, 1, false ) . '> ' . esc_html__( 'Show the start time next to each event', 'nonprofit-manager' ) . '</label></td>';
	echo '</tr>';

	echo '<tr>';
	echo '<th scope="row"><label for="npmp_calendar_accent">' . esc_html__( 'Highlight color', 'nonprofit-manager' ) . '</label></th>';
	echo '<td><input type="color" name="npmp_calendar_accent" id="npmp_calendar_accent" value="' . esc_attr( $opt_accent ) . '">';
	echo '<p class="description">' . esc_html__( 'Used for today, event chips, and buttons.', 'nonprofit-manager' ) . '</p></td>';
	echo '</tr>';

	echo '<tr>';
	echo '<th scope="row"><label for="npmp_calendar_list_limit">' . esc_html__( 'Events in list view', 'nonprofit-manager' ) . '</label></th>';
	echo '<td><input type="number" min="1" max="100" name="npmp_calendar_list_limit" id="npmp_calendar_list_limit" value="' . esc_attr( $opt_limit ) . '" class="small-text"></td>';
	echo '</tr>';

	echo '<tr>';
	echo '<th scope="row">' . esc_html__( 'Past events', 'nonprofit-manager' ) . '</th>';
	echo '<td><label><input type="checkbox" name="npmp_calendar_list_show_past" value="1"' . checked( $opt_past, 1, false ) . '> ' . esc_html__( 'Include past events in the list view', 'nonprofit-manager' ) . '</label></td>';
	echo '</tr>';

	$wp_days       = array( __( 'Sunday' ), __( 'Monday' ), __( 'Tuesday' ), __( 'Wednesday' ), __( 'Thursday' ), __( 'Friday' ), __( 'Saturday' ) ); // phpcs:ignore WordPress.WP.I18n.MissingArgDomain -- Reusing WordPress core weekday translations.
	$start_of_week = (int) get_option( 'start_of_week', 0 );
	echo '<tr>';
	echo '<th scope="row">' . esc_html__( 'Week starts on', 'nonprofit-manager' ) . '</th>';
	echo '<td><p style="margin-top:0;">' . sprintf(
		/* translators: %s: weekday name */
		esc_html__( 'The calendar follows your WordPress setting: %s.', 'nonprofit-manager' ),
		'<strong>' . esc_html( $wp_days[ $start_of_week ] ?? $wp_days[0] ) . '</strong>'
	) . '</p>';
	echo '<p class="description"><a href="' . esc_url( admin_url( 'options-general.php' ) ) . '">' . esc_html__( 'Change it under Settings → General → Week Starts On.', 'nonprofit-manager' ) . '</a></p></td>';
	echo '</tr>';

	echo '</tbody></table>';

	submit_button( esc_html__( 'Save Settings', 'nonprofit-manager' ), 'primary', 'npmp_save_calendar_settings' );
	echo '</form>';
	echo '</div>';

	// Embed Instructions Section
	echo '<div class="card" style="max-width:720px;">';
	echo '<h2 class="title">' . esc_html__( 'Embed Instructions', 'nonprofit-manager' ) . '</h2>';
	echo '<p>' . esc_html__( 'Use these shortcodes to display your calendar and events on any page or post:', 'nonprofit-manager' ) . '</p>';

	echo '<table class="widefat striped">';
	echo '<thead><tr><th>' . esc_html__( 'Shortcode', 'nonprofit-manager' ) . '</th><th>' . esc_html__( 'Description', 'nonprofit-manager' ) . '</th></tr></thead>';
	echo '<tbody>';

	echo '<tr>';
	echo '<td><code>[npmp_calendar]</code></td>';
	echo '<td>' . esc_html__( 'Displays the monthly calendar grid with navigation.', 'nonprofit-manager' ) . '</td>';
	echo '</tr>';

	echo '<tr>';
	echo '<td><code>[npmp_events limit="5"]</code></td>';
	echo '<td>' . esc_html__( 'Shows a list of upcoming events. Change the limit to display more or fewer events.', 'nonprofit-manager' ) . '</td>';
	echo '</tr>';

	echo '<tr>';
	echo '<td><code>[npmp_events past="true"]</code></td>';
	echo '<td>' . esc_html__( 'Lists past events for an archive page.', 'nonprofit-manager' ) . '</td>';
	echo '</tr>';

	echo '<tr>';
	echo '<td><code>[npmp_events category="fundraisers"]</code></td>';
	echo '<td>' . esc_html__( 'Filter events by category slug (replace "fundraisers" with your category).', 'nonprofit-manager' ) . '</td>';
	echo '</tr>';

	echo '</tbody></table>';
	echo '</div>';

	echo '</div>';
}

/**
 * Keep the Events menu highlighted when managing event content.
 *
 * @param string $parent_file Parent menu slug.
 * @return string
 */
function npmp_events_parent_menu( $parent_file ) {
	$screen = get_current_screen();

	if ( $screen && 'npmp_event' === $screen->post_type ) {
		return 'npmp-events';
	}

	if ( isset( $screen->id ) && 'events_page_npmp_event_settings' === $screen->id ) {
		return 'npmp-events';
	}

	return $parent_file;
}
add_filter( 'parent_file', 'npmp_events_parent_menu' );

/**
 * Match the correct submenu entry for Events pages.
 *
 * @param string $submenu_file Current submenu slug.
 * @return string
 */
function npmp_events_submenu_highlight( $submenu_file ) {
	global $plugin_page;
	$screen = get_current_screen();

	if ( $screen && 'npmp_event' === $screen->post_type ) {
		if ( 'post-new.php' === $screen->base ) {
			return 'post-new.php?post_type=npmp_event';
		}

		return 'edit.php?post_type=npmp_event';
	}

	if ( 'npmp_event_settings' === $plugin_page ) {
		return 'npmp_event_settings';
	}

	return $submenu_file;
}
add_filter( 'submenu_file', 'npmp_events_submenu_highlight' );

/**
 * Automatically embed the calendar on the configured page.
 *
 * @param string $content Original post content.
 * @return string
 */
function npmp_auto_inject_calendar_page( $content ) {
	if ( ! is_main_query() || ! in_the_loop() || ! is_page() ) {
		return $content;
	}

	$page_id = (int) get_option( 'npmp_calendar_page_id', 0 );
	if ( ! $page_id || get_the_ID() !== $page_id ) {
		return $content;
	}

	// By this priority $content has already run through do_shortcode, so the
	// literal "[npmp_calendar]" is gone and a strpos on $content would never
	// match — that double-rendered the calendar on pages that include the
	// shortcode. Check the raw stored content instead.
	$raw = (string) get_post_field( 'post_content', $page_id );
	if ( has_shortcode( $raw, 'npmp_calendar' ) || has_shortcode( $raw, 'npmp_events' ) ) {
		return $content;
	}

	return $content . "\n\n" . do_shortcode( '[npmp_calendar]' );
}
add_filter( 'the_content', 'npmp_auto_inject_calendar_page', 12 );

/**
 * Escape ICS text.
 *
 * @param string $text Raw text.
 * @return string
 */
function npmp_ics_escape_text( $text ) {
	$text = wp_strip_all_tags( (string) $text );
	$text = str_replace( array( "\r\n", "\r", "\n" ), '\n', $text );

	return strtr(
		$text,
		array(
			'\\' => '\\\\',
			','  => '\,',
			';'  => '\;',
		)
	);
}

/**
 * Drop the cached iCal feed whenever an event changes so subscribers never
 * see stale data for longer than a page refresh.
 *
 * @param int $post_id Post ID.
 * @return void
 */
function npmp_calendar_flush_ical_cache( $post_id ) {
	if ( 'npmp_event' === get_post_type( $post_id ) ) {
		delete_transient( 'npmp_ical_feed' );
	}
}
add_action( 'save_post_npmp_event', 'npmp_calendar_flush_ical_cache' );
add_action( 'deleted_post', 'npmp_calendar_flush_ical_cache' );
add_action( 'trashed_post', 'npmp_calendar_flush_ical_cache' );
