<?php
/**
 * Brentwood Calendar — data layer for the bw/calendar block.
 *
 * Aggregates events from Brentwood's PUBLIC Google Calendar ICS feeds, the same
 * calendars the live site shows at brentwood.ca/calendar (Calendar, Socials,
 * Admissions, Box Office, B-Well). All five are public, so we read them over
 * plain HTTPS as .ics files — NO Google API, NO service account, NO credentials.
 *
 * Pipeline:  fetch (cached) -> parse ICS -> normalize (expand RRULE) -> aggregate.
 *
 * Two-tier cache keeps the calendar resilient: a 30-minute "fresh" copy plus a
 * 24-hour "last-good" backup, so a transient Google hiccup serves slightly stale
 * data instead of a blank screen (the failure mode the live mirror shows today).
 *
 * @package kadence-child
 */

defined( 'ABSPATH' ) || exit;

/**
 * Calendar sources. google_id values mirror the live site's google_calendars.
 * `color` is the accent (live stores null, so we use the Brentwood brand red).
 * `default` => shown/checked on first paint. `filter` => optional regex on title.
 *
 * @return array<string,array>
 */
function bw_cal_sources() {
	return array(
		'brentwood'  => array(
			'label'   => 'Brentwood Calendar',
			'gid'     => 'brentwood.ca_3pgnmrpebak3vo7npopp05gp1c@group.calendar.google.com',
			'color'   => '#c8272c',
			'default' => true,
			'filter'  => '',
		),
		'socials'    => array(
			'label'   => 'Brentwood Socials',
			'gid'     => 'c_hllrl4oo2218oa92jo8sh3d4rs@group.calendar.google.com',
			'color'   => '#c8272c',
			'default' => true,
			'filter'  => '',
		),
		'admissions' => array(
			'label'   => 'Brentwood Admissions',
			'gid'     => 'c_1941a70ed96b81b0cd0b2f381a8ed645c9489c79a81125faecf8b9507906ad1a@group.calendar.google.com',
			'color'   => '#c8272c',
			'default' => true,
			'filter'  => '',
		),
		'boxoffice'  => array(
			'label'   => 'Brentwood Box Office',
			'gid'     => 'c_5e90381c2aed03c46a90906299d473693dea61b1bc460c4f7944a099477d4e75@group.calendar.google.com',
			'color'   => '#c8272c',
			'default' => true,
			'filter'  => '',
		),
		'bwell'      => array(
			'label'   => 'B-Well',
			'gid'     => 'c_915c05f3fed059ece5ca2d860b9b911d5be61f51773b4521a476801f6e9123e0@group.calendar.google.com',
			'color'   => '#c8272c',
			'default' => false,
			'filter'  => '',
		),
	);
}

/** Canonical timezone events are stored/aggregated in (Brentwood is Pacific). */
function bw_cal_tz() {
	return 'America/Vancouver';
}

/** Resolve a (validated) display DateTimeZone, falling back to the canonical one. */
function bw_cal_zone( $tz_string = '' ) {
	if ( $tz_string ) {
		try {
			return new DateTimeZone( $tz_string );
		} catch ( Exception $e ) {
			// fall through.
		}
	}
	return new DateTimeZone( bw_cal_tz() );
}

/** Public ICS download URL for a calendar id. */
function bw_cal_feed_url( $gid ) {
	return 'https://calendar.google.com/calendar/ical/' . rawurlencode( $gid ) . '/public/basic.ics';
}

/** webcal:// subscribe URL — used by the "Add to my Calendar" links. */
function bw_cal_webcal_url( $gid ) {
	return 'webcal://calendar.google.com/calendar/ical/' . rawurlencode( $gid ) . '/public/basic.ics';
}

/**
 * The time window we keep events for / expand recurrences within.
 *
 * @return array{0:DateTimeImmutable,1:DateTimeImmutable}
 */
function bw_cal_window() {
	$tz = new DateTimeZone( bw_cal_tz() );
	return array(
		new DateTimeImmutable( '-1 month', $tz ),
		new DateTimeImmutable( '+12 months', $tz ),
	);
}

/**
 * Fetch raw ICS text for a calendar, with a two-tier transient cache.
 * Returns '' only if the feed has never been fetched successfully.
 *
 * @param string $gid Google calendar id.
 * @return string
 */
function bw_cal_fetch( $gid ) {
	$fresh_key  = 'bw_cal_raw_' . md5( $gid );
	$backup_key = 'bw_cal_backup_' . md5( $gid );

	$cached = get_transient( $fresh_key );
	if ( false !== $cached ) {
		return $cached;
	}

	$resp = wp_remote_get(
		bw_cal_feed_url( $gid ),
		array(
			'timeout'    => 20,
			'user-agent' => 'brentwooddev-calendar/1.0 (+https://brentwooddev.demoing.info)',
		)
	);

	if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) {
		return get_transient( $backup_key ) ?: '';
	}

	$body = wp_remote_retrieve_body( $resp );
	if ( false === strpos( $body, 'BEGIN:VCALENDAR' ) ) {
		return get_transient( $backup_key ) ?: '';
	}

	set_transient( $fresh_key, $body, 30 * MINUTE_IN_SECONDS );
	set_transient( $backup_key, $body, DAY_IN_SECONDS );
	return $body;
}

/** Unfold ICS continuation lines (RFC 5545: a line starting with space/tab continues the previous). */
function bw_cal_unfold( $ics ) {
	$ics = str_replace( array( "\r\n", "\r" ), "\n", $ics );
	return preg_replace( "/\n[ \t]/", '', $ics );
}

/** Unescape ICS text values (\n \, \; \\). */
function bw_cal_unescape( $s ) {
	$s = str_replace( array( '\\n', '\\N' ), "\n", $s );
	$s = str_replace( array( '\\,', '\\;' ), array( ',', ';' ), $s );
	return str_replace( '\\\\', '\\', $s );
}

/**
 * Parse ICS text into an array of VEVENT property maps.
 * Each event is array<PROPNAME, array<int, array{params:array,value:string}>>
 * (a list per property name, since some — EXDATE — may repeat).
 *
 * @param string $ics Raw ICS text.
 * @return array<int,array>
 */
function bw_cal_parse_ics( $ics ) {
	$ics    = bw_cal_unfold( $ics );
	$events = array();

	if ( ! preg_match_all( '/BEGIN:VEVENT\n(.*?)\nEND:VEVENT/s', $ics, $matches ) ) {
		return $events;
	}

	foreach ( $matches[1] as $block ) {
		$props = array();
		foreach ( explode( "\n", $block ) as $line ) {
			$pos = strpos( $line, ':' );
			if ( false === $pos ) {
				continue;
			}
			$left  = substr( $line, 0, $pos );
			$value = substr( $line, $pos + 1 );
			$bits  = explode( ';', $left );
			$name  = strtoupper( array_shift( $bits ) );

			$params = array();
			foreach ( $bits as $bit ) {
				$kv = explode( '=', $bit, 2 );
				if ( 2 === count( $kv ) ) {
					$params[ strtoupper( $kv[0] ) ] = $kv[1];
				}
			}
			$props[ $name ][] = array(
				'params' => $params,
				'value'  => $value,
			);
		}
		if ( ! empty( $props ) ) {
			$events[] = $props;
		}
	}
	return $events;
}

/**
 * Parse one ICS date/datetime property entry into a tz-adjusted DateTimeImmutable.
 *
 * @param array $entry array{params:array,value:string}
 * @return array{0:?DateTimeImmutable,1:bool}  [datetime, is_all_day]
 */
function bw_cal_parse_dt( $entry ) {
	$tz     = new DateTimeZone( bw_cal_tz() );
	$params = $entry['params'];
	$value  = trim( $entry['value'] );

	// All-day: DTSTART;VALUE=DATE:20260106
	if ( isset( $params['VALUE'] ) && 'DATE' === strtoupper( $params['VALUE'] ) ) {
		$dt = DateTimeImmutable::createFromFormat( 'Ymd|', $value, $tz );
		return array( $dt ?: null, true );
	}

	// UTC: ...T...Z
	if ( 'Z' === substr( $value, -1 ) ) {
		try {
			$dt = new DateTimeImmutable( $value, new DateTimeZone( 'UTC' ) );
			return array( $dt->setTimezone( $tz ), false );
		} catch ( Exception $e ) {
			return array( null, false );
		}
	}

	// Floating with TZID, or plain floating.
	$zone = $tz;
	if ( isset( $params['TZID'] ) ) {
		try {
			$zone = new DateTimeZone( $params['TZID'] );
		} catch ( Exception $e ) {
			$zone = $tz;
		}
	}
	$dt = DateTimeImmutable::createFromFormat( 'Ymd\THis', $value, $zone );
	return array( $dt ? $dt->setTimezone( $tz ) : null, false );
}

/**
 * Expand an RRULE into concrete start datetimes within the window.
 * Supports FREQ DAILY/WEEKLY/MONTHLY/YEARLY + INTERVAL/COUNT/UNTIL, BYDAY (weekly),
 * and EXDATE exclusions. Good enough for the handful of recurring events in feed.
 *
 * @return DateTimeImmutable[]
 */
function bw_cal_expand_rrule( $rrule, $start, $win_from, $win_to, $props ) {
	$rules = array();
	foreach ( explode( ';', $rrule ) as $part ) {
		$kv = explode( '=', $part, 2 );
		if ( 2 === count( $kv ) ) {
			$rules[ strtoupper( $kv[0] ) ] = $kv[1];
		}
	}

	$freq_map = array(
		'DAILY'   => 'day',
		'WEEKLY'  => 'week',
		'MONTHLY' => 'month',
		'YEARLY'  => 'year',
	);
	$freq = isset( $rules['FREQ'] ) ? strtoupper( $rules['FREQ'] ) : '';
	if ( ! isset( $freq_map[ $freq ] ) ) {
		return array( $start );
	}

	$interval = isset( $rules['INTERVAL'] ) ? max( 1, (int) $rules['INTERVAL'] ) : 1;
	$count    = isset( $rules['COUNT'] ) ? (int) $rules['COUNT'] : null;

	$until = null;
	if ( isset( $rules['UNTIL'] ) ) {
		try {
			$u     = $rules['UNTIL'];
			$until = ( 'Z' === substr( $u, -1 ) )
				? new DateTimeImmutable( $u, new DateTimeZone( 'UTC' ) )
				: new DateTimeImmutable( $u, $start->getTimezone() );
			$until = $until->setTimezone( $start->getTimezone() );
		} catch ( Exception $e ) {
			$until = null;
		}
	}

	// EXDATE exclusions keyed by YmdHis.
	$excluded = array();
	if ( ! empty( $props['EXDATE'] ) ) {
		foreach ( $props['EXDATE'] as $entry ) {
			foreach ( explode( ',', $entry['value'] ) as $d ) {
				list( $dd ) = bw_cal_parse_dt(
					array(
						'params' => $entry['params'],
						'value'  => $d,
					)
				);
				if ( $dd ) {
					$excluded[ $dd->format( 'YmdHis' ) ] = true;
				}
			}
		}
	}

	$byday = array();
	if ( 'WEEKLY' === $freq && isset( $rules['BYDAY'] ) ) {
		$dow = array(
			'SU' => 0,
			'MO' => 1,
			'TU' => 2,
			'WE' => 3,
			'TH' => 4,
			'FR' => 5,
			'SA' => 6,
		);
		foreach ( explode( ',', $rules['BYDAY'] ) as $d ) {
			$d = strtoupper( trim( $d ) );
			if ( isset( $dow[ $d ] ) ) {
				$byday[] = $dow[ $d ];
			}
		}
	}

	$out    = array();
	$count_seen = 0;
	$cursor = $start;
	$guard  = 0;

	while ( $guard++ < 1000 ) {
		if ( $cursor > $win_to ) {
			break;
		}
		if ( $until && $cursor > $until ) {
			break;
		}
		if ( null !== $count && $count_seen >= $count ) {
			break;
		}

		if ( 'WEEKLY' === $freq && $byday ) {
			$week_start = $cursor->modify( '-' . (int) $cursor->format( 'w' ) . ' days' );
			foreach ( $byday as $wd ) {
				$day = $week_start->modify( '+' . $wd . ' days' )->setTime(
					(int) $start->format( 'H' ),
					(int) $start->format( 'i' )
				);
				if ( $day < $start ) {
					continue;
				}
				if ( $until && $day > $until ) {
					continue;
				}
				if ( null !== $count && $count_seen >= $count ) {
					break;
				}
				++$count_seen;
				if ( isset( $excluded[ $day->format( 'YmdHis' ) ] ) ) {
					continue;
				}
				if ( $day >= $win_from && $day <= $win_to ) {
					$out[] = $day;
				}
			}
			$cursor = $cursor->modify( '+' . $interval . ' week' );
		} else {
			++$count_seen;
			if ( ! isset( $excluded[ $cursor->format( 'YmdHis' ) ] ) && $cursor >= $win_from && $cursor <= $win_to ) {
				$out[] = $cursor;
			}
			$cursor = $cursor->modify( '+' . $interval . ' ' . $freq_map[ $freq ] );
		}
	}

	return $out;
}

/**
 * Normalize one parsed VEVENT into zero or more event rows (expanding recurrence).
 *
 * @param array $props  Parsed VEVENT property map.
 * @param array $source Source config (with 'key').
 * @param array $window [from, to].
 * @return array<int,array>
 */
function bw_cal_normalize( $props, $source, $window ) {
	list( $win_from, $win_to ) = $window;

	if ( empty( $props['DTSTART'][0] ) ) {
		return array();
	}
	list( $start, $all_day ) = bw_cal_parse_dt( $props['DTSTART'][0] );
	if ( ! $start ) {
		return array();
	}

	$end = null;
	if ( ! empty( $props['DTEND'][0] ) ) {
		list( $end ) = bw_cal_parse_dt( $props['DTEND'][0] );
	}
	$duration = ( $end ) ? ( $end->getTimestamp() - $start->getTimestamp() ) : ( $all_day ? DAY_IN_SECONDS : HOUR_IN_SECONDS );

	$summary = isset( $props['SUMMARY'][0] ) ? bw_cal_unescape( $props['SUMMARY'][0]['value'] ) : '';

	if ( ! empty( $source['filter'] ) && ! preg_match( '/' . str_replace( '/', '\\/', $source['filter'] ) . '/i', $summary ) ) {
		return array();
	}

	$base = array(
		'uid'       => isset( $props['UID'][0] ) ? $props['UID'][0]['value'] : md5( $summary . $start->format( 'c' ) ),
		'calendar'  => $source['key'],
		'title'     => $summary,
		'all_day'   => $all_day,
		'desc_html' => isset( $props['DESCRIPTION'][0] ) ? bw_cal_unescape( $props['DESCRIPTION'][0]['value'] ) : '',
		'location'  => isset( $props['LOCATION'][0] ) ? bw_cal_unescape( $props['LOCATION'][0]['value'] ) : '',
		'color'     => $source['color'],
	);

	$starts = ! empty( $props['RRULE'][0] )
		? bw_cal_expand_rrule( $props['RRULE'][0]['value'], $start, $win_from, $win_to, $props )
		: array( $start );

	$out = array();
	foreach ( $starts as $st ) {
		$en = $st->modify( '+' . $duration . ' seconds' );
		if ( $en < $win_from || $st > $win_to ) {
			continue;
		}
		$row          = $base;
		$row['start'] = $st;
		$row['end']   = $en;
		$row['sort']  = $st->getTimestamp();
		$out[]        = $row;
	}
	return $out;
}

/** Serialize events for the transient (DateTimeImmutable -> timestamps). */
function bw_cal_dehydrate( $events ) {
	$rows = array();
	foreach ( $events as $e ) {
		$rows[] = array(
			'uid'       => $e['uid'],
			'calendar'  => $e['calendar'],
			'title'     => $e['title'],
			'all_day'   => $e['all_day'],
			'desc_html' => $e['desc_html'],
			'location'  => $e['location'],
			'color'     => $e['color'],
			'start_ts'  => $e['start']->getTimestamp(),
			'end_ts'    => $e['end']->getTimestamp(),
		);
	}
	return $rows;
}

/** Rebuild events (timestamps -> DateTimeImmutable). */
function bw_cal_hydrate( $rows ) {
	$tz   = new DateTimeZone( bw_cal_tz() );
	$out  = array();
	foreach ( $rows as $r ) {
		$r['start'] = ( new DateTimeImmutable( '@' . $r['start_ts'] ) )->setTimezone( $tz );
		$r['end']   = ( new DateTimeImmutable( '@' . $r['end_ts'] ) )->setTimezone( $tz );
		$r['sort']  = $r['start_ts'];
		$out[]      = $r;
	}
	return $out;
}

/**
 * Parsed, normalized events for a single source (cached 30 min).
 *
 * @return array<int,array>
 */
function bw_cal_events_for_source( $key, $source, $window ) {
	$cache_key = 'bw_cal_parsed_' . md5( $source['gid'] );
	$cached    = get_transient( $cache_key );
	if ( false !== $cached ) {
		return bw_cal_hydrate( $cached );
	}

	$source['key'] = $key;
	$events        = array();
	foreach ( bw_cal_parse_ics( bw_cal_fetch( $source['gid'] ) ) as $props ) {
		foreach ( bw_cal_normalize( $props, $source, $window ) as $row ) {
			$events[] = $row;
		}
	}

	set_transient( $cache_key, bw_cal_dehydrate( $events ), 30 * MINUTE_IN_SECONDS );
	return $events;
}

/**
 * Aggregate, filter, sort, and paginate events across the selected calendars.
 *
 * @param array $args calendars[], start_date (Y-m-d), search, page, per_page.
 * @return array{events:array,total:int,page:int,per_page:int}
 */
function bw_cal_get_events( $args = array() ) {
	$args = wp_parse_args(
		$args,
		array(
			'calendars'  => array(),
			'start_date' => null,
			'search'     => '',
			'page'       => 1,
			'per_page'   => 10,
			'tz'         => '',
		)
	);

	$sources = bw_cal_sources();
	$keys    = ! empty( $args['calendars'] )
		? array_values( array_intersect( $args['calendars'], array_keys( $sources ) ) )
		: array_keys( array_filter( $sources, function ( $s ) {
			return ! empty( $s['default'] );
		} ) );

	$window = bw_cal_window();
	$tz     = bw_cal_zone( $args['tz'] );

	$start_date = $args['start_date'] ? DateTimeImmutable::createFromFormat( 'Y-m-d|', $args['start_date'], $tz ) : false;
	if ( ! $start_date ) {
		$start_date = new DateTimeImmutable( 'today', $tz );
	}
	$from_ts = $start_date->getTimestamp();

	$all = array();
	foreach ( $keys as $key ) {
		foreach ( bw_cal_events_for_source( $key, $sources[ $key ], $window ) as $event ) {
			// "Over" = ended at or before the start-of-day boundary. Using <=
			// drops multi-day events whose exclusive all-day DTEND lands exactly
			// on the selected date (matches the live site's event count).
			if ( $event['end']->getTimestamp() <= $from_ts ) {
				continue;
			}
			$all[] = $event;
		}
	}

	if ( '' !== $args['search'] ) {
		$needle = function_exists( 'mb_strtolower' ) ? mb_strtolower( $args['search'] ) : strtolower( $args['search'] );
		$all    = array_filter(
			$all,
			function ( $e ) use ( $needle ) {
				$hay = strtolower( $e['title'] . ' ' . wp_strip_all_tags( $e['desc_html'] ) . ' ' . $e['location'] );
				return false !== strpos( $hay, $needle );
			}
		);
	}

	// Dedupe (master-expanded instance vs RECURRENCE-ID override).
	$seen   = array();
	$unique = array();
	foreach ( $all as $e ) {
		$sig = $e['uid'] . '@' . $e['start']->format( 'YmdHi' );
		if ( isset( $seen[ $sig ] ) ) {
			continue;
		}
		$seen[ $sig ] = true;
		$unique[]     = $e;
	}

	usort(
		$unique,
		function ( $a, $b ) {
			return $a['sort'] <=> $b['sort'];
		}
	);

	$total    = count( $unique );
	$per_page = max( 1, min( 50, (int) $args['per_page'] ) );
	$page     = max( 1, (int) $args['page'] );
	$slice    = array_slice( $unique, ( $page - 1 ) * $per_page, $per_page );

	return array(
		'events'   => $slice,
		'total'    => $total,
		'page'     => $page,
		'per_page' => $per_page,
	);
}

/**
 * Compact "when" label for an event popover, e.g. "Jun 25, '26 6:00pm - 8:00pm"
 * or "Jun 25, '26 – Jun 27, '26" for an all-day multi-day event.
 *
 * @param array        $e  Event row.
 * @param DateTimeZone $tz Display timezone.
 * @return string
 */
function bw_cal_when_label( $e, $tz ) {
	$start   = $e['start']->setTimezone( $tz );
	$end     = $e['end']->setTimezone( $tz );
	$inc_end = $e['all_day'] ? $end->modify( '-1 day' ) : $end;
	$multi   = $start->format( 'Y-m-d' ) !== $inc_end->format( 'Y-m-d' );
	$sd      = $start->format( "M j, 'y" );

	if ( $e['all_day'] ) {
		return $multi ? ( $sd . ' – ' . $inc_end->format( "M j, 'y" ) ) : $sd;
	}

	$out = $sd . ' ' . $start->format( 'g:ia' );
	if ( $multi ) {
		$out .= ' – ' . $inc_end->format( "M j, 'y" ) . ' ' . $end->format( 'g:ia' );
	} else {
		$out .= ' - ' . $end->format( 'g:ia' );
	}
	return $out;
}

/**
 * Build the sidebar mini-calendar payload, mirroring the live brentwood.ca
 * v-calendar:
 *   - 'marks':  date => 'event'|'break', for colour-coding the day cells.
 *   - 'events': date => list of { title, when, desc, location, type }, for the
 *     popover shown when a marked day is clicked.
 *
 * Marking rules (unchanged):
 *   - Only events NOT yet over are marked — same "end <= start-of-day" boundary
 *     the event list uses (why live June 2026 shows only the 25th/26th).
 *   - Each event spans its start day through its RAW end day inclusive — the
 *     live mini does NOT subtract the exclusive all-day DTEND, so a single
 *     all-day event on the 25th (DTEND the 26th) lights up BOTH 25th and 26th.
 *   - Type is 'break' for titles mentioning "break"/"leave" (red), else 'event'
 *     (blue); 'event' wins where spans overlap (a single-day event shows blue
 *     on top of a multi-day leave backdrop, mirroring live).
 *
 * Cheap: the per-source events are transient-cached.
 *
 * @param array        $calendars  Selected calendar keys (empty = defaults).
 * @param DateTimeZone $tz         Display timezone.
 * @param string|null  $start_date Y-m-d lower bound (default: today).
 * @return array{marks:array<string,string>,events:array<string,array>}
 */
function bw_cal_mini_data( $calendars, $tz, $start_date = null ) {
	$sources = bw_cal_sources();
	$keys    = ! empty( $calendars )
		? array_values( array_intersect( $calendars, array_keys( $sources ) ) )
		: array_keys( array_filter( $sources, function ( $s ) {
			return ! empty( $s['default'] );
		} ) );

	$window = bw_cal_window();

	$lower = $start_date ? DateTimeImmutable::createFromFormat( 'Y-m-d|', $start_date, $tz ) : false;
	if ( ! $lower ) {
		$lower = new DateTimeImmutable( 'today', $tz );
	}
	$from_ts = $lower->getTimestamp();

	$marks  = array();
	$events = array();
	$seen   = array();

	foreach ( $keys as $key ) {
		foreach ( bw_cal_events_for_source( $key, $sources[ $key ], $window ) as $e ) {
			if ( $e['end']->getTimestamp() <= $from_ts ) {
				continue; // already over — not in the upcoming set the live mini marks.
			}

			$start = $e['start']->setTimezone( $tz );
			$end   = $e['end']->setTimezone( $tz ); // RAW end (no all-day -1), to mirror live.

			$lc   = strtolower( $e['title'] );
			$type = ( false !== strpos( $lc, 'break' ) || false !== strpos( $lc, 'leave' ) ) ? 'break' : 'event';

			$detail = array(
				'title'    => $e['title'],
				'when'     => bw_cal_when_label( $e, $tz ),
				'desc'     => wp_kses_post( (string) $e['desc_html'] ),
				'location' => (string) $e['location'],
				'type'     => $type,
			);
			$sig = $e['uid'] . '@' . $start->format( 'YmdHi' );

			$cursor = $start->setTime( 0, 0, 0 );
			$last   = $end->setTime( 0, 0, 0 );
			$guard  = 0;
			while ( $cursor->getTimestamp() <= $last->getTimestamp() && $guard < 400 ) {
				$d = $cursor->format( 'Y-m-d' );
				if ( ! isset( $marks[ $d ] ) || 'event' === $type ) {
					// 'event' (blue) wins over 'break' (red) where spans overlap, to
					// mirror live: a multi-day leave is the backdrop, and a specific
					// single-day event sitting on the same day shows blue on top.
					// Order-independent: 'event' always overwrites 'break', never vice versa.
					$marks[ $d ] = $type;
				}
				if ( ! isset( $seen[ $d ][ $sig ] ) ) {
					$seen[ $d ][ $sig ] = true;
					$events[ $d ][]     = $detail;
				}
				$cursor = $cursor->modify( '+1 day' );
				$guard++;
			}
		}
	}

	return array(
		'marks'  => $marks,
		'events' => $events,
	);
}

/**
 * Inline monochrome SVG icon (currentColor), matching the live FontAwesome look
 * without pulling in the FA dependency.
 *
 * @param string $name  Icon id.
 * @param string $class Extra class.
 * @return string
 */
function bw_cal_icon( $name, $class = '' ) {
	$paths = array(
		'calendar'     => '<rect x="3" y="4.5" width="18" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M3 9h18M8 2.5v4M16 2.5v4" fill="none" stroke="currentColor" stroke-width="1.8"/>',
		'search'       => '<circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M16.5 16.5 21 21" fill="none" stroke="currentColor" stroke-width="1.8"/>',
		'arrow'        => '<path d="M3 12h16m-6-6 6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.8"/>',
		'info'         => '<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M12 11v5" fill="none" stroke="currentColor" stroke-width="1.8"/><circle cx="12" cy="7.8" r="1.1" fill="currentColor"/>',
		'check-circle' => '<circle cx="12" cy="12" r="9.5" fill="currentColor"/><path d="M7.5 12.2l3 3 6-6.4" fill="none" stroke="#fff" stroke-width="2"/>',
		'circle'       => '<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.8"/>',
		'star'         => '<path d="M12 2.5l2.9 6.1 6.6.6-5 4.4 1.5 6.5L12 17.2 6 20.6l1.5-6.5-5-4.4 6.6-.6z" fill="currentColor"/>',
		'plane'        => '<path d="M2 18.5h20V20H2zM20.9 11.7c.25-.95-.32-1.93-1.27-2.18l-5.2-1.4-3.4-5.6-1.5-.4 1.7 5.07-3.9-1.05-1.4-1.9-1.1.3 1.55 4.2 12.7 3.4c.95.26 1.93-.32 2.18-1.27z" fill="currentColor"/>',
	);
	if ( ! isset( $paths[ $name ] ) ) {
		return '';
	}
	return '<svg class="bw-cal__svg ' . esc_attr( $class ) . '" viewBox="0 0 24 24" width="1em" height="1em" aria-hidden="true" focusable="false" stroke-linecap="round" stroke-linejoin="round">' . $paths[ $name ] . '</svg>';
}

/**
 * Truncate HTML description to ~26 words of plain text (matches live). Returns
 * [short_plain_or_null, full_html] — short is null when no truncation is needed.
 *
 * @param string $html
 * @return array{0:?string,1:string}
 */
function bw_cal_truncate_desc( $html ) {
	$full  = trim( wp_kses_post( $html ) );
	$plain = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( $full ) ) );
	if ( '' === $plain ) {
		return array( null, '' );
	}
	$words = explode( ' ', $plain );
	if ( count( $words ) > 26 ) {
		return array( implode( ' ', array_slice( $words, 0, 26 ) ) . '...', $full );
	}
	return array( null, $full );
}

/**
 * Build the date-range line for one event (mirrors the live calendar-date-range).
 * Returns [date_range_html, year_tag]. All-day DTEND is exclusive, so the shown
 * inclusive end is one day earlier.
 *
 * @param array        $e  Event row.
 * @param DateTimeZone $tz Display timezone.
 * @return array{0:string,1:string}
 */
function bw_cal_event_dates( $e, $tz ) {
	$start = $e['start']->setTimezone( $tz );
	$end   = $e['end']->setTimezone( $tz );

	$inclusive_end = $e['all_day'] ? $end->modify( '-1 day' ) : $end;
	$multi_day     = $start->format( 'Y-m-d' ) !== $inclusive_end->format( 'Y-m-d' );

	$out  = '<span class="bw-cal__dr-start">' . esc_html( $start->format( 'l, F j' ) );
	if ( ! $e['all_day'] ) {
		$out .= ', ' . esc_html( $start->format( 'g:i a' ) );
	}
	$out .= '</span>';

	$show_end = ! $e['all_day'] || $multi_day;
	if ( $show_end ) {
		$out .= ' <span class="bw-cal__arrow">' . bw_cal_icon( 'arrow' ) . '</span> ';
		$out .= '<span class="bw-cal__dr-end">';
		if ( $multi_day ) {
			$out .= esc_html( $inclusive_end->format( 'l, F j' ) );
			if ( ! $e['all_day'] ) {
				$out .= ', ' . esc_html( $end->format( 'g:i a' ) );
			}
		} else {
			$out .= esc_html( $end->format( 'g:i a' ) );
		}
		$out .= '</span>';
	}

	$start_y = $start->format( 'y' );
	$end_y   = $inclusive_end->format( 'y' );
	$year    = ( $end_y !== $start_y ) ? "'" . $start_y . '-' . $end_y : "'" . $start_y;

	return array( $out, $year );
}

/**
 * Render the upcoming-events list to an HTML string. Shared by render.php (first
 * paint) and the REST endpoint (live updates) so markup is always identical.
 * One bordered block per event — date range, faded year tag, dashed title rule,
 * break/school-year icons, and a 26-word "Read more" clamp — mirroring the live
 * brentwood.ca/calendar list layout.
 *
 * @param array        $events  Event rows from bw_cal_get_events().
 * @param DateTimeZone $tz      Display timezone.
 * @return string
 */
function bw_cal_render_list( $events, $tz ) {
	if ( empty( $events ) ) {
		return '<p class="bw-cal__empty">No upcoming events.</p>';
	}

	ob_start();
	foreach ( $events as $e ) {
		list( $date_html, $year ) = bw_cal_event_dates( $e, $tz );

		$title_lc = strtolower( $e['title'] );
		$is_break = ( false !== strpos( $title_lc, 'break' ) || false !== strpos( $title_lc, 'leave' ) );
		$is_year  = ( false !== strpos( $title_lc, 'school year' ) );

		$classes = 'bw-cal__event';
		if ( $is_year ) {
			$classes .= ' bw-cal__event--bookend';
		}

		printf( '<article class="%s" data-cal="%s">', esc_attr( $classes ), esc_attr( $e['calendar'] ) );

		echo '<span class="bw-cal__year">' . esc_html( $year ) . '</span>';
		echo '<div class="bw-cal__dr">' . $date_html . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput

		$title_icon = $is_break ? bw_cal_icon( 'plane' ) : ( $is_year ? bw_cal_icon( 'star' ) : '' );
		$title_cls  = ( $is_break || $is_year ) ? ' is-marked' : '';
		echo '<div class="bw-cal__title-row">';
		if ( $title_icon ) {
			echo '<span class="bw-cal__title-icon">' . $title_icon . '</span> '; // phpcs:ignore WordPress.Security.EscapeOutput
		}
		echo '<span class="bw-cal__title' . esc_attr( $title_cls ) . '">' . esc_html( $e['title'] ) . '</span>';
		echo '</div>';

		// Note: the live calendar list does not render LOCATION; only date, title,
		// and description. Omitted here to match brentwood.ca/calendar exactly.

		list( $short, $full ) = bw_cal_truncate_desc( $e['desc_html'] );
		if ( '' !== $full ) {
			echo '<div class="bw-cal__desc">';
			echo '<span class="bw-cal__info">' . bw_cal_icon( 'info' ) . '</span>'; // phpcs:ignore WordPress.Security.EscapeOutput
			echo '<div class="bw-cal__desc-body">';
			if ( null !== $short ) {
				echo '<div class="bw-cal__desc-short"><p>' . esc_html( $short ) . '</p><button type="button" class="bw-cal__readmore">Read More &gt;</button></div>';
				echo '<div class="bw-cal__desc-full" hidden>' . $full . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput
			} else {
				echo $full; // phpcs:ignore WordPress.Security.EscapeOutput
			}
			echo '</div></div>';
		}

		echo '</article>';
	}

	return ob_get_clean();
}

/**
 * Run a query and render it. Returns the list HTML plus pagination meta.
 *
 * @param array $args Same as bw_cal_get_events().
 * @return array{html:string,total:int,page:int,per_page:int,pages:int}
 */
function bw_cal_render_response( $args ) {
	$result = bw_cal_get_events( $args );
	$tz     = bw_cal_zone( isset( $args['tz'] ) ? $args['tz'] : '' );
	$mini   = bw_cal_mini_data( isset( $args['calendars'] ) ? $args['calendars'] : array(), $tz, isset( $args['start_date'] ) ? $args['start_date'] : null );

	return array(
		'html'        => bw_cal_render_list( $result['events'], $tz ),
		'total'       => $result['total'],
		'page'        => $result['page'],
		'per_page'    => $result['per_page'],
		'pages'       => (int) ceil( $result['total'] / max( 1, $result['per_page'] ) ),
		'marks'       => $mini['marks'],
		'mini_events' => $mini['events'],
	);
}

/**
 * REST endpoint: GET /wp-json/bw/v1/calendar-events
 * Public, read-only, public data. Heavy lifting is transient-cached, so repeated
 * calls don't hammer Google. Returns rendered list HTML + pagination meta.
 */
add_action(
	'rest_api_init',
	function () {
		register_rest_route(
			'bw/v1',
			'/calendar-events',
			array(
				'methods'             => 'GET',
				'permission_callback' => '__return_true',
				'callback'            => 'bw_cal_rest_events',
				'args'                => array(
					'calendars'  => array( 'sanitize_callback' => 'sanitize_text_field' ),
					'start_date' => array( 'sanitize_callback' => 'sanitize_text_field' ),
					'search'     => array( 'sanitize_callback' => 'sanitize_text_field' ),
					'tz'         => array( 'sanitize_callback' => 'sanitize_text_field' ),
					'page'       => array( 'sanitize_callback' => 'absint' ),
					'per_page'   => array( 'sanitize_callback' => 'absint' ),
				),
			)
		);
	}
);

/**
 * REST callback. Validates/normalizes params and returns the rendered response.
 *
 * @param WP_REST_Request $req Request.
 * @return WP_REST_Response
 */
function bw_cal_rest_events( $req ) {
	$cals    = (string) $req->get_param( 'calendars' );
	$cal_arr = $cals ? array_filter( array_map( 'trim', explode( ',', $cals ) ) ) : array();

	$start = (string) $req->get_param( 'start_date' );
	if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $start ) ) {
		$start = null;
	}

	$args = array(
		'calendars'  => $cal_arr,
		'start_date' => $start,
		'search'     => (string) $req->get_param( 'search' ),
		'tz'         => (string) $req->get_param( 'tz' ),
		'page'       => max( 1, (int) $req->get_param( 'page' ) ),
		'per_page'   => $req->get_param( 'per_page' ) ? (int) $req->get_param( 'per_page' ) : 10,
	);

	return rest_ensure_response( bw_cal_render_response( $args ) );
}
