<?php
/**
 * Core functions.
 *
 * @package    EventKoi
 * @subpackage EventKoi\Core
 */

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Decode a calendar/term name for display.
 *
 * WordPress core's wp_insert_term() runs term names through
 * wp_kses_normalize_entities(), which converts `&` to `&amp;` before saving.
 * The DB value therefore needs to be decoded back to its human-readable form
 * before being returned to React UIs, REST consumers, and HTML templates that
 * apply their own escaping.
 *
 * @param string|null $name Raw term name as stored in wp_terms.name.
 * @return string Decoded name safe to display or pass through esc_html().
 */
function eventkoi_decode_term_name( $name ) {
	return html_entity_decode( (string) $name, ENT_QUOTES, 'UTF-8' );
}

/**
 * Resolve a usable event_cal term_id with validation and fallbacks.
 *
 * Tries (in order): caller-preferred ids, the eventkoi_default_event_cal
 * option, then the first existing term. Returns 0 only if no calendars
 * exist on the site at all.
 *
 * @param int|array|string $preferred Caller's preferred id or list (e.g. block attr).
 * @return int Valid term_id, or 0 if no calendars exist.
 */
function eventkoi_resolve_calendar_id( $preferred = 0 ) {
	$candidates = array();

	if ( is_array( $preferred ) ) {
		foreach ( $preferred as $p ) {
			$candidates[] = (int) $p;
		}
	} elseif ( is_string( $preferred ) && false !== strpos( $preferred, ',' ) ) {
		foreach ( explode( ',', $preferred ) as $p ) {
			$candidates[] = (int) trim( $p );
		}
	} else {
		$candidates[] = (int) $preferred;
	}

	$default_option = (int) get_option( 'eventkoi_default_event_cal', 0 );
	$candidates[]   = $default_option;

	foreach ( $candidates as $id ) {
		if ( $id > 0 && term_exists( $id, 'event_cal' ) ) {
			return $id;
		}
	}

	$first = get_terms(
		array(
			'taxonomy'   => 'event_cal',
			'hide_empty' => false,
			'number'     => 1,
			'fields'     => 'ids',
			'orderby'    => 'term_id',
			'order'      => 'ASC',
		)
	);

	if ( empty( $first ) || is_wp_error( $first ) ) {
		return 0;
	}

	$fallback_id = (int) $first[0];

	// Self-heal: if the stored default option is invalid, repoint it to a real term.
	if ( $default_option !== $fallback_id ) {
		update_option( 'eventkoi_default_event_cal', $fallback_id );
	}

	return $fallback_id;
}

/**
 * Retrieve a single enriched event structure.
 *
 * Returns the same normalized event object as Calendar::get_events(),
 * including recurring data, timezone, and formatted meta.
 *
 * @param int $event_id Event post ID.
 * @return array|\WP_Error Single event array or error.
 */
function eventkoi_get_event( $event_id ) {
	$event_id = absint( $event_id );

	if ( ! $event_id ) {
		return new \WP_Error(
			'eventkoi_invalid_id',
			__( 'Invalid event ID.', 'eventkoi-lite' ),
			array( 'status' => 400 )
		);
	}

	$post_status = array( 'publish' );
	if ( current_user_can( 'edit_post', $event_id ) ) {
		$post_status = array( 'publish', 'draft', 'pending', 'future', 'private' );
	}
	$post_status = apply_filters( 'eventkoi_get_event_post_status', $post_status, $event_id );

	$event_data = \EventKoi\Core\Calendar::get_events(
		array(),
		false,
		array(
			'include'  => array( $event_id ),
			'per_page' => 1,
			'post_status' => $post_status,
		)
	);

	// Support both new and legacy return formats.
	$events = ( isset( $event_data['items'] ) && is_array( $event_data['items'] ) )
		? $event_data['items']
		: (array) $event_data;

	if ( empty( $events ) || ! isset( $events[0] ) ) {
		$post = get_post( $event_id );
		if ( empty( $post ) ) {
			return new \WP_Error(
				'eventkoi_not_found',
				__( 'Event not found.', 'eventkoi-lite' ),
				array( 'status' => 404 )
			);
		}

		$allowed_statuses = (array) $post_status;
		if ( ! in_array( $post->post_status, $allowed_statuses, true ) ) {
			return new \WP_Error(
				'eventkoi_not_found',
				__( 'Event not found.', 'eventkoi-lite' ),
				array( 'status' => 404 )
			);
		}

		$event = new \EventKoi\Core\Event( $event_id );
		return $event::get_meta();
	}

	$event = $events[0];

	// Normalize single-location key for consumers that expect `location`.
	if (
		( ! isset( $event['location'] ) || empty( $event['location'] ) ) &&
		! empty( $event['locations'] ) &&
		is_array( $event['locations'] ) &&
		! empty( $event['locations'][0] ) &&
		is_array( $event['locations'][0] )
	) {
		$event['location'] = $event['locations'][0];
	}

	return $event;
}

use EKLIB\RRule\RRule;
use EventKoi\Core\Settings;

/**
 * Retrieve the current instance ID from pretty permalink or query param.
 *
 * Public endpoint: instance param is used on frontend URLs for recurring events.
 * Nonce verification is intentionally not applied here, since the parameter
 * must be publicly accessible. Input is strictly sanitized with absint().
 *
 * @return int Instance timestamp.
 */
function eventkoi_get_instance_id() {
	$instance_ts = absint( get_query_var( 'instance' ) );

	if ( 0 === $instance_ts ) {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public URL param, sanitized with absint()
		$instance_ts = isset( $_GET['instance'] ) ? absint( wp_unslash( $_GET['instance'] ) ) : 0;
	}

	if ( 0 === $instance_ts ) {
		$post = get_post();
		if ( $post instanceof \WP_Post && ! empty( $post->eventkoi_instance_ts ) ) {
			$instance_ts = absint( $post->eventkoi_instance_ts );
		}
	}

	return $instance_ts;
}

/**
 * Public helper to check if the current request is for a recurring instance.
 *
 * @return bool
 */
function eventkoi_is_recurring_instance() {
	return (bool) eventkoi_get_instance_id();
}

/**
 * Get the plugin directory name (i.e. the base name of the main plugin file without the `.php` extension).
 *
 * Useful for referencing the plugin folder or textdomain dynamically.
 *
 * @since 1.0.0
 *
 * @return string Plugin name/folder.
 */
function eventkoi_plugin_name() {
	return str_replace( '.php', '', basename( EVENTKOI_PLUGIN_FILE ) );
}

/**
 * Get the URL for a template asset within the plugin.
 *
 * @since 1.0.0
 *
 * @param string $asset Relative path to the asset file.
 * @return string Full URL to the asset.
 */
function eventkoi_get_template_asset( $asset ) {
	$plugin_root = dirname( __FILE__, 3 );
	$base_url    = trailingslashit( plugins_url( '', $plugin_root . '/eventkoi.php' ) );
	$url         = $base_url . 'templates/assets/' . ltrim( $asset, '/' );

	/**
	 * Filter the template asset URL.
	 *
	 * @since 1.0.0
	 *
	 * @param string $url   Full URL to the asset.
	 * @param string $asset Relative path to the asset.
	 */
	return apply_filters( 'eventkoi_get_template_asset', $url, $asset );
}

/**
 * Get the rendered header block template content.
 *
 * This function loads the header template part using the block editor system,
 * typically used in block themes or full site editing (FSE).
 *
 * @since 1.0.0
 *
 * @return string Rendered header HTML.
 */
function eventkoi_get_header() {
	$content = '<!-- wp:template-part {"slug":"header","area":"header","tagName":"header"} /-->';

	/**
	 * Filter the block template content for the header.
	 *
	 * @since 1.0.0
	 *
	 * @param string $content Raw block template markup for the header.
	 */
	return do_blocks( apply_filters( 'eventkoi_get_header', $content ) );
}

/**
 * Get the rendered footer block template content.
 *
 * This function loads the footer template part using the block editor system,
 * typically used in block themes or full site editing (FSE).
 *
 * @since 1.0.0
 *
 * @return string Rendered footer HTML.
 */
function eventkoi_get_footer() {
	$content = '<!-- wp:template-part {"slug":"footer","area":"footer","tagName":"footer"} /-->';

	/**
	 * Filter the block template content for the footer.
	 *
	 * @since 1.0.0
	 *
	 * @param string $content Raw block template markup for the footer.
	 */
	return do_blocks( apply_filters( 'eventkoi_get_footer', $content ) );
}

/**
 * Get template content.
 */
function eventkoi_get_content() {

	$template = get_post( get_option( 'eventkoi_default_template_id' ) );

	$blocks = new \EventKoi\Core\Blocks();

	if ( ! wp_is_block_theme() ) {
		$page_id = (int) get_option( 'eventkoi_template_page_id' );
		if ( $page_id ) {
			$page = get_post( $page_id );
			if ( $page && ! empty( $page->post_content ) ) {
				return do_blocks(
					apply_filters( 'eventkoi_get_content', $page->post_content )
				);
			}
		}
	}

	if ( ! empty( $template ) && ! empty( $template->post_content ) ) {
		$event_template = $template->post_content;
	} else {
		$event_template = $blocks::get_default_template();
	}

	return do_blocks( apply_filters( 'eventkoi_get_content', $event_template ) );
}

/**
 * Get calendar template content.
 *
 * @param int    $calendar_id Calendar ID.
 * @param string $display Display mode ('display' or 'list').
 * @param array  $args Additional options for the calendar.
 *
 * @return string Rendered calendar content.
 */
function eventkoi_get_calendar_content( $calendar_id = 0, $display = '', $args = array() ) {
	$term    = get_queried_object();
	$term_id = ( ! empty( $term->term_id ) ) ? $term->term_id : (int) $calendar_id;

	if ( $calendar_id > 0 ) {
		$term_id = (int) $calendar_id;
	}

	if ( empty( $term_id ) ) {
		$term_id = eventkoi_resolve_calendar_id( 0 );
	}

	$calendar = new \EventKoi\Core\Calendar( $term_id );

	if ( $calendar::is_invalid() ) {
		return '<div class="wp-block-group eventkoi-front"><p>' . esc_html__( 'Invalid calendar. Please check the calendar ID.', 'eventkoi-lite' ) . '</p></div>';
	}

	// Use calendar settings if no display mode is specified.
	if ( empty( $display ) ) {
		$display = $calendar::get_display();
	}

	// Extract arguments safely.
	$calendars        = ! empty( $args['calendars'] ) ? implode( ',', array_map( 'intval', (array) $args['calendars'] ) ) : '';
	$startday         = isset( $args['startday'] ) ? sanitize_text_field( $args['startday'] ) : '';
	$timeframe        = isset( $args['timeframe'] ) ? sanitize_text_field( $args['timeframe'] ) : '';
	$color            = isset( $args['color'] ) ? sanitize_hex_color( $args['color'] ) : '';
	$show_image       = isset( $args['show_image'] ) ? esc_attr( $args['show_image'] ) : 'yes';
	$show_location    = isset( $args['show_location'] ) ? esc_attr( $args['show_location'] ) : 'yes';
	$show_description = isset( $args['show_description'] ) ? esc_attr( $args['show_description'] ) : 'yes';
	$border_style     = isset( $args['border_style'] ) ? esc_attr( $args['border_style'] ) : 'dotted';
	$border_size      = isset( $args['border_size'] ) ? esc_attr( $args['border_size'] ) : '2px';
	$default_month    = isset( $args['default_month'] ) ? sanitize_text_field( $args['default_month'] ) : '';
	$default_year     = isset( $args['default_year'] ) ? sanitize_text_field( $args['default_year'] ) : '';
	$orderby          = isset( $args['orderby'] ) ? sanitize_key( $args['orderby'] ) : 'date_modified';
	$order            = isset( $args['order'] ) ? strtolower( sanitize_key( $args['order'] ) ) : 'desc';
	$per_page         = isset( $args['per_page'] ) ? absint( $args['per_page'] ) : 10;
	$max_results      = isset( $args['max_results'] ) ? absint( $args['max_results'] ) : 0;
	$date_start       = isset( $args['date_start'] ) ? sanitize_text_field( $args['date_start'] ) : '';
	$date_end         = isset( $args['date_end'] ) ? sanitize_text_field( $args['date_end'] ) : '';
	$expand_instances = isset( $args['expand_instances'] ) ? (bool) $args['expand_instances'] : false;
	$container_id     = 'eventkoi-calendar-' . uniqid();
	$content_size     = ! empty( $args['layout']['contentSize'] ) ? sanitize_text_field( $args['layout']['contentSize'] ) : '';
	$wide_size        = ! empty( $args['layout']['wideSize'] ) ? sanitize_text_field( $args['layout']['wideSize'] ) : '';

	if ( empty( $content_size ) && is_tax( 'event_cal' ) ) {
		$content_size = '1100px';
	}

	if ( 'list' === $display ) {
		$allowed_orderby = array( 'modified', 'date_modified', 'date', 'publish_date', 'title', 'start_date', 'event_start', 'upcoming', 'past', 'past_events' );
		if ( ! in_array( $orderby, $allowed_orderby, true ) ) {
			$orderby = 'date_modified';
		}
		if ( 'modified' === $orderby ) {
			$orderby = 'date_modified';
		}
		if ( 'date' === $orderby ) {
			$orderby = 'publish_date';
		}
		if ( 'start_date' === $orderby ) {
			$orderby = 'event_start';
		}
		if ( 'past_events' === $orderby ) {
			$orderby = 'past';
		}
		if ( ! in_array( $order, array( 'asc', 'desc' ), true ) ) {
			$order = 'desc';
		}
		if ( 'upcoming' === $orderby && empty( $args['order'] ) ) {
			$order = 'asc';
		}
		if ( 'past' === $orderby ) {
			$order = 'desc';
		}
		$per_page    = $per_page > 0 ? min( $per_page, 100 ) : 10;
		$max_results = $max_results > 0 ? min( $max_results, 1000 ) : 0;

		if ( $expand_instances ) {
			if ( empty( $calendars ) ) {
				$calendars = (string) absint( $calendar::get_id() );
			}
			$calendars .= '|ek_expand_instances=1';
		}
	} else {
		$orderby = '';
		$order = '';
		$per_page = 0;
		$max_results = 0;
		$date_start = '';
		$date_end = '';
		$expand_instances = false;
	}

	$style_parts = array();
	if ( ! empty( $content_size ) ) {
		$style_parts[] = 'max-width:' . esc_attr( $content_size );
		$style_parts[] = 'margin-left:auto';
		$style_parts[] = 'margin-right:auto';
	}
	$style = ! empty( $style_parts ) ? implode( ';', $style_parts ) . ';' : '';

	// Subscribable iCal feed for exactly the calendar set this instance shows
	// (single, multi, or all), so visitors can add it to Apple/Google/Outlook
	// and have events appear and update automatically.
	$feed_calendar_ids = ! empty( $args['calendars'] ) ? array_map( 'absint', (array) $args['calendars'] ) : array( absint( $calendar::get_id() ) );
	$feed_url          = function_exists( 'eventkoi_get_calendar_feed_url' ) ? eventkoi_get_calendar_feed_url( $feed_calendar_ids ) : '';
	$feed_webcal_url   = function_exists( 'eventkoi_get_calendar_feed_url' ) ? eventkoi_get_calendar_feed_url( $feed_calendar_ids, 'webcal' ) : '';

	$calendar_template = sprintf(
		'<!-- wp:group {"className":"eventkoi-front"} -->
		<div class="wp-block-group eventkoi-front" style="%13$s">
			<div id="%12$s"
				data-calendar-id="%1$d"
				data-calendars="%2$s"
				data-display="%3$s"
				data-startday="%4$s"
				data-timeframe="%5$s"
				data-color="%6$s"
				data-show-image="%7$s"
				data-show-location="%8$s"
				data-show-description="%9$s"
				data-border-style="%10$s"
				data-border-size="%11$s"
				data-context="%14$s"
				data-default-month="%15$s"
				data-default-year="%16$s"
				data-orderby="%17$s"
				data-order="%18$s"
				data-per-page="%19$s"
				data-max-results="%20$s"
				data-date-start="%21$s"
				data-date-end="%22$s"
				data-expand-instances="%23$s"
				data-feed-url="%24$s"
				data-feed-webcal="%25$s">
			</div>
		</div>
	<!-- /wp:group -->',
		absint( $calendar::get_id() ),
		esc_attr( $calendars ),
		esc_attr( $display ),
		esc_attr( $startday ),
		esc_attr( $timeframe ),
		esc_attr( $color ),
		esc_attr( $show_image ),
		esc_attr( $show_location ),
		esc_attr( $show_description ),
		esc_attr( $border_style ),
		esc_attr( $border_size ),
		esc_attr( $container_id ),
		esc_attr( $style ),
		esc_attr( $args['context'] ?? 'frontend' ), // %14$s
		esc_attr( $args['default_month'] ?? '' ),   // %15$s
		esc_attr( $args['default_year'] ?? '' ),    // %16$s
		esc_attr( $orderby ),                       // %17$s
		esc_attr( $order ),                         // %18$s
		esc_attr( $per_page ),                      // %19$s
		esc_attr( $max_results ),                   // %20$s
		esc_attr( $date_start ),                    // %21$s
		esc_attr( $date_end ),                      // %22$s
		$expand_instances ? '1' : '0',              // %23$s
		esc_url( $feed_url ),                        // %24$s
		esc_url( $feed_webcal_url, array( 'https', 'http', 'webcal' ) ) // %25$s
	);

	$output = do_blocks( apply_filters( 'eventkoi_get_calendar_content', $calendar_template ) );

	// Only wrap when viewing a calendar term page.
	if ( is_tax( 'event_cal' ) ) {
		$title = sprintf(
			'<!-- wp:group {"className":"eventkoi-title"} -->
		<div class="wp-block-group eventkoi-title" style="%2$s">
			<!-- wp:post-title {"level":1} -->
			<h1 class="wp-block-post-title">%1$s</h1>
			<!-- /wp:post-title -->
		</div>
		<!-- /wp:group -->',
			esc_html( eventkoi_decode_term_name( $term->name ) ),
			esc_attr( $style )
		);

		$output = '<main class="eventkoi-main wp-block-group has-global-padding">'
			. $title
			. $output
			. '</main>';
	}

	return $output;
}

/**
 * Get the permalink structure for EventKoi.
 *
 * This merges stored permalink settings with defaults,
 * sanitizes slugs, and ensures rewrite slugs are generated.
 *
 * @since 1.0.0
 *
 * @return array {
 *     The permalink structure.
 *
 *     @type string $event_base             Raw slug for events.
 *     @type string $category_base          Raw slug for categories/calendars.
 *     @type bool   $use_verbose_page_rules Whether verbose rules are used.
 *     @type string $event_rewrite_slug     Sanitized and formatted event slug.
 *     @type string $category_rewrite_slug  Sanitized and formatted category slug.
 * }
 */
function eventkoi_get_permalink_structure() {
	$saved_permalinks = (array) get_option( 'eventkoi_permalinks', array() );
	$defaults         = array(
		'event_base'             => _x( 'event', 'slug', 'eventkoi-lite' ),
		'category_base'          => _x( 'calendar', 'slug', 'eventkoi-lite' ),
		'use_verbose_page_rules' => false,
	);
	$permalinks       = wp_parse_args( $saved_permalinks, $defaults );

	// Sanitize per path segment so nested bases like "agenda/calendar"
	// keep their slashes instead of collapsing to "agendacalendar".
	$sanitize_base_path = static function ( $base ) {
		return implode( '/', array_filter( array_map( 'sanitize_title_with_dashes', explode( '/', (string) $base ) ) ) );
	};

	foreach ( array( 'event_base', 'category_base' ) as $key ) {
		$base = untrailingslashit( $sanitize_base_path( $permalinks[ $key ] ?? '' ) );
		if ( '' === $base ) {
			$base = untrailingslashit( sanitize_title_with_dashes( (string) $defaults[ $key ] ) );
		}
		$permalinks[ $key ] = $base;
	}

	$permalinks['use_verbose_page_rules'] = rest_sanitize_boolean( $permalinks['use_verbose_page_rules'] ?? false );

	$stored = array(
		'event_base'             => $permalinks['event_base'],
		'category_base'          => $permalinks['category_base'],
		'use_verbose_page_rules' => $permalinks['use_verbose_page_rules'],
	);

	if ( $saved_permalinks !== $stored ) {
		update_option( 'eventkoi_permalinks', $stored );
	}

	// Rewrite slugs reuse the same per-segment sanitizer.
	$permalinks['event_rewrite_slug']    = untrailingslashit( $sanitize_base_path( $permalinks['event_base'] ) );
	$permalinks['category_rewrite_slug'] = untrailingslashit( $sanitize_base_path( $permalinks['category_base'] ) );

	return $permalinks;
}

/**
 * Check if the current theme supports the block editor or full site editing (FSE).
 *
 * @since 1.0.0
 *
 * @return bool True if the theme supports block editor or FSE, false otherwise.
 */
function eventkoi_current_theme_support() {
	$support = false;

	if ( function_exists( 'wp_is_block_theme' ) && wp_is_block_theme() ) {
		$support = true;
	} elseif ( function_exists( 'gutenberg_is_fse_theme' ) && gutenberg_is_fse_theme() ) {
		$support = true;
	}

	/**
	 * Filter the theme support detection result.
	 *
	 * @since 1.0.0
	 *
	 * @param bool $support Whether the theme supports the block editor or FSE.
	 */
	return apply_filters( 'eventkoi_current_theme_support', $support );
}

/**
 * Get the default date format for EventKoi.
 *
 * @return string Date format string.
 */
function eventkoi_get_default_date_format() {
	static $date_format = null;

	if ( null === $date_format ) {
		// Allow override via constant or use fallback format.
		if ( defined( 'EVENTKOI_DATE_FORMAT' ) && EVENTKOI_DATE_FORMAT ) {
			$date_format = EVENTKOI_DATE_FORMAT;
		} else {
			// Customize this if you prefer WordPress settings: get_option( 'date_format' ).
			$date_format = 'Y-m-d h:i A';
		}
	}

	/**
	 * Filter the default date format used across EventKoi.
	 *
	 * @param string $date_format The date format string.
	 */
	return apply_filters( 'eventkoi_get_default_date_format', $date_format );
}

/**
 * Get GMT time from date given.
 *
 * @param string $date A date.
 */
function eventkoi_get_gmt_from_date( $date ) {
	if ( ! $date ) {
		return '';
	}

	return get_gmt_from_date( $date, eventkoi_get_default_date_format() );
}

/**
 * Return a formatted date based on a given date string or timestamp.
 *
 * @param string|int $date A date string or Unix timestamp.
 * @param bool       $gmt  Optional. If true, use GMT timezone. Default false.
 * @return string           Formatted date string.
 */
function eventkoi_date_i18n( $date, $gmt = false ) {
	$format = eventkoi_get_default_date_format();

	// Convert to timestamp if needed.
	if ( ! is_numeric( $date ) ) {
		$date = strtotime( $date );
	}

	// Fallback to now if conversion fails.
	if ( false === $date ) {
		$date = time();
	}

	$timezone = true === $gmt ? new DateTimeZone( 'GMT' ) : wp_timezone();

	return gmdate( $format, (int) $date );
}

/**
 * Get the default calendar URL.
 *
 * Returns the permalink for the default event calendar, or an empty string if none is set.
 * Falls back to the main events archive if the default calendar is not valid.
 *
 * @return string Default calendar URL.
 */
function eventkoi_get_default_calendar_url() {
	$default_cal_id = eventkoi_resolve_calendar_id( (int) get_option( 'eventkoi_default_event_cal', 0 ) );

	if ( $default_cal_id <= 0 ) {
		// Fallback to events archive if no default calendar is set.
		$archive_url = get_post_type_archive_link( 'eventkoi_event' );
		return $archive_url ? esc_url( $archive_url ) : '';
	}

	$default_cal = get_term_by( 'id', $default_cal_id, 'event_cal' );

	if ( ! $default_cal || is_wp_error( $default_cal ) ) {
		// Fallback to events archive if the term is invalid.
		$archive_url = get_post_type_archive_link( 'eventkoi_event' );
		return $archive_url ? esc_url( $archive_url ) : '';
	}

	$cal_url = get_term_link( $default_cal, 'event_cal' );

	if ( is_wp_error( $cal_url ) ) {
		return '';
	}

	return esc_url( trailingslashit( $cal_url ) );
}

/**
 * Add a query arg with RFC3986 encoding so timezone offsets and IANA names
 * survive frontend navigation unchanged.
 *
 * @param string $url   URL to update.
 * @param string $key   Query arg key.
 * @param string $value Query arg value.
 * @return string
 */
function eventkoi_add_encoded_query_arg( $url, $key, $value ) {
	$url = (string) $url;

	if ( '' === $url ) {
		return '';
	}

	$fragment = '';
	$hash_pos = strpos( $url, '#' );

	if ( false !== $hash_pos ) {
		$fragment = substr( $url, $hash_pos );
		$url      = substr( $url, 0, $hash_pos );
	}

	$url       = remove_query_arg( $key, $url );
	$separator = false === strpos( $url, '?' ) ? '?' : '&';

	return $url . $separator . rawurlencode( (string) $key ) . '=' . rawurlencode( (string) $value ) . $fragment;
}

/**
 * Restore offset timezone values after PHP decodes a raw plus as a space.
 *
 * @param string $timezone Timezone query arg.
 * @return string
 */
function eventkoi_restore_decoded_timezone_offset( $timezone ) {
	$timezone = (string) $timezone;

	if ( preg_match( '/^\s+\d{1,2}(?::?\d{2})?$/', $timezone ) ) {
		return '+' . ltrim( $timezone );
	}

	if ( preg_match( '/^UTC\s+\d{1,2}(?::?\d{2})?$/i', $timezone ) ) {
		return preg_replace( '/^UTC\s+/i', 'UTC+', $timezone );
	}

	return $timezone;
}

/**
 * Get the current frontend display timezone query arg when it is valid.
 *
 * @return string
 */
function eventkoi_get_frontend_timezone_query_arg() {
	if ( empty( $_GET['tz'] ) || is_array( $_GET['tz'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only frontend display state.
		return '';
	}

	$timezone = (string) wp_unslash( $_GET['tz'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized below after restoring offset notation.
	$timezone = eventkoi_restore_decoded_timezone_offset( $timezone );
	$timezone = sanitize_text_field( $timezone );

	if ( '' === $timezone ) {
		return '';
	}

	if ( 'local' === $timezone ) {
		return $timezone;
	}

	try {
		new DateTimeZone( eventkoi_php_timezone( $timezone ) );
	} catch ( Exception $e ) {
		return '';
	}

	return $timezone;
}

/**
 * Preserve the active frontend display timezone on internal EventKoi links.
 *
 * @param string $url URL to update.
 * @return string
 */
function eventkoi_append_frontend_timezone_arg( $url ) {
	$url = (string) $url;

	if ( '' === $url ) {
		return '';
	}

	$timezone = eventkoi_get_frontend_timezone_query_arg();
	if ( '' === $timezone ) {
		return $url;
	}

	return eventkoi_add_encoded_query_arg( $url, 'tz', $timezone );
}

/**
 * Returns current date based on GMT and specific format.
 *
 * @param string $format Optional. A date format. Default is the plugin's default format.
 * @return string Current GMT date/time.
 */
function eventkoi_gmt_date( $format = '' ) {
	if ( '' === $format ) {
		$format = eventkoi_get_default_date_format();
	}

	return gmdate( $format );
}

/**
 * Get human-readable timezone.
 *
 * Returns a named timezone (e.g. 'Africa/Egypt') or a UTC offset (e.g. 'UTC+2').
 *
 * @return string Timezone string.
 */
function eventkoi_timezone() {
	$timezone_string = (string) get_option( 'timezone_string', '' );

	if ( ! empty( $timezone_string ) ) {
		$timezone = $timezone_string;
	} else {
		$offset = (float) get_option( 'gmt_offset', 0 );

		if ( 0.0 === $offset ) {
			$timezone = 'UTC';
		} else {
			$sign    = ( 0.0 <= $offset ) ? '+' : '-';
			$hours   = (int) $offset;
			$minutes = (int) round( abs( ( $offset - $hours ) * 60 ) );

			if ( 0 === $minutes ) {
				$timezone = sprintf( 'UTC%s%d', $sign, abs( $hours ) );
			} else {
				$timezone = sprintf( 'UTC%s%d:%02d', $sign, abs( $hours ), $minutes );
			}
		}
	}

	return apply_filters( 'eventkoi_timezone', $timezone );
}

/**
 * Normalize an EventKoi timezone string into a PHP-compatible timezone identifier.
 *
 * WordPress can expose offset-style values like `UTC+0` or `UTC+2`, but
 * `DateTimeZone` only accepts named zones or offset identifiers such as
 * `+00:00` and `+02:00`. This helper keeps named timezones unchanged and
 * normalizes offset-style EventKoi values for runtime date calculations.
 *
 * @param string $timezone Raw EventKoi timezone string.
 * @return string
 */
function eventkoi_php_timezone( $timezone ) {
	$timezone = (string) $timezone;

	if ( preg_match( '/^\s+\d{1,2}(?::?\d{2})?$/', $timezone ) ) {
		$timezone = '+' . ltrim( $timezone );
	} elseif ( preg_match( '/^UTC\s+\d{1,2}(?::?\d{2})?$/i', $timezone ) ) {
		$timezone = preg_replace( '/^UTC\s+/i', 'UTC+', $timezone );
	}

	$timezone = trim( $timezone );

	if ( '' === $timezone ) {
		return 'UTC';
	}

	if ( preg_match( '/^[+-]?\d+(?:\.\d+)?$/', $timezone ) ) {
		$offset  = (float) $timezone;
		$abs     = abs( $offset );
		$hours   = (int) floor( $abs );
		$minutes = (int) round( ( $abs - $hours ) * 60 );

		if ( 60 === $minutes ) {
			++$hours;
			$minutes = 0;
		}

		if ( 0 === $hours && 0 === $minutes ) {
			return 'UTC';
		}

		return sprintf( '%s%02d:%02d', $offset >= 0 ? '+' : '-', $hours, $minutes );
	}

	if ( preg_match( '/^UTC([+-]?\d+(?:\.\d+)?)$/i', $timezone, $matches ) ) {
		return eventkoi_php_timezone( $matches[1] );
	}

	if ( preg_match( '/^UTC([+-])(\d{1,2})(?::?(\d{2}))?$/', $timezone, $matches ) ) {
		$hours   = str_pad( $matches[2], 2, '0', STR_PAD_LEFT );
		$minutes = isset( $matches[3] ) ? $matches[3] : '00';

		if ( '00' === $hours && '00' === $minutes ) {
			return 'UTC';
		}

		return sprintf( '%s%s:%s', $matches[1], $hours, $minutes );
	}

	return $timezone;
}

/**
 * Infer a fixed all-day source timezone from UTC all-day boundaries.
 *
 * Legacy all-day events may only have UTC start/end instants. When those
 * instants are not UTC-midnight boundaries, the UTC time-of-day encodes the
 * local offset that was used when the all-day date was saved.
 *
 * @param string $start_date UTC start date string.
 * @param string $end_date   Optional UTC end date string.
 * @return string PHP-compatible timezone identifier, or empty string.
 */
function eventkoi_infer_all_day_timezone_from_utc_range( $start_date, $end_date = '' ) {
	$start_ts = strtotime( (string) $start_date );
	if ( ! $start_ts ) {
		return '';
	}

	$start_seconds = ( (int) gmdate( 'H', $start_ts ) * HOUR_IN_SECONDS )
		+ ( (int) gmdate( 'i', $start_ts ) * MINUTE_IN_SECONDS )
		+ (int) gmdate( 's', $start_ts );

	$offset = ( DAY_IN_SECONDS - $start_seconds ) % DAY_IN_SECONDS;
	if ( $offset > 14 * HOUR_IN_SECONDS ) {
		$offset -= DAY_IN_SECONDS;
	}

	if ( $offset < -12 * HOUR_IN_SECONDS || $offset > 14 * HOUR_IN_SECONDS ) {
		return '';
	}

	if ( 0 === $offset ) {
		return 'UTC';
	}

	$sign    = $offset >= 0 ? '+' : '-';
	$abs     = abs( $offset );
	$hours   = (int) floor( $abs / HOUR_IN_SECONDS );
	$minutes = (int) floor( ( $abs % HOUR_IN_SECONDS ) / MINUTE_IN_SECONDS );

	return sprintf( '%s%02d:%02d', $sign, $hours, $minutes );
}

/**
 * Check whether a timezone fits stored UTC all-day boundaries.
 *
 * Legacy all-day rows can be ambiguous for western offsets because a UTC
 * midnight-equivalent like 10:00Z may be either +14 next-day midnight or
 * Pacific/Honolulu same-day midnight. Stored event timezone metadata should
 * disambiguate only when it actually matches the all-day boundary shape.
 *
 * @param string $timezone   Candidate timezone.
 * @param string $start_date UTC start date string.
 * @param string $end_date   Optional UTC end date string.
 * @return bool
 */
function eventkoi_all_day_timezone_matches_utc_range( $timezone, $start_date, $end_date = '' ) {
	$timezone = trim( (string) $timezone );
	if ( '' === $timezone ) {
		return false;
	}

	$start_ts = strtotime( (string) $start_date );
	if ( false === $start_ts ) {
		return false;
	}

	try {
		$tz    = new \DateTimeZone( eventkoi_php_timezone( $timezone ) );
		$start = ( new \DateTimeImmutable( '@' . $start_ts ) )->setTimezone( $tz );
	} catch ( \Exception $e ) {
		return false;
	}

	if ( '00:00:00' !== $start->format( 'H:i:s' ) ) {
		return false;
	}

	if ( '' === (string) $end_date ) {
		return true;
	}

	$end_ts = strtotime( (string) $end_date );
	if ( false === $end_ts || $end_ts <= $start_ts ) {
		return true;
	}

	$end  = ( new \DateTimeImmutable( '@' . $end_ts ) )->setTimezone( $tz );
	$hour = (int) $end->format( 'H' );
	$min  = (int) $end->format( 'i' );

	return '00:00:00' === $end->format( 'H:i:s' ) || ( 23 === $hour && $min >= 58 );
}

/**
 * Decide whether stored timezone metadata should override inferred offset.
 *
 * Keep legacy fixed-offset output when it produces the same calendar date as
 * the stored timezone. Prefer stored timezone only when it disambiguates a
 * different date, such as UTC-10 all-day rows that fixed-offset inference
 * would otherwise read as +14.
 *
 * @param string $stored_timezone Stored event timezone.
 * @param string $inferred        Inferred fixed-offset timezone.
 * @param string $start_date      UTC start date string.
 * @param string $end_date        Optional UTC end date string.
 * @return bool
 */
function eventkoi_all_day_timezone_should_prefer_stored( $stored_timezone, $inferred, $start_date, $end_date = '' ) {
	if ( ! eventkoi_all_day_timezone_matches_utc_range( $stored_timezone, $start_date, $end_date ) ) {
		return false;
	}

	$inferred = trim( (string) $inferred );
	if ( '' === $inferred ) {
		return true;
	}

	$start_ts = strtotime( (string) $start_date );
	if ( false === $start_ts ) {
		return true;
	}

	$end_ts = '' !== (string) $end_date ? strtotime( (string) $end_date ) : false;

	try {
		$stored_tz      = new \DateTimeZone( eventkoi_php_timezone( $stored_timezone ) );
		$inferred_tz    = new \DateTimeZone( eventkoi_php_timezone( $inferred ) );
		$stored_start   = ( new \DateTimeImmutable( '@' . $start_ts ) )->setTimezone( $stored_tz )->format( 'Y-m-d' );
		$inferred_start = ( new \DateTimeImmutable( '@' . $start_ts ) )->setTimezone( $inferred_tz )->format( 'Y-m-d' );

		if ( $stored_start !== $inferred_start ) {
			return true;
		}

		if ( false === $end_ts ) {
			return false;
		}

		$stored_end   = ( new \DateTimeImmutable( '@' . $end_ts ) )->setTimezone( $stored_tz )->format( 'Y-m-d' );
		$inferred_end = ( new \DateTimeImmutable( '@' . $end_ts ) )->setTimezone( $inferred_tz )->format( 'Y-m-d' );

		return $stored_end !== $inferred_end;
	} catch ( \Exception $e ) {
		return true;
	}
}

/**
 * Define a constant if it is not already defined.
 *
 * @since 1.0.0
 *
 * @param string $name  Constant name.
 * @param mixed  $value Constant value.
 * @return void
 */
function eventkoi_maybe_define_constant( $name, $value ) {
	if ( ! defined( $name ) ) {
		define( $name, $value );
	}
}

/**
 * Get the default calendar color.
 *
 * @since 1.0.0
 *
 * @return string Hex color string.
 */
function eventkoi_default_calendar_color() {
	/**
	 * Filter the default calendar color.
	 *
	 * @since 1.0.0
	 *
	 * @param string $color Default calendar color in hex format.
	 */
	return apply_filters( 'eventkoi_default_calendar_color', '#578CA7' );
}

/**
 * Get the stored calendar color, preserving explicit transparency.
 *
 * @param int $calendar_id Calendar term ID.
 * @return string Calendar color.
 */
function eventkoi_get_stored_calendar_color( $calendar_id = 0 ) {
	$calendar_id = absint( $calendar_id );

	if ( ! $calendar_id ) {
		return eventkoi_default_calendar_color();
	}

	$color     = get_term_meta( $calendar_id, 'color', true );
	$has_color = metadata_exists( 'term', $calendar_id, 'color' );

	if ( $has_color && '' === (string) $color ) {
		return 'transparent';
	}

	if ( '' === (string) $color ) {
		return eventkoi_default_calendar_color();
	}

	return (string) $color;
}

/**
 * Get a human-readable label for a given status.
 *
 * @since 1.0.0
 *
 * @param string $status A given status.
 * @return string Localized human-readable label.
 */
function eventkoi_get_status_title( $status = '' ) {
	$map = array(
		'complete'           => __( 'Completed', 'eventkoi-lite' ),
		'succeeded'          => __( 'Completed', 'eventkoi-lite' ),
		'refunded'           => __( 'Refunded', 'eventkoi-lite' ),
		'partially_refunded' => __( 'Partially refunded', 'eventkoi-lite' ),
		'failed'             => __( 'Failed', 'eventkoi-lite' ),
		'pending'            => __( 'Pending payment', 'eventkoi-lite' ),
	);

	$label = $map[ $status ] ?? __( 'Incomplete', 'eventkoi-lite' );

	/**
	 * Filter the human-readable label for a status.
	 *
	 * @since 1.0.0
	 *
	 * @param string $label  The human-readable label.
	 * @param string $status The original status.
	 */
	return apply_filters( 'eventkoi_get_status_title', $label, $status );
}

/**
 * Checks if live mode is enabled for eCommerce/purchase.
 *
 * @return bool True if live mode is enabled, false otherwise.
 */
function eventkoi_live_mode_enabled() {
	$settings = \EventKoi\Core\Settings::get();
	$live     = ( ! empty( $settings['mode'] ) && 'live' === $settings['mode'] );

	/**
	 * Filter whether live mode is enabled.
	 *
	 * @param bool $live True if live mode is enabled, false otherwise.
	 */
	return apply_filters( 'eventkoi_live_mode_enabled', $live );
}

/**
 * Format a UTC datetime string for use with Google Calendar.
 *
 * Google Calendar expects timestamps in the format: `Ymd\THis\Z` (e.g., 20250330T130000Z).
 *
 * @since 1.0.0
 *
 * @param string $datetime A UTC datetime string (e.g., '2025-03-30 13:00:00').
 * @return string Formatted string for Google Calendar, or an empty string on failure.
 */
function eventkoi_format_gcal_datetime( $datetime ) {
	if ( empty( $datetime ) ) {
		return '';
	}

	try {
		$utc = new DateTime( $datetime, new DateTimeZone( 'UTC' ) );

		// Format as required by Google Calendar (e.g., 20250330T130000Z).
		return $utc->format( 'Ymd\THis\Z' );
	} catch ( Exception $e ) {
		// Log or handle the exception if needed.
		return '';
	}
}

/**
 * Get the client's IP address from server headers.
 *
 * Supports Cloudflare and other proxy headers.
 *
 * @return string The sanitized IP address.
 */
function eventkoi_get_client_ip() {
	$raw_ip = '';

	if ( isset( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) {
		$raw_ip = sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) );
	} elseif ( isset( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
		$forwarded_ips = explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) );
		$raw_ip        = trim( $forwarded_ips[0] );
	} elseif ( isset( $_SERVER['REMOTE_ADDR'] ) ) {
		$raw_ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
	}

	// Normalize IPv6 localhost.
	if ( '::1' === $raw_ip ) {
		$raw_ip = '127.0.0.1';
	}

	/**
	 * Filter the sanitized client IP address.
	 *
	 * @param string $raw_ip The sanitized IP string.
	 */
	return apply_filters( 'eventkoi_raw_client_ip', $raw_ip );
}

/**
 * Get the appropriate date format for a given database timestamp field.
 *
 * @param string $field Field name (e.g., 'created', 'last_updated').
 * @return string Date format string.
 */
function eventkoi_get_field_date_format( $field ) {
	$date_format = trim( eventkoi_resolved_date_format() );
	$time_format = trim( eventkoi_resolved_time_format() );
	$format      = trim( $date_format . ', ' . $time_format, ', ' );

	if ( '' === $format ) {
		$format = eventkoi_get_default_date_format();
	}

	/**
	 * Filter the date format used for a specific field.
	 *
	 * @param string $format The date format string.
	 * @param string $field  The field name (e.g., 'created', 'last_updated').
	 */
	return apply_filters( 'eventkoi_field_date_format', $format, $field );
}

/**
 * Generate recurring instance start timestamps based on rule.
 *
 * @param array $rule  Recurrence rule.
 * @param int   $limit Maximum number of instances.
 * @return array Array of UNIX timestamps.
 */
function eventkoi_generate_instance_starts( $rule, $limit = 500 ) {
	$instances = array();

	if ( empty( $rule['start_date'] ) || empty( $rule['frequency'] ) ) {
		return $instances;
	}

	$start_ts     = strtotime( $rule['start_date'] );
	$frequency    = $rule['frequency'];
	$interval     = isset( $rule['every'] ) ? max( 1, intval( $rule['every'] ) ) : 1;
	$ends_type    = isset( $rule['ends'] ) ? $rule['ends'] : 'never';
	$ends_after   = isset( $rule['ends_after'] ) ? intval( $rule['ends_after'] ) : 0;
	$ends_on      = ! empty( $rule['ends_on'] ) ? strtotime( $rule['ends_on'] ) : null;
	$instance_max = ( 'after' === $ends_type && 0 < $ends_after ) ? $ends_after : $limit;

	$count   = 0;
	$current = $start_ts;

	if ( 'week' === $frequency && ! empty( $rule['weekdays'] ) ) {
		while ( $count < $instance_max ) {
			foreach ( $rule['weekdays'] as $weekday ) {
				$weekday   = intval( $weekday ); // 0 = Monday.
				$week_base = strtotime( 'monday this week', $current );
				$day_ts    = strtotime( '+' . $weekday . ' days', $week_base );

				if ( $day_ts < $start_ts ) {
					continue;
				}

				if ( 'on' === $ends_type && null !== $ends_on && $day_ts > $ends_on ) {
					break 2;
				}

				$instances[] = $day_ts;
				++$count;

				if ( $count >= $instance_max ) {
					break 2;
				}
			}

			$current = strtotime( '+' . $interval . ' weeks', $current );
		}
	} elseif ( 'month' === $frequency ) {
		$month_rule = isset( $rule['month_day_rule'] ) ? $rule['month_day_rule'] : 'day-of-month';
		$base_date  = new DateTimeImmutable( gmdate( 'Y-m-d', $start_ts ) );

		while ( $count < $instance_max ) {
			if ( 'on' === $ends_type && null !== $ends_on && $base_date->getTimestamp() > $ends_on ) {
				break;
			}

			if ( 'day-of-month' === $month_rule ) {
				$instance = $base_date->format( 'Y-m-' ) . $base_date->format( 'd' );
				$ts       = strtotime( $instance );
			} elseif ( 'weekday-of-month' === $month_rule ) {
				$weekday = (int) $base_date->format( 'N' );
				$nth     = (int) ceil( $base_date->format( 'j' ) / 7 );

				$month_year  = $base_date->format( 'F Y' );
				$weekday_str = jddayofweek( $weekday % 7, 1 );
				$nth_label   = $nth . ' ' . $weekday_str;

				$ts = strtotime( $nth_label . ' of ' . $month_year );
			} else {
				$ts = $base_date->getTimestamp();
			}

			if ( $ts >= $start_ts ) {
				$instances[] = $ts;
				++$count;
			}

			$base_date = $base_date->modify( '+' . $interval . ' months' );
		}
	} else { // Daily fallback.
		while ( $count < $instance_max ) {
			if ( 'on' === $ends_type && null !== $ends_on && $current > $ends_on ) {
				break;
			}

			$instances[] = $current;
			++$count;
			$current = strtotime( '+' . $interval . ' days', $current );
		}
	}

	return $instances;
}

/**
 * Build an inclusive RRULE UNTIL value for a recurrence "ends on" date.
 *
 * The UI saves `ends_on` as a UTC ISO instant for the selected local midnight.
 * Convert shifted instants back to the event timezone's calendar day before anchoring
 * UNTIL to 23:59:59, while preserving legacy date-only and exact-midnight values.
 *
 * @param string             $ends_on  Raw `ends_on` value from the rule.
 * @param \DateTimeZone|null $event_tz Event timezone. Falls back to site timezone.
 * @return \DateTimeImmutable|null End-of-day in event timezone, or null on bad input.
 */
function eventkoi_recurrence_until( $ends_on, $event_tz = null ) {
	$raw = trim( (string) $ends_on );

	if ( '' === $raw ) {
		return null;
	}

	if ( ! ( $event_tz instanceof \DateTimeZone ) ) {
		$event_tz = wp_timezone();
	}

	$date_part = null;

	if ( preg_match( '/^(\d{4}-\d{2}-\d{2})$/', $raw, $m ) ) {
		$date_part = $m[1];
	} elseif ( preg_match( '/^(\d{4}-\d{2}-\d{2})T00:00(?::00(?:\.0+)?)?(?:Z|[+-]\d\d:\d\d)$/i', $raw, $m ) ) {
		$date_part = $m[1];
	} elseif ( preg_match( '/(?:Z|[+-]\d\d:\d\d)$/i', $raw ) ) {
		try {
			$instant   = new \DateTimeImmutable( $raw );
			$date_part = $instant->setTimezone( $event_tz )->format( 'Y-m-d' );
		} catch ( \Exception $e ) {
			$date_part = null;
		}
	}

	if ( null === $date_part && preg_match( '/^(\d{4}-\d{2}-\d{2})/', $raw, $m ) ) {
		$date_part = $m[1];
	}

	if ( null === $date_part ) {
		return null;
	}

	try {
		return new \DateTimeImmutable( $date_part . ' 23:59:59', $event_tz );
	} catch ( \Exception $e ) {
		return null;
	}
}

/**
 * Locate an EventKoi template.
 *
 * Looks in the theme first, then falls back to the plugin.
 *
 * @param string $template_name Template filename relative to eventkoi/ folder.
 * @param string $default_path  Fallback path in the plugin.
 * @return string Full path to the located template.
 */
function eventkoi_locate_template( $template_name, $default_path = '' ) {
	$default_path = $default_path ? $default_path : EVENTKOI_PLUGIN_DIR . 'templates/parts/';

	// Look inside the theme.
	$template = locate_template(
		array(
			'eventkoi/' . $template_name,
		)
	);

	// Allow developers to override via filter.
	$template = apply_filters( 'eventkoi_locate_template', $template, $template_name, $default_path );

	// Use plugin fallback if nothing found.
	if ( ! $template || ! file_exists( $template ) ) {
		$template = $default_path . $template_name;
	}

	return $template;
}

/**
 * Resolve the date format EventKoi should use for date-only renders.
 *
 * Precedence:
 *   1. eventkoi_settings['date_format'] (custom PHP format string, when non-empty)
 *   2. WordPress core option 'date_format'
 *   3. Hard fallback 'F j, Y'
 *
 * @return string PHP date format.
 */
function eventkoi_resolved_date_format(): string {
	$settings = Settings::get();
	$custom   = isset( $settings['date_format'] ) ? trim( (string) $settings['date_format'] ) : '';
	$format   = '' !== $custom ? $custom : (string) get_option( 'date_format', 'F j, Y' );

	/**
	 * Filter the resolved EventKoi date format.
	 *
	 * Lets a single render override the format (e.g. the Event Data block's
	 * per-block "Date format" control) without touching the global setting.
	 *
	 * @param string $format Resolved PHP date format.
	 */
	return (string) apply_filters( 'eventkoi_resolved_date_format', $format );
}

/**
 * Resolve the time format EventKoi should use for time-only renders.
 *
 * Precedence:
 *   1. eventkoi_settings['time_format_string'] (custom PHP format string, when non-empty)
 *      → returned as-is; the 12/24 toggle is NOT applied.
 *   2. WordPress core option 'time_format', then mutated by the 12/24 toggle.
 *   3. Hard fallback 'g:i a'.
 *
 * @return string PHP date format.
 */
function eventkoi_resolved_time_format(): string {
	$settings = Settings::get();
	$custom   = isset( $settings['time_format_string'] ) ? trim( (string) $settings['time_format_string'] ) : '';
	$format   = '' !== $custom
		? $custom
		: eventkoi_apply_time_preference( (string) get_option( 'time_format', 'g:i a' ) );

	/**
	 * Filter the resolved EventKoi time format.
	 *
	 * Lets a single render override the format (e.g. the Event Data block's
	 * per-block "Time format" control) without touching the global setting.
	 *
	 * @param string $format Resolved PHP time format.
	 */
	return (string) apply_filters( 'eventkoi_resolved_time_format', $format );
}

/**
 * Check whether a PHP date format contains an unescaped token.
 *
 * @param string $format PHP date format string.
 * @param array  $tokens Tokens to look for.
 * @return bool
 */
function eventkoi_time_format_has_token( string $format, array $tokens ): bool {
	$escaped = false;
	$tokens  = array_flip( $tokens );

	foreach ( str_split( $format ) as $char ) {
		if ( $escaped ) {
			$escaped = false;
			continue;
		}

		if ( '\\' === $char ) {
			$escaped = true;
			continue;
		}

		if ( isset( $tokens[ $char ] ) ) {
			return true;
		}
	}

	return false;
}

/**
 * Replace unescaped PHP date format tokens while preserving escaped literals.
 *
 * @param string $format PHP date format string.
 * @param array  $replacements Token replacement map.
 * @return string
 */
function eventkoi_replace_time_format_tokens( string $format, array $replacements ): string {
	$output  = '';
	$escaped = false;

	foreach ( str_split( $format ) as $char ) {
		if ( $escaped ) {
			$output .= '\\' . $char;
			$escaped = false;
			continue;
		}

		if ( '\\' === $char ) {
			$escaped = true;
			continue;
		}

		$output .= array_key_exists( $char, $replacements ) ? $replacements[ $char ] : $char;
	}

	if ( $escaped ) {
		$output .= '\\';
	}

	return $output;
}

/**
 * Normalize spacing after removing or appending time format tokens.
 *
 * @param string $format PHP date format string.
 * @return string
 */
function eventkoi_tidy_time_format( string $format ): string {
	$format = preg_replace( '/\s{2,}/', ' ', $format );
	$format = preg_replace( '/\s+([,.:;])/', '$1', (string) $format );

	return trim( (string) $format );
}

/**
 * Apply EventKoi 12/24-hour preference to a PHP date format string.
 *
 * Skipped (returns $format unchanged) when the admin has set a custom
 * time_format_string in eventkoi_settings — the explicit string wins.
 *
 * @param string $format PHP date format string.
 * @return string Adjusted format string.
 */
function eventkoi_apply_time_preference( string $format ): string {
	$settings = Settings::get();
	if ( ! empty( $settings['time_format_string'] ) ) {
		return $format;
	}

	$preference = ! empty( $settings['time_format'] ) ? (string) $settings['time_format'] : '12';

	if ( '24' === $preference ) {
		$format = eventkoi_replace_time_format_tokens(
			$format,
			array(
				'g' => 'H',
				'h' => 'H',
				'G' => 'H',
				'H' => 'H',
				'a' => '',
				'A' => '',
			)
		);

		return eventkoi_tidy_time_format( $format ) ?: 'H:i';
	}

	if ( '12' === $preference ) {
		$has_meridiem = eventkoi_time_format_has_token( $format, array( 'a', 'A' ) );
		$has_hour     = eventkoi_time_format_has_token( $format, array( 'g', 'G', 'h', 'H' ) );
		$format       = eventkoi_replace_time_format_tokens(
			$format,
			array(
				'G' => 'g',
				'H' => 'g',
				'h' => 'g',
			)
		);
		$format       = eventkoi_tidy_time_format( $format );

		if ( $has_hour && ! $has_meridiem ) {
			$format = eventkoi_tidy_time_format( $format . ' a' );
		}
	}

	return $format;
}

/**
 * Wrapper around wp_date() that respects WordPress date/time settings.
 *
 * @param string                   $type      'date', 'time', or 'datetime'.
 * @param int|null                 $timestamp Optional. Unix timestamp. Defaults to now.
 * @param DateTimeZone|string|null $timezone  Optional. Defaults to WP timezone.
 * @return string
 */
function eventkoi_date( $type = 'datetime', $timestamp = null, $timezone = null ) {
	if ( null === $timestamp ) {
		$timestamp = time();
	}

	// Honor auto-detect setting: use visitor local time when enabled and no explicit timezone passed.
	if ( null === $timezone ) {
		$settings = Settings::get();
		if ( rest_sanitize_boolean( $settings['auto_detect_timezone'] ?? false ) ) {
			$timezone = wp_timezone();
		}
	}

	$date_format = eventkoi_resolved_date_format();
	$time_format = eventkoi_resolved_time_format();

	switch ( $type ) {
		case 'date':
			$format = $date_format;
			break;
		case 'time':
			$format = $time_format;
			break;
		default:
			$format = "$date_format, $time_format";
			break;
	}

	return wp_date( $format, $timestamp, $timezone );
}

/**
 * EventKoi wrapper for gmdate() that respects settings->time_format.
 *
 * @param string $format    PHP date format string.
 * @param int    $timestamp Unix timestamp. Defaults to now.
 * @return string
 */
function eventkoi_gmdate( $format, $timestamp = null ) {
	if ( null === $timestamp ) {
		$timestamp = time();
	}

	$format = eventkoi_apply_time_preference( $format );

	return gmdate( $format, $timestamp );
}

/**
 * Determine whether an all-day UTC span represents one calendar day.
 *
 * @param int|null $start_ts Start timestamp.
 * @param int|null $end_ts   Inclusive end timestamp.
 * @return bool
 */
function eventkoi_is_single_all_day_span( $start_ts, $end_ts ): bool {
	$start_ts = ! empty( $start_ts ) ? (int) $start_ts : 0;
	$end_ts   = ! empty( $end_ts ) ? (int) $end_ts : 0;

	return $start_ts > 0 && $end_ts > $start_ts && ( $end_ts - $start_ts ) <= DAY_IN_SECONDS;
}

/**
 * Format a datetime range for display.
 *
 * Single entry point for all event date/time rendering to guarantee consistency
 * between the event single page, calendar grid, event data block, and any other
 * surface. The visitor-local conversion is handled by the frontend JS
 * (local-timezone.js) via data attributes — this function always renders in
 * the site timezone (via wp_date with default).
 *
 * @param int                      $start_ts  Start timestamp (UTC).
 * @param int|null                 $end_ts    End timestamp (UTC), or null.
 * @param bool                     $all_day   Whether the event is all-day.
 * @param array                    $args      {
 *     Optional.
 *
 *     @type string                $separator Separator between parts. Default ' — '.
 *     @type DateTimeZone|string   $timezone  Optional timezone override.
 * }
 * @return string Formatted display string (plain text, no HTML wrapping).
 */
function eventkoi_format_datetime_range( $start_ts, $end_ts = null, $all_day = false, $args = array() ) {
	if ( empty( $start_ts ) ) {
		return '';
	}

	$separator   = isset( $args['separator'] ) ? (string) $args['separator'] : ' — ';
	$timezone    = $args['timezone'] ?? null;
	$date_format = eventkoi_resolved_date_format();
	$time_format = eventkoi_resolved_time_format();

	$start_ts = (int) $start_ts;
	$end_ts   = ! empty( $end_ts ) ? (int) $end_ts : null;

	$same_day = false;
	if ( $end_ts ) {
		$same_day = wp_date( 'Y-m-d', $start_ts, $timezone ) === wp_date( 'Y-m-d', $end_ts, $timezone );
	}

	if ( $all_day ) {
		if ( eventkoi_is_single_all_day_span( $start_ts, $end_ts ) ) {
			return wp_date( $date_format, $start_ts, $timezone );
		}

		if ( ! $end_ts || $same_day ) {
			return wp_date( $date_format, $start_ts, $timezone );
		}

		return wp_date( $date_format, $start_ts, $timezone )
			. $separator
			. wp_date( $date_format, $end_ts, $timezone );
	}

	$start_str = wp_date( $date_format . ', ' . $time_format, $start_ts, $timezone );

	if ( ! $end_ts ) {
		return $start_str;
	}

	if ( $same_day ) {
		return $start_str . $separator . wp_date( $time_format, $end_ts, $timezone );
	}

	return $start_str . $separator . wp_date( $date_format . ', ' . $time_format, $end_ts, $timezone );
}

/**
 * Retrieves Stripe's webhook secret.
 *
 * @return string The Stripe webhook secret.
 */
function eventkoi_get_stripe_webhook_secret() {
	$env_secret = defined( 'EVENTKOI_STRIPE_WEBHOOK_SECRET' ) ? EVENTKOI_STRIPE_WEBHOOK_SECRET : '';
	if ( ! empty( $env_secret ) ) {
		$webhook_secret = sanitize_text_field( (string) $env_secret );
	} else {
		$settings       = \EventKoi\Core\Settings::get();
		$webhook        = $settings['stripe_webhook'] ?? null;
		$webhook_secret = '';

		if ( is_object( $webhook ) && ! empty( $webhook->secret ) ) {
			$webhook_secret = sanitize_text_field( (string) $webhook->secret );
		} elseif ( is_array( $webhook ) && ! empty( $webhook['secret'] ) ) {
			$webhook_secret = sanitize_text_field( (string) $webhook['secret'] );
		}
	}

	/**
	 * Filters the Stripe webhook secret.
	 *
	 * @param string $webhook_secret The Stripe webhook secret.
	 */
	return apply_filters( 'eventkoi_get_stripe_webhook_secret', $webhook_secret );
}

/**
 * Build a subscribable iCal feed URL for one or more calendars.
 *
 * Pass a single event_cal term ID, an array of IDs, or nothing/"all" to export
 * every calendar. The returned URL serves a live text/calendar feed that Apple
 * and Google Calendar can subscribe to.
 *
 * @param int|int[]|string $calendar_ids Calendar term ID(s), or 'all'.
 * @param string           $scheme       'https' (default) or 'webcal' for a one-click subscribe link.
 * @return string Feed URL.
 */
function eventkoi_get_calendar_feed_url( $calendar_ids = array(), $scheme = 'https' ) {
	if ( 'all' === $calendar_ids ) {
		$value = 'all';
	} else {
		$ids = array();
		foreach ( (array) $calendar_ids as $id ) {
			$id = absint( $id );
			if ( $id ) {
				$ids[] = $id;
			}
		}
		$value = empty( $ids ) ? 'all' : implode( ',', array_values( array_unique( $ids ) ) );
	}

	$url = add_query_arg( 'eventkoi_calendar_feed', $value, home_url( '/' ) );

	if ( 'webcal' === $scheme ) {
		$url = preg_replace( '#^https?://#', 'webcal://', $url );
	}

	return apply_filters( 'eventkoi_calendar_feed_url', $url, $calendar_ids, $scheme );
}
