summaryrefslogblamecommitdiffstats
path: root/modules-available/locationinfo/inc/icalparser.inc.php
blob: 54869c2e82a1fbb7c8469dcff63eee5001f6db3c (plain) (tree)
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
























                                                                                           








































































































































































































































































































































































































































































                                                                                                                             





                                                             










                                                                                                          
                                                                                                                                                                                               










                                                                                                                                                                                             
                                              































































                                                                                                                       
                                       








                                                
                                                   









                                                                 
                                    


                               
                                             





                                                                                         
                                                        









































































































































                                                                                                                                 
                                                                                         
                                                            
                                                             













                                                                                                            
                                                                          
         

                                                                                                                        









                                                                            
                                                                                                         






                                                                                


                                                               

                                        

                       
                                                                                                          
         











                                                                                              
                                                                                               




















                                                                                                  
                                                                                        


















                                                                                                                                                    
                                                                                                                   












































                                                                                                         
                          






                                                      
                         
           
                                                                   



































                                                                                                      
                                    

                 
                                                                          


                                                                                                            
                                                                                            








































                                                                                                                                                               
                 
                                                 







                                                                    
                                                                      























































                                                                                                     
                                                                      











                                                                                         
                                                                                                                



































                                                                                                 
































                                                                                                                         

                                                                                                                           
 
                                      





                                                                                                                                                       
                                                                                                                   


                                                                                                                                         























































































































































































































































































































































































                                                                                                                                                                     
                                                                                                            


























































                                                                                                       
                                                                                                    


























































                                                                                                              
                                       
















                                                                     
                                              
         
                                                                     






                                           
                                                     
         
                                                                     







                                         
                                                                          

























                                                                                                    
                                               


                                    
                                                      
























                                                                                    
                                                                                                  







































































                                                                                                                                                       
                                                                                            



















                                                                         
                                                                    











                                                                                
                                                                        






























                                                                                                                            
                                                                     









                                                                                                
                                                                        






                                                                                                   

                                   
                                                                                                                   

























                                                             
                                                                       
         
                                                             









                                                                               
                                                                         














                                                               
                                                         


                                                    
                 
                                                  









                                                                 
                                                                           






                                                                                       
                                                                                                                                            

















                                                                                          
                                                        

















                                                                                           
                                                     

                               
                                                                                   
         

                                                            




















                                                                                             
<?php

/*
 * Modified for slx-admin to support streaming, some functions removed that are not needed,
 * Carbon removed, Honor window size when calculating recurring events, ...
 */

/**
 * This PHP class will read an ICS (`.ics`, `.ical`, `.ifb`) file, parse it and return an
 * array of its contents.
 *
 * PHP 5 (≥ 5.3.9)
 *
 * @author  Jonathan Goode <https://github.com/u01jmg3>
 * @license https://opensource.org/licenses/mit-license.php MIT License
 * @version 2.1.20
 */
class ICalParser
{
	// phpcs:disable Generic.Arrays.DisallowLongArraySyntax

	const DATE_TIME_FORMAT = 'Ymd\THis';
	const ICAL_DATE_TIME_TEMPLATE = 'TZID=%s:';
	const ISO_8601_WEEK_START = 'MO';
	const RECURRENCE_EVENT = 'Generated recurrence event';
	const TIME_ZONE_UTC = 'UTC';
	const UNIX_FORMAT = 'U';

	/**
	 * Tracks the number of alarms in the current iCal feed
	 *
	 * @var integer
	 */
	public $alarmCount = 0;

	/**
	 * Tracks the number of events in the current iCal feed
	 *
	 * @var integer
	 */
	public $eventCount = 0;

	/**
	 * Tracks the free/busy count in the current iCal feed
	 *
	 * @var integer
	 */
	public $freeBusyCount = 0;

	/**
	 * Tracks the number of todos in the current iCal feed
	 *
	 * @var integer
	 */
	public $todoCount = 0;

	/**
	 * The value in years to use for indefinite, recurring events
	 *
	 * @var integer
	 */
	public $defaultSpan = 2;

	/**
	 * Enables customisation of the default time zone
	 *
	 * @var string
	 */
	public $defaultTimeZone;

	/**
	 * The two letter representation of the first day of the week
	 *
	 * @var string
	 */
	public $defaultWeekStart = self::ISO_8601_WEEK_START;

	/**
	 * Toggles whether to skip the parsing of recurrence rules
	 *
	 * @var boolean
	 */
	public $skipRecurrence = false;

	/**
	 * With this being non-null the parser will ignore all events more than roughly this many days after now.
	 *
	 * @var integer
	 */
	public $filterDaysBefore;

	/**
	 * With this being non-null the parser will ignore all events more than roughly this many days before now.
	 *
	 * @var integer
	 */
	public $filterDaysAfter;

	/**
	 * @var string Which object type we're currently handling while parsing.
	 */
	private $parseStateComponent = '';

	/**
	 * @var string Current line being read (in case of continuation).
	 */
	private $currentLineBuffer = '';

	/**
	 * @var string Chunk of data currently being handled - might stop mid-line.
	 */
	private $feedBuffer = '';

	/**
	 * @var bool whether we ever saw a BEGIN:VCALENDAR in the data
	 */
	private $hasSeenStart = false;

	/**
	 * The parsed calendar
	 *
	 * @var array
	 */
	public $cal = array();

	/**
	 * Tracks the VFREEBUSY component
	 *
	 * @var integer
	 */
	protected $freeBusyIndex = 0;

	/**
	 * Variable to track the previous keyword
	 *
	 * @var string
	 */
	protected $lastKeyword;

	/**
	 * Cache valid IANA time zone IDs to avoid unnecessary lookups
	 *
	 * @var array
	 */
	protected $validIanaTimeZones = array();

	/**
	 * Event recurrence instances that have been altered
	 *
	 * @var array
	 */
	protected $alteredRecurrenceInstances = array();

	/**
	 * An associative array containing weekday conversion data
	 *
	 * The order of the days in the array follow the ISO-8601 specification of a week.
	 *
	 * @var array
	 */
	protected $weekdays = array(
		'MO' => 'monday',
		'TU' => 'tuesday',
		'WE' => 'wednesday',
		'TH' => 'thursday',
		'FR' => 'friday',
		'SA' => 'saturday',
		'SU' => 'sunday',
	);

	/**
	 * An associative array containing frequency conversion terms
	 *
	 * @var array
	 */
	protected $frequencyConversion = array(
		'DAILY' => 'day',
		'WEEKLY' => 'week',
		'MONTHLY' => 'month',
		'YEARLY' => 'year',
	);

	/**
	 * Define which variables can be configured
	 *
	 * @var array
	 */
	private static $configurableOptions = array(
		'defaultSpan',
		'defaultTimeZone',
		'defaultWeekStart',
		'filterDaysAfter',
		'filterDaysBefore',
		'skipRecurrence',
	);

	/**
	 * CLDR time zones mapped to IANA time zones.
	 *
	 * @var array
	 */
	private static $cldrTimeZonesMap = array(
		'(UTC-12:00) International Date Line West' => 'Etc/GMT+12',
		'(UTC-11:00) Coordinated Universal Time-11' => 'Etc/GMT+11',
		'(UTC-10:00) Hawaii' => 'Pacific/Honolulu',
		'(UTC-09:00) Alaska' => 'America/Anchorage',
		'(UTC-08:00) Pacific Time (US & Canada)' => 'America/Los_Angeles',
		'(UTC-07:00) Arizona' => 'America/Phoenix',
		'(UTC-07:00) Chihuahua, La Paz, Mazatlan' => 'America/Chihuahua',
		'(UTC-07:00) Mountain Time (US & Canada)' => 'America/Denver',
		'(UTC-06:00) Central America' => 'America/Guatemala',
		'(UTC-06:00) Central Time (US & Canada)' => 'America/Chicago',
		'(UTC-06:00) Guadalajara, Mexico City, Monterrey' => 'America/Mexico_City',
		'(UTC-06:00) Saskatchewan' => 'America/Regina',
		'(UTC-05:00) Bogota, Lima, Quito, Rio Branco' => 'America/Bogota',
		'(UTC-05:00) Chetumal' => 'America/Cancun',
		'(UTC-05:00) Eastern Time (US & Canada)' => 'America/New_York',
		'(UTC-05:00) Indiana (East)' => 'America/Indianapolis',
		'(UTC-04:00) Asuncion' => 'America/Asuncion',
		'(UTC-04:00) Atlantic Time (Canada)' => 'America/Halifax',
		'(UTC-04:00) Caracas' => 'America/Caracas',
		'(UTC-04:00) Cuiaba' => 'America/Cuiaba',
		'(UTC-04:00) Georgetown, La Paz, Manaus, San Juan' => 'America/La_Paz',
		'(UTC-04:00) Santiago' => 'America/Santiago',
		'(UTC-03:30) Newfoundland' => 'America/St_Johns',
		'(UTC-03:00) Brasilia' => 'America/Sao_Paulo',
		'(UTC-03:00) Cayenne, Fortaleza' => 'America/Cayenne',
		'(UTC-03:00) City of Buenos Aires' => 'America/Buenos_Aires',
		'(UTC-03:00) Greenland' => 'America/Godthab',
		'(UTC-03:00) Montevideo' => 'America/Montevideo',
		'(UTC-03:00) Salvador' => 'America/Bahia',
		'(UTC-02:00) Coordinated Universal Time-02' => 'Etc/GMT+2',
		'(UTC-01:00) Azores' => 'Atlantic/Azores',
		'(UTC-01:00) Cabo Verde Is.' => 'Atlantic/Cape_Verde',
		'(UTC) Coordinated Universal Time' => 'Etc/GMT',
		'(UTC+00:00) Casablanca' => 'Africa/Casablanca',
		'(UTC+00:00) Dublin, Edinburgh, Lisbon, London' => 'Europe/London',
		'(UTC+00:00) Monrovia, Reykjavik' => 'Atlantic/Reykjavik',
		'(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna' => 'Europe/Berlin',
		'(UTC+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague' => 'Europe/Budapest',
		'(UTC+01:00) Brussels, Copenhagen, Madrid, Paris' => 'Europe/Paris',
		'(UTC+01:00) Sarajevo, Skopje, Warsaw, Zagreb' => 'Europe/Warsaw',
		'(UTC+01:00) West Central Africa' => 'Africa/Lagos',
		'(UTC+02:00) Amman' => 'Asia/Amman',
		'(UTC+02:00) Athens, Bucharest' => 'Europe/Bucharest',
		'(UTC+02:00) Beirut' => 'Asia/Beirut',
		'(UTC+02:00) Cairo' => 'Africa/Cairo',
		'(UTC+02:00) Chisinau' => 'Europe/Chisinau',
		'(UTC+02:00) Damascus' => 'Asia/Damascus',
		'(UTC+02:00) Harare, Pretoria' => 'Africa/Johannesburg',
		'(UTC+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius' => 'Europe/Kiev',
		'(UTC+02:00) Jerusalem' => 'Asia/Jerusalem',
		'(UTC+02:00) Kaliningrad' => 'Europe/Kaliningrad',
		'(UTC+02:00) Tripoli' => 'Africa/Tripoli',
		'(UTC+02:00) Windhoek' => 'Africa/Windhoek',
		'(UTC+03:00) Baghdad' => 'Asia/Baghdad',
		'(UTC+03:00) Istanbul' => 'Europe/Istanbul',
		'(UTC+03:00) Kuwait, Riyadh' => 'Asia/Riyadh',
		'(UTC+03:00) Minsk' => 'Europe/Minsk',
		'(UTC+03:00) Moscow, St. Petersburg, Volgograd' => 'Europe/Moscow',
		'(UTC+03:00) Nairobi' => 'Africa/Nairobi',
		'(UTC+03:30) Tehran' => 'Asia/Tehran',
		'(UTC+04:00) Abu Dhabi, Muscat' => 'Asia/Dubai',
		'(UTC+04:00) Baku' => 'Asia/Baku',
		'(UTC+04:00) Izhevsk, Samara' => 'Europe/Samara',
		'(UTC+04:00) Port Louis' => 'Indian/Mauritius',
		'(UTC+04:00) Tbilisi' => 'Asia/Tbilisi',
		'(UTC+04:00) Yerevan' => 'Asia/Yerevan',
		'(UTC+04:30) Kabul' => 'Asia/Kabul',
		'(UTC+05:00) Ashgabat, Tashkent' => 'Asia/Tashkent',
		'(UTC+05:00) Ekaterinburg' => 'Asia/Yekaterinburg',
		'(UTC+05:00) Islamabad, Karachi' => 'Asia/Karachi',
		'(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi' => 'Asia/Calcutta',
		'(UTC+05:30) Sri Jayawardenepura' => 'Asia/Colombo',
		'(UTC+05:45) Kathmandu' => 'Asia/Katmandu',
		'(UTC+06:00) Astana' => 'Asia/Almaty',
		'(UTC+06:00) Dhaka' => 'Asia/Dhaka',
		'(UTC+06:30) Yangon (Rangoon)' => 'Asia/Rangoon',
		'(UTC+07:00) Bangkok, Hanoi, Jakarta' => 'Asia/Bangkok',
		'(UTC+07:00) Krasnoyarsk' => 'Asia/Krasnoyarsk',
		'(UTC+07:00) Novosibirsk' => 'Asia/Novosibirsk',
		'(UTC+08:00) Beijing, Chongqing, Hong Kong, Urumqi' => 'Asia/Shanghai',
		'(UTC+08:00) Irkutsk' => 'Asia/Irkutsk',
		'(UTC+08:00) Kuala Lumpur, Singapore' => 'Asia/Singapore',
		'(UTC+08:00) Perth' => 'Australia/Perth',
		'(UTC+08:00) Taipei' => 'Asia/Taipei',
		'(UTC+08:00) Ulaanbaatar' => 'Asia/Ulaanbaatar',
		'(UTC+09:00) Osaka, Sapporo, Tokyo' => 'Asia/Tokyo',
		'(UTC+09:00) Pyongyang' => 'Asia/Pyongyang',
		'(UTC+09:00) Seoul' => 'Asia/Seoul',
		'(UTC+09:00) Yakutsk' => 'Asia/Yakutsk',
		'(UTC+09:30) Adelaide' => 'Australia/Adelaide',
		'(UTC+09:30) Darwin' => 'Australia/Darwin',
		'(UTC+10:00) Brisbane' => 'Australia/Brisbane',
		'(UTC+10:00) Canberra, Melbourne, Sydney' => 'Australia/Sydney',
		'(UTC+10:00) Guam, Port Moresby' => 'Pacific/Port_Moresby',
		'(UTC+10:00) Hobart' => 'Australia/Hobart',
		'(UTC+10:00) Vladivostok' => 'Asia/Vladivostok',
		'(UTC+11:00) Chokurdakh' => 'Asia/Srednekolymsk',
		'(UTC+11:00) Magadan' => 'Asia/Magadan',
		'(UTC+11:00) Solomon Is., New Caledonia' => 'Pacific/Guadalcanal',
		'(UTC+12:00) Anadyr, Petropavlovsk-Kamchatsky' => 'Asia/Kamchatka',
		'(UTC+12:00) Auckland, Wellington' => 'Pacific/Auckland',
		'(UTC+12:00) Coordinated Universal Time+12' => 'Etc/GMT-12',
		'(UTC+12:00) Fiji' => 'Pacific/Fiji',
		"(UTC+13:00) Nuku'alofa" => 'Pacific/Tongatapu',
		'(UTC+13:00) Samoa' => 'Pacific/Apia',
		'(UTC+14:00) Kiritimati Island' => 'Pacific/Kiritimati',
	);

	/**
	 * Maps Windows (non-CLDR) time zone ID to IANA ID. This is pragmatic but not 100% precise as one Windows zone ID
	 * maps to multiple IANA IDs (one for each territory). For all practical purposes this should be good enough, though.
	 *
	 * Source: http://unicode.org/repos/cldr/trunk/common/supplemental/windowsZones.xml
	 *
	 * @var array
	 */
	private static $windowsTimeZonesMap = array(
		'AUS Central Standard Time' => 'Australia/Darwin',
		'AUS Eastern Standard Time' => 'Australia/Sydney',
		'Afghanistan Standard Time' => 'Asia/Kabul',
		'Alaskan Standard Time' => 'America/Anchorage',
		'Aleutian Standard Time' => 'America/Adak',
		'Altai Standard Time' => 'Asia/Barnaul',
		'Arab Standard Time' => 'Asia/Riyadh',
		'Arabian Standard Time' => 'Asia/Dubai',
		'Arabic Standard Time' => 'Asia/Baghdad',
		'Argentina Standard Time' => 'America/Buenos_Aires',
		'Astrakhan Standard Time' => 'Europe/Astrakhan',
		'Atlantic Standard Time' => 'America/Halifax',
		'Aus Central W. Standard Time' => 'Australia/Eucla',
		'Azerbaijan Standard Time' => 'Asia/Baku',
		'Azores Standard Time' => 'Atlantic/Azores',
		'Bahia Standard Time' => 'America/Bahia',
		'Bangladesh Standard Time' => 'Asia/Dhaka',
		'Belarus Standard Time' => 'Europe/Minsk',
		'Bougainville Standard Time' => 'Pacific/Bougainville',
		'Canada Central Standard Time' => 'America/Regina',
		'Cape Verde Standard Time' => 'Atlantic/Cape_Verde',
		'Caucasus Standard Time' => 'Asia/Yerevan',
		'Cen. Australia Standard Time' => 'Australia/Adelaide',
		'Central America Standard Time' => 'America/Guatemala',
		'Central Asia Standard Time' => 'Asia/Almaty',
		'Central Brazilian Standard Time' => 'America/Cuiaba',
		'Central Europe Standard Time' => 'Europe/Budapest',
		'Central European Standard Time' => 'Europe/Warsaw',
		'Central Pacific Standard Time' => 'Pacific/Guadalcanal',
		'Central Standard Time (Mexico)' => 'America/Mexico_City',
		'Central Standard Time' => 'America/Chicago',
		'Chatham Islands Standard Time' => 'Pacific/Chatham',
		'China Standard Time' => 'Asia/Shanghai',
		'Cuba Standard Time' => 'America/Havana',
		'Dateline Standard Time' => 'Etc/GMT+12',
		'E. Africa Standard Time' => 'Africa/Nairobi',
		'E. Australia Standard Time' => 'Australia/Brisbane',
		'E. Europe Standard Time' => 'Europe/Chisinau',
		'E. South America Standard Time' => 'America/Sao_Paulo',
		'Easter Island Standard Time' => 'Pacific/Easter',
		'Eastern Standard Time (Mexico)' => 'America/Cancun',
		'Eastern Standard Time' => 'America/New_York',
		'Egypt Standard Time' => 'Africa/Cairo',
		'Ekaterinburg Standard Time' => 'Asia/Yekaterinburg',
		'FLE Standard Time' => 'Europe/Kiev',
		'Fiji Standard Time' => 'Pacific/Fiji',
		'GMT Standard Time' => 'Europe/London',
		'GTB Standard Time' => 'Europe/Bucharest',
		'Georgian Standard Time' => 'Asia/Tbilisi',
		'Greenland Standard Time' => 'America/Godthab',
		'Greenwich Standard Time' => 'Atlantic/Reykjavik',
		'Haiti Standard Time' => 'America/Port-au-Prince',
		'Hawaiian Standard Time' => 'Pacific/Honolulu',
		'India Standard Time' => 'Asia/Calcutta',
		'Iran Standard Time' => 'Asia/Tehran',
		'Israel Standard Time' => 'Asia/Jerusalem',
		'Jordan Standard Time' => 'Asia/Amman',
		'Kaliningrad Standard Time' => 'Europe/Kaliningrad',
		'Korea Standard Time' => 'Asia/Seoul',
		'Libya Standard Time' => 'Africa/Tripoli',
		'Line Islands Standard Time' => 'Pacific/Kiritimati',
		'Lord Howe Standard Time' => 'Australia/Lord_Howe',
		'Magadan Standard Time' => 'Asia/Magadan',
		'Magallanes Standard Time' => 'America/Punta_Arenas',
		'Marquesas Standard Time' => 'Pacific/Marquesas',
		'Mauritius Standard Time' => 'Indian/Mauritius',
		'Middle East Standard Time' => 'Asia/Beirut',
		'Montevideo Standard Time' => 'America/Montevideo',
		'Morocco Standard Time' => 'Africa/Casablanca',
		'Mountain Standard Time (Mexico)' => 'America/Chihuahua',
		'Mountain Standard Time' => 'America/Denver',
		'Myanmar Standard Time' => 'Asia/Rangoon',
		'N. Central Asia Standard Time' => 'Asia/Novosibirsk',
		'Namibia Standard Time' => 'Africa/Windhoek',
		'Nepal Standard Time' => 'Asia/Katmandu',
		'New Zealand Standard Time' => 'Pacific/Auckland',
		'Newfoundland Standard Time' => 'America/St_Johns',
		'Norfolk Standard Time' => 'Pacific/Norfolk',
		'North Asia East Standard Time' => 'Asia/Irkutsk',
		'North Asia Standard Time' => 'Asia/Krasnoyarsk',
		'North Korea Standard Time' => 'Asia/Pyongyang',
		'Omsk Standard Time' => 'Asia/Omsk',
		'Pacific SA Standard Time' => 'America/Santiago',
		'Pacific Standard Time (Mexico)' => 'America/Tijuana',
		'Pacific Standard Time' => 'America/Los_Angeles',
		'Pakistan Standard Time' => 'Asia/Karachi',
		'Paraguay Standard Time' => 'America/Asuncion',
		'Romance Standard Time' => 'Europe/Paris',
		'Russia Time Zone 10' => 'Asia/Srednekolymsk',
		'Russia Time Zone 11' => 'Asia/Kamchatka',
		'Russia Time Zone 3' => 'Europe/Samara',
		'Russian Standard Time' => 'Europe/Moscow',
		'SA Eastern Standard Time' => 'America/Cayenne',
		'SA Pacific Standard Time' => 'America/Bogota',
		'SA Western Standard Time' => 'America/La_Paz',
		'SE Asia Standard Time' => 'Asia/Bangkok',
		'Saint Pierre Standard Time' => 'America/Miquelon',
		'Sakhalin Standard Time' => 'Asia/Sakhalin',
		'Samoa Standard Time' => 'Pacific/Apia',
		'Sao Tome Standard Time' => 'Africa/Sao_Tome',
		'Saratov Standard Time' => 'Europe/Saratov',
		'Singapore Standard Time' => 'Asia/Singapore',
		'South Africa Standard Time' => 'Africa/Johannesburg',
		'Sri Lanka Standard Time' => 'Asia/Colombo',
		'Sudan Standard Time' => 'Africa/Tripoli',
		'Syria Standard Time' => 'Asia/Damascus',
		'Taipei Standard Time' => 'Asia/Taipei',
		'Tasmania Standard Time' => 'Australia/Hobart',
		'Tocantins Standard Time' => 'America/Araguaina',
		'Tokyo Standard Time' => 'Asia/Tokyo',
		'Tomsk Standard Time' => 'Asia/Tomsk',
		'Tonga Standard Time' => 'Pacific/Tongatapu',
		'Transbaikal Standard Time' => 'Asia/Chita',
		'Turkey Standard Time' => 'Europe/Istanbul',
		'Turks And Caicos Standard Time' => 'America/Grand_Turk',
		'US Eastern Standard Time' => 'America/Indianapolis',
		'US Mountain Standard Time' => 'America/Phoenix',
		'UTC' => 'Etc/GMT',
		'UTC+12' => 'Etc/GMT-12',
		'UTC+13' => 'Etc/GMT-13',
		'UTC-02' => 'Etc/GMT+2',
		'UTC-08' => 'Etc/GMT+8',
		'UTC-09' => 'Etc/GMT+9',
		'UTC-11' => 'Etc/GMT+11',
		'Ulaanbaatar Standard Time' => 'Asia/Ulaanbaatar',
		'Venezuela Standard Time' => 'America/Caracas',
		'Vladivostok Standard Time' => 'Asia/Vladivostok',
		'W. Australia Standard Time' => 'Australia/Perth',
		'W. Central Africa Standard Time' => 'Africa/Lagos',
		'W. Europe Standard Time' => 'Europe/Berlin',
		'W. Mongolia Standard Time' => 'Asia/Hovd',
		'West Asia Standard Time' => 'Asia/Tashkent',
		'West Bank Standard Time' => 'Asia/Hebron',
		'West Pacific Standard Time' => 'Pacific/Port_Moresby',
		'Yakutsk Standard Time' => 'Asia/Yakutsk',
	);

	/**
	 * If `$filterDaysBefore` or `$filterDaysAfter` are set then the events are filtered according to the window defined
	 * by this field and `$windowMaxTimestamp`.
	 *
	 * @var integer
	 */
	private $windowMinTimestamp;

	/**
	 * If `$filterDaysBefore` or `$filterDaysAfter` are set then the events are filtered according to the window defined
	 * by this field and `$windowMinTimestamp`.
	 *
	 * @var integer
	 */
	private $windowMaxTimestamp;

	/**
	 * `true` if either `$filterDaysBefore` or `$filterDaysAfter` are set.
	 *
	 * @var boolean
	 */
	private $shouldFilterByWindow;

	/**
	 * Creates the ICal object
	 *
	 * @param array $options
	 * @return void
	 * @throws Exception
	 */
	public function __construct(array $options = array())
	{
		foreach ($options as $option => $value) {
			if (in_array($option, self::$configurableOptions)) {
				$this->{$option} = $value;
			}
		}

		// Fallback to use the system default time zone
		if (!isset($this->defaultTimeZone) || !$this->isValidTimeZoneId($this->defaultTimeZone)) {
			$this->defaultTimeZone = date_default_timezone_get();
		}

		$this->windowMinTimestamp = is_null($this->filterDaysBefore) ? PHP_INT_MIN : (new DateTime('now'))->sub(new DateInterval('P' . $this->filterDaysBefore . 'D'))->getTimestamp();
		$this->windowMaxTimestamp = is_null($this->filterDaysAfter) ? PHP_INT_MAX : (new DateTime('now'))->add(new DateInterval('P' . $this->filterDaysAfter . 'D'))->getTimestamp();

		$this->shouldFilterByWindow = !is_null($this->filterDaysBefore) || !is_null($this->filterDaysAfter);
	}

	/**
	 * Feed more data to the parser. This can be a chunk of arbitrary length, it
	 * is not required to end on a line break.
	 *
	 * @param string $data
	 */
	public function feedData(string $data)
	{
		$this->feedBuffer .= $data;
		$start = 0;
		$bufferLen = strlen($this->feedBuffer);
		while (($newLine = strcspn($this->feedBuffer, "\r\n", $start) + $start) !== $bufferLen) {
			$length = $newLine - $start;
			if ($length > 1) {
				if ($this->feedBuffer[$start] === ' ' || $this->feedBuffer[$start] === '\t') {
					// Continuation of previous line
					$this->currentLineBuffer .= substr($this->feedBuffer, $start + 1, $length - 1);
				} else {
					// New line, flush previous one
					$this->handleLine($this->currentLineBuffer);
					$this->currentLineBuffer = substr($this->feedBuffer, $start, $length);
				}
			}
			$start = $newLine + 1;
		}
		$this->feedBuffer = substr($this->feedBuffer, $start);
	}

	/**
	 * Finish feeding more data to the parser, process the data.
	 */
	public function finish()
	{
		// Flush
		$this->feedData("\n*\n");
		$this->currentLineBuffer = '';
		$this->feedBuffer = '';
		$this->processEvents();

		if (!$this->skipRecurrence) {
			$this->processRecurrences();

			// Apply changes to altered recurrence instances
			if (!empty($this->alteredRecurrenceInstances)) {
				$events = $this->cal['VEVENT'];

				foreach ($this->alteredRecurrenceInstances as $alteredRecurrenceInstance) {
					if (isset($alteredRecurrenceInstance['altered-event'])) {
						$alteredEvent = $alteredRecurrenceInstance['altered-event'];
						$key = key($alteredEvent);
						$events[$key] = $alteredEvent[$key];
					}
				}

				$this->cal['VEVENT'] = $events;
			}
		}

		if ($this->shouldFilterByWindow) {
			$this->reduceEventsToMinMaxRange();
		}

		$this->processDateConversions();
	}

	/**
	 * True if this resembles a calendar, i.e. we've seen the
	 * BEGIN:VCALENDAR line at some point.
	 *
	 * @return bool
	 */
	public function isValid(): bool
	{
		return $this->hasSeenStart;
	}

	/**
	 * Process next completed line from file
	 *
	 * @param string $line
	 */
	protected function handleLine(string $line)
	{
		$line = rtrim($line); // Trim trailing whitespace
		$line = $this->removeUnprintableChars($line);

		if (empty($line)) {
			return;
		}

		$add = $this->keyValueFromString($line);

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

		$keyword = $add[0]; // string
		$values = $add[1]; // May be an array containing multiple values

		if (!is_array($values)) {
			if (!empty($values)) {
				$values = array($values); // Make an array as not already
				$blankArray = array(); // Empty placeholder array
				$values[] = $blankArray;
			} else {
				$values = array(); // Use blank array to ignore this line
			}
		} elseif (empty($values[0])) {
			$values = array(); // Use blank array to ignore this line
		}

		// Reverse so that our array of properties is processed first
		$values = array_reverse($values);

		foreach ($values as $value) {
			switch ($line) {
				// https://www.kanzaki.com/docs/ical/vtodo.html
			case 'BEGIN:VTODO':
				if (!is_array($value)) {
					$this->todoCount++;
				}

				$this->parseStateComponent = 'VTODO';

				break;

				// https://www.kanzaki.com/docs/ical/vevent.html
			case 'BEGIN:VEVENT':
				if (!is_array($value)) {
					$this->eventCount++;
				}

				$this->parseStateComponent = 'VEVENT';

				break;

				// https://www.kanzaki.com/docs/ical/vfreebusy.html
			case 'BEGIN:VFREEBUSY':
				if (!is_array($value)) {
					$this->freeBusyIndex++;
				}

				$this->parseStateComponent = 'VFREEBUSY';

				break;

			case 'BEGIN:VALARM':
				if (!is_array($value)) {
					$this->alarmCount++;
				}

				$this->parseStateComponent = 'VALARM';

				break;

			case 'END:VALARM':
				$this->parseStateComponent = 'VEVENT';

				break;

			case 'BEGIN:DAYLIGHT':
			case 'BEGIN:STANDARD':
			case 'BEGIN:VTIMEZONE':
				$this->parseStateComponent = $value;

				break;

			case 'END:DAYLIGHT':
			case 'END:STANDARD':
			case 'END:VFREEBUSY':
			case 'END:VTIMEZONE':
			case 'END:VTODO':
				$this->parseStateComponent = 'VCALENDAR';

				break;

			case 'BEGIN:VCALENDAR':
				$this->hasSeenStart = true;
				$this->parseStateComponent = $value;

				break;

			case 'END:VCALENDAR':
				$this->parseStateComponent = '';

				break;

			case 'END:VEVENT':
				if ($this->shouldFilterByWindow) {
					$this->removeLastEventIfOutsideWindowAndNonRecurring();
				}

				$this->parseStateComponent = 'VCALENDAR';

				break;

			default:
				if (!empty($this->parseStateComponent)) {
					$this->addCalendarComponentWithKeyAndValue($this->parseStateComponent, $keyword, $value);
				}
				break;
			}
		}
	}

	/**
	 * Removes the last event (i.e. most recently parsed) if its start date is outside the window spanned by
	 * `$windowMinTimestamp` / `$windowMaxTimestamp`.
	 *
	 * @return void
	 */
	protected function removeLastEventIfOutsideWindowAndNonRecurring()
	{
		$events = $this->cal['VEVENT'];

		if (!empty($events)) {
			$lastIndex = count($events) - 1;
			$lastEvent = $events[$lastIndex];

			if (empty($lastEvent['RRULE']) && $this->doesEventStartOutsideWindow($lastEvent)) {
				$this->eventCount--;

				unset($events[$lastIndex]);
			}

			$this->cal['VEVENT'] = $events;
		}
	}

	/**
	 * Reduces the number of events to the defined minimum and maximum range
	 *
	 * @return void
	 */
	protected function reduceEventsToMinMaxRange()
	{
		$events = (isset($this->cal['VEVENT'])) ? $this->cal['VEVENT'] : array();

		if (!empty($events)) {
			foreach ($events as $key => $anEvent) {
				if ($anEvent === null) {
					unset($events[$key]);
				} elseif ($this->doesEventStartOutsideWindow($anEvent)) {
					$this->eventCount--;
					unset($events[$key]);
				}
			}

			$this->cal['VEVENT'] = $events;
		}
	}

	/**
	 * Determines whether the event start date is outside `$windowMinTimestamp` / `$windowMaxTimestamp`.
	 * Returns `true` for invalid dates.
	 *
	 * @param array $event
	 * @return boolean
	 */
	protected function doesEventStartOutsideWindow(array $event): bool
	{
		return !isset($event['DTSTART']) || !$this->isValidDate($event['DTSTART'])
			|| $this->isOutOfRange($event['DTSTART'], $this->windowMinTimestamp, $this->windowMaxTimestamp);
	}

	/**
	 * Determines whether a valid iCalendar date is within a given range
	 *
	 * @param string $calendarDate
	 * @param integer $minTimestamp
	 * @param integer $maxTimestamp
	 * @return boolean
	 */
	protected function isOutOfRange(string $calendarDate, int $minTimestamp, int $maxTimestamp): bool
	{
		$timestamp = strtotime(explode('T', $calendarDate)[0]);

		return $timestamp < $minTimestamp || $timestamp > $maxTimestamp;
	}

	/**
	 * Add one key and value pair to the `$this->cal` array
	 *
	 * @param string $component
	 * @param string $keyword
	 * @param string|string[] $value
	 * @return void
	 */
	protected function addCalendarComponentWithKeyAndValue(string $component, string $keyword, $value)
	{
		switch ($component) {
		case 'VALARM':
			$key1 = 'VEVENT';
			$key2 = ($this->eventCount - 1);
			$key3 = $component;

			if (!isset($this->cal[$key1][$key2][$key3]["{$keyword}_array"])) {
				$this->cal[$key1][$key2][$key3]["{$keyword}_array"] = array();
			}

			if (is_array($value)) {
				// Add array of properties to the end
				$this->cal[$key1][$key2][$key3]["{$keyword}_array"][] = $value;
			} else {
				if (!isset($this->cal[$key1][$key2][$key3][$keyword])) {
					$this->cal[$key1][$key2][$key3][$keyword] = $value;
				}

				if ($this->cal[$key1][$key2][$key3][$keyword] !== $value) {
					$this->cal[$key1][$key2][$key3][$keyword] .= ',' . $value;
				}
			}
			break;

		case 'VEVENT':
			$key1 = $component;
			$key2 = ($this->eventCount - 1);

			if (!isset($this->cal[$key1][$key2]["{$keyword}_array"])) {
				$this->cal[$key1][$key2]["{$keyword}_array"] = array();
			}

			if (is_array($value)) {
				// Add array of properties to the end
				$this->cal[$key1][$key2]["{$keyword}_array"][] = $value;
			} else {
				if (!isset($this->cal[$key1][$key2][$keyword])) {
					$this->cal[$key1][$key2][$keyword] = $value;
				}

				if ($keyword === 'EXDATE') {
					if (trim($value) === $value) {
						$array = array_filter(explode(',', $value));
						$this->cal[$key1][$key2]["{$keyword}_array"][] = $array;
					} else {
						$value = explode(',', implode(',', $this->cal[$key1][$key2]["{$keyword}_array"][1]) . trim($value));
						$this->cal[$key1][$key2]["{$keyword}_array"][1] = $value;
					}
				} else {
					$this->cal[$key1][$key2]["{$keyword}_array"][] = $value;

					if ($keyword === 'DURATION') {
						try {
							$duration = new DateInterval($value);
							$this->cal[$key1][$key2]["{$keyword}_array"][] = $duration;
						} catch (Exception $e) {
							error_log('Ignoring invalid duration ' . $value);
						}
					}
				}

				if ($this->cal[$key1][$key2][$keyword] !== $value) {
					$this->cal[$key1][$key2][$keyword] .= ',' . $value;
				}
			}
			break;

		case 'VFREEBUSY':
			$key1 = $component;
			$key2 = ($this->freeBusyIndex - 1);
			$key3 = $keyword;

			if ($keyword === 'FREEBUSY') {
				if (is_array($value)) {
					$this->cal[$key1][$key2][$key3][][] = $value;
				} else {
					$this->freeBusyCount++;

					end($this->cal[$key1][$key2][$key3]);
					$key = key($this->cal[$key1][$key2][$key3]);

					$value = explode('/', $value);
					$this->cal[$key1][$key2][$key3][$key][] = $value;
				}
			} else {
				$this->cal[$key1][$key2][$key3][] = $value;
			}
			break;

		case 'VTODO':
			$this->cal[$component][$this->todoCount - 1][$keyword] = $value;

			break;

		default:
			$this->cal[$component][$keyword] = $value;

			break;
		}

		// Remove?
		$this->lastKeyword = $keyword;
	}

	/**
	 * Gets the key value pair from an iCal string
	 *
	 * @param string $text
	 * @return ?array
	 */
	protected function keyValueFromString(string $text): ?array
	{
		$text = htmlspecialchars($text, ENT_NOQUOTES, 'UTF-8');

		$colon = strpos($text, ':');
		$quote = strpos($text, '"');
		if ($colon === false) {
			$matches = array();
		} elseif ($quote === false || $colon < $quote) {
			list($before, $after) = explode(':', $text, 2);
			$matches = array($text, $before, $after);
		} else {
			list($before, $text) = explode('"', $text, 2);
			$text = '"' . $text;
			$matches = str_getcsv($text, ':');
			$combinedValue = '';

			foreach (array_keys($matches) as $key) {
				if ($key === 0) {
					if (!empty($before)) {
						$matches[$key] = $before . '"' . $matches[$key] . '"';
					}
				} else {
					if ($key > 1) {
						$combinedValue .= ':';
					}

					$combinedValue .= $matches[$key];
				}
			}

			$matches = array_slice($matches, 0, 2);
			$matches[1] = $combinedValue;
			array_unshift($matches, $before . $text);
		}

		if (count($matches) === 0) {
			return null;
		}

		if (preg_match('/^([A-Z-]+)(;[\w\W]*)?$/', $matches[1])) {
			$matches = array_splice($matches, 1, 2); // Remove first match and re-align ordering

			// Process properties
			if (preg_match('/([A-Z-]+);([\w\W]*)/', $matches[0], $properties)) {
				// Remove first match
				array_shift($properties);
				// Fix to ignore everything in keyword after a ; (e.g. Language, TZID, etc.)
				$matches[0] = $properties[0];
				array_shift($properties); // Repeat removing first match

				$formatted = array();
				foreach ($properties as $property) {
					// Match semicolon separator outside of quoted substrings
					preg_match_all('~[^' . PHP_EOL . '";]+(?:"[^"\\\]*(?:\\\.[^"\\\]*)*"[^' . PHP_EOL . '";]*)*~', $property, $attributes);
					// Remove multi-dimensional array and use the first key
					$attributes = (count($attributes) === 0) ? array($property) : reset($attributes);

					if (is_array($attributes)) {
						foreach ($attributes as $attribute) {
							// Match equals sign separator outside of quoted substrings
							preg_match_all(
								'~[^' . PHP_EOL . '"=]+(?:"[^"\\\]*(?:\\\.[^"\\\]*)*"[^' . PHP_EOL . '"=]*)*~',
								$attribute,
								$values
							);
							// Remove multi-dimensional array and use the first key
							$value = (count($values) === 0) ? null : reset($values);

							if (is_array($value) && isset($value[1])) {
								// Remove double quotes from beginning and end only
								$formatted[$value[0]] = trim($value[1], '"');
							}
						}
					}
				}

				// Assign the keyword property information
				$properties[0] = $formatted;

				// Add match to beginning of array
				array_unshift($properties, $matches[1]);
				$matches[1] = $properties;
			}

			return $matches;
		}
		return null; // Ignore this match
	}

	/**
	 * Returns a `DateTime` object from an iCal date time format
	 *
	 * @param string $icalDate
	 * @return DateTime
	 */
	public function iCalDateToDateTime(string $icalDate): DateTime
	{
		/**
		 * iCal times may be in 3 formats, (https://www.kanzaki.com/docs/ical/dateTime.html)
		 *
		 * UTC:      Has a trailing 'Z'
		 * Floating: No time zone reference specified, no trailing 'Z', use local time
		 * TZID:     Set time zone as specified
		 *
		 * Use DateTime class objects to get around limitations with `mktime` and `gmmktime`.
		 * Must have a local time zone set to process floating times.
		 */
		$pattern = '/^(?:TZID=)?([^:]*|".*")'; // [1]: Time zone
		$pattern .= ':?';                       //      Time zone delimiter
		$pattern .= '([0-9]{8})';               // [2]: YYYYMMDD
		$pattern .= 'T?';                       //      Time delimiter
		$pattern .= '(?(?<=T)([0-9]{6}))';      // [3]: HHMMSS (filled if delimiter present)
		$pattern .= '(Z?)/';                    // [4]: UTC flag

		preg_match($pattern, $icalDate, $date);

		if (empty($date)) {
			error_log('Invalid iCal date format: ' . $icalDate);
			return new Datetime('1970-08-08'); // Return something far in the past
		}

		// A Unix timestamp usually cannot represent a date prior to 1 Jan 1970.
		// PHP, on the other hand, uses negative numbers for that. Thus we don't
		// need to special case them.

		if ($date[4] === 'Z') {
			$dateTimeZone = new DateTimeZone(self::TIME_ZONE_UTC);
		} elseif (!empty($date[1])) {
			$dateTimeZone = $this->timeZoneStringToDateTimeZone($date[1]);
		} else {
			$dateTimeZone = new DateTimeZone($this->defaultTimeZone);
		}

		// The exclamation mark at the start of the format string indicates that if a
		// time portion is not included, the time in the returned DateTime should be
		// set to 00:00:00. Without it, the time would be set to the current system time.
		$dateFormat = '!Ymd';
		$dateBasic = $date[2];
		if (!empty($date[3])) {
			$dateBasic .= "T{$date[3]}";
			$dateFormat .= '\THis';
		}

		return DateTime::createFromFormat($dateFormat, $dateBasic, $dateTimeZone);
	}

	/**
	 * Returns a Unix timestamp from an iCal date time format
	 *
	 * @param string $icalDate
	 * @return integer
	 */
	public function iCalDateToUnixTimestamp(string $icalDate): int
	{
		return $this->iCalDateToDateTime($icalDate)->getTimestamp();
	}

	/**
	 * Returns a date adapted to the calendar time zone depending on the event `TZID`
	 *
	 * @param array $event
	 * @param string $key
	 * @param string $format
	 * @return string|boolean
	 */
	public function iCalDateWithTimeZone(array $event, string $key, string $format = self::DATE_TIME_FORMAT)
	{
		if (!isset($event["{$key}_array"]) || !isset($event[$key])) {
			return false;
		}

		$dateArray = $event["{$key}_array"];

		if ($key === 'DURATION') {
			$dateTime = $this->parseDuration($event['DTSTART'], $dateArray[2], null);
		} else {
			// When constructing from a Unix Timestamp, no time zone needs passing.
			$dateTime = new DateTime("@{$dateArray[2]}");
		}

		// Set the time zone we wish to use when running `$dateTime->format`.
		$dateTime->setTimezone(new DateTimeZone($this->calendarTimeZone()));

		if (is_null($format)) {
			return $dateTime;
		}

		return $dateTime->format($format);
	}

	/**
	 * Performs admin tasks on all events as read from the iCal file.
	 * Adds a Unix timestamp to all `{DTSTART|DTEND|RECURRENCE-ID}_array` arrays
	 * Tracks modified recurrence instances
	 *
	 * @return void
	 */
	protected function processEvents()
	{
		if (empty($this->cal['VEVENT']))
			return;
		$events =& $this->cal['VEVENT'];

		foreach ($events as $key => $anEvent) {
			foreach (array('DTSTART', 'DTEND', 'RECURRENCE-ID') as $type) {
				if (isset($anEvent[$type])) {
					$date = $anEvent["{$type}_array"][1];

					if (isset($anEvent["{$type}_array"][0]['TZID'])) {
						$timeZone = $this->escapeParamText($anEvent["{$type}_array"][0]['TZID']);
						$date = sprintf(self::ICAL_DATE_TIME_TEMPLATE, $timeZone) . $date;
					}

					$anEvent["{$type}_array"][2] = $this->iCalDateToUnixTimestamp($date);
					$anEvent["{$type}_array"][3] = $date;
				}
			}

			if (isset($anEvent['RECURRENCE-ID'])) {
				$uid = $anEvent['UID'];

				if (!isset($this->alteredRecurrenceInstances[$uid])) {
					$this->alteredRecurrenceInstances[$uid] = array();
				}

				$recurrenceDateUtc = $this->iCalDateToUnixTimestamp($anEvent['RECURRENCE-ID_array'][3]);
				$this->alteredRecurrenceInstances[$uid][$key] = $recurrenceDateUtc;
			}

			$events[$key] = $anEvent;
		}

		$eventKeysToRemove = array();

		foreach ($events as $key => $event) {
			$checks = !isset($event['RECURRENCE-ID'])
					&& isset($event['UID']) && isset($this->alteredRecurrenceInstances[$event['UID']]);

			if ($checks) {
				$eventDtstartUnix = $this->iCalDateToUnixTimestamp($event['DTSTART_array'][3]);

				// phpcs:ignore CustomPHPCS.ControlStructures.AssignmentInCondition
				if (($alteredEventKey = array_search($eventDtstartUnix, $this->alteredRecurrenceInstances[$event['UID']])) !== false) {
					$eventKeysToRemove[] = $alteredEventKey;

					$alteredEvent = array_replace_recursive($event, $events[$alteredEventKey]);
					$this->alteredRecurrenceInstances[$event['UID']]['altered-event'] = array($key => $alteredEvent);
				}
			}
		}

		foreach ($eventKeysToRemove as $eventKeyToRemove) {
			$events[$eventKeyToRemove] = null;
		}
	}

	/**
	 * Processes recurrence rules
	 *
	 * @return void
	 */
	protected function processRecurrences()
	{
		// If there are no events, then we have nothing to process.
		if (empty($this->cal['VEVENT']))
			return;
		$events =& $this->cal['VEVENT'];

		$allEventRecurrences = array();
		$eventKeysToRemove = array();

		foreach ($events as $key => $anEvent) {
			if (!isset($anEvent['RRULE']) || $anEvent['RRULE'] === '') {
				continue;
			}

			// Tag as generated by a recurrence rule
			$anEvent['RRULE_array'][2] = self::RECURRENCE_EVENT;

			// Create new initial starting point.
			$initialEventDate = $this->icalDateToDateTime($anEvent['DTSTART_array'][3]);

			// Separate the RRULE stanzas, and explode the values that are lists.
			$rrules = array();
			foreach (explode(';', $anEvent['RRULE']) as $s) {
				list($k, $v) = explode('=', $s);
				if (in_array($k, array('BYSETPOS', 'BYDAY', 'BYMONTHDAY', 'BYMONTH'))) {
					$rrules[$k] = explode(',', $v);
				} else {
					$rrules[$k] = $v;
				}
			}

			// Get frequency
			$frequency = $rrules['FREQ'];

			// Reject RRULE if BYDAY stanza is invalid:
			// > The BYDAY rule part MUST NOT be specified with a numeric value
			// > when the FREQ rule part is not set to MONTHLY or YEARLY.
			if (isset($rrules['BYDAY']) && !in_array($frequency, array('MONTHLY', 'YEARLY'))) {
				$allByDayStanzasValid = array_reduce($rrules['BYDAY'], function ($carry, $weekday) {
					return $carry && substr($weekday, -2) === $weekday;
				}, true);

				if (!$allByDayStanzasValid) {
					error_log("ICal::ProcessRecurrences: A \"{$frequency}\" RRULE should not contain BYDAY values with numeric prefixes");

					continue;
				}
			}

			// Get Interval
			$interval = (empty($rrules['INTERVAL'])) ? 1 : $rrules['INTERVAL'];

			// Throw an error if this isn't an integer.
			if (!is_int($this->defaultSpan)) {
				trigger_error('ICal::defaultSpan: User defined value is not an integer', E_USER_NOTICE);
			}

			// Compute EXDATEs
			$exdates = $this->parseExdates($anEvent);

			// Determine if the initial date is also an EXDATE
			$initialDateIsExdate = array_reduce($exdates, function ($carry, $exdate) use ($initialEventDate) {
				return $carry || $exdate->getTimestamp() === $initialEventDate->getTimestamp();
			}, false);

			if ($initialDateIsExdate) {
				$eventKeysToRemove[] = $key;
			}

			/**
			 * Determine at what point we should stop calculating recurrences
			 * by looking at the UNTIL or COUNT rrule stanza, or, if neither
			 * if set, using a fallback.
			 *
			 * If the initial date is also an EXDATE, it shouldn't be included
			 * in the count.
			 *
			 * Syntax:
			 *   UNTIL={enddate}
			 *   COUNT=<positive integer>
			 *
			 * Where:
			 *   enddate = <icalDate> || <icalDateTime>
			 */
			$count = 1;
			$countLimit = (isset($rrules['COUNT'])) ? intval($rrules['COUNT']) : 0;
			$until = date_create()->modify("{$this->defaultSpan} years")->setTime(23, 59, 59)->getTimestamp();

			if (isset($rrules['UNTIL'])) {
				$until = min($until, $this->iCalDateToUnixTimestamp($rrules['UNTIL']));
			}
			$until = min($until, $this->windowMaxTimestamp);

			$eventRecurrences = array();

			$frequencyRecurringDateTime = clone $initialEventDate;
			while ($frequencyRecurringDateTime->getTimestamp() <= $until) {
				$candidateDateTimes = array();

				// phpcs:ignore Squiz.ControlStructures.SwitchDeclaration.MissingDefault
				switch ($frequency) {
				case 'DAILY':
					$candidateDateTimes[] = clone $frequencyRecurringDateTime;

					break;

				case 'WEEKLY':
					$initialDayOfWeek = $frequencyRecurringDateTime->format('N');
					$matchingDays = array($initialDayOfWeek);

					if (!empty($rrules['BYDAY'])) {
						// setISODate() below uses the ISO-8601 specification of weeks: start on
						// a Monday, end on a Sunday. However, RRULEs (or the caller of the
						// parser) may state an alternate WeeKSTart.
						$wkstTransition = 7;

						if (empty($rrules['WKST'])) {
							if ($this->defaultWeekStart !== self::ISO_8601_WEEK_START) {
								$wkstTransition = array_search($this->defaultWeekStart, array_keys($this->weekdays));
							}
						} elseif ($rrules['WKST'] !== self::ISO_8601_WEEK_START) {
							$wkstTransition = array_search($rrules['WKST'], array_keys($this->weekdays));
						}

						$matchingDays = array_map(
							function ($weekday) use ($initialDayOfWeek, $wkstTransition, $interval) {
								$day = array_search($weekday, array_keys($this->weekdays));

								if ($day < $initialDayOfWeek) {
									$day += 7;
								}

								if ($day >= $wkstTransition) {
									$day += 7 * ($interval - 1);
								}

								// Ignoring alternate week starts, $day at this point will have a
								// value between 0 and 6. But setISODate() expects a value of 1 to 7.
								// Even with alternate week starts, we still need to +1 to set the
								// correct weekday.
								$day++;

								return $day;
							},
							$rrules['BYDAY']
						);
					}

					sort($matchingDays);

					foreach ($matchingDays as $day) {
						$clonedDateTime = clone $frequencyRecurringDateTime;
						$candidateDateTimes[] = $clonedDateTime->setISODate(
							$frequencyRecurringDateTime->format('o'),
							$frequencyRecurringDateTime->format('W'),
							$day
						);
					}
					break;

				case 'MONTHLY':
					$matchingDays = array();

					if (!empty($rrules['BYMONTHDAY'])) {
						$matchingDays = $rrules['BYMONTHDAY'];
					} elseif (!empty($rrules['BYDAY'])) {
						$matchingDays = $this->getDaysOfMonthMatchingByDayRRule($rrules['BYDAY'], $frequencyRecurringDateTime);
					}

					if (!empty($rrules['BYSETPOS'])) {
						$matchingDays = $this->filterValuesUsingBySetPosRRule($rrules['BYSETPOS'], $matchingDays);
					}

					foreach ($matchingDays as $day) {
						// Skip invalid dates (e.g. 30th February)
						if ($day > $frequencyRecurringDateTime->format('t')) {
							continue;
						}

						$clonedDateTime = clone $frequencyRecurringDateTime;
						$candidateDateTimes[] = $clonedDateTime->setDate(
							$frequencyRecurringDateTime->format('Y'),
							$frequencyRecurringDateTime->format('m'),
							$day
						);
					}
					break;

				case 'YEARLY':
					if (!empty($rrules['BYMONTH'])) {
						foreach ($rrules['BYMONTH'] as $byMonth) {
							$clonedDateTime = clone $frequencyRecurringDateTime;
							$bymonthRecurringDatetime = $clonedDateTime->setDate(
								$frequencyRecurringDateTime->format('Y'),
								$byMonth,
								$frequencyRecurringDateTime->format('d')
							);

							if (!empty($rrules['BYDAY'])) {
								// Get all days of the month that match the BYDAY rule.
								$matchingDays = $this->getDaysOfMonthMatchingByDayRRule($rrules['BYDAY'], $bymonthRecurringDatetime);

								// And add each of them to the list of recurrences
								foreach ($matchingDays as $day) {
									$clonedDateTime = clone $bymonthRecurringDatetime;
									$candidateDateTimes[] = $clonedDateTime->setDate(
										$frequencyRecurringDateTime->format('Y'),
										$bymonthRecurringDatetime->format('m'),
										$day
									);
								}
							} else {
								$candidateDateTimes[] = clone $bymonthRecurringDatetime;
							}
						}
					} else {
						$candidateDateTimes[] = clone $frequencyRecurringDateTime;
					}
					break;
				}

				foreach ($candidateDateTimes as $candidate) {
					$timestamp = $candidate->getTimestamp();
					if ($timestamp <= $initialEventDate->getTimestamp()) {
						continue;
					}

					if ($timestamp > $until) {
						break;
					}

					// Exclusions
					$isExcluded = ($this->shouldFilterByWindow && $timestamp + 160000 < $this->windowMinTimestamp);

					if (!$isExcluded) {
						$isExcluded = array_filter($exdates, function ($exdate) use ($timestamp) {
							return $exdate->getTimestamp() == $timestamp;
						});
					}

					if (!$isExcluded && isset($this->alteredRecurrenceInstances[$anEvent['UID']])) {
						if (in_array($timestamp, $this->alteredRecurrenceInstances[$anEvent['UID']])) {
							$isExcluded = true;
						}
					}

					if (!$isExcluded) {
						$eventRecurrences[] = $candidate;
						$this->eventCount++;
					}

					// Count all evaluated candidates including excluded ones
					if (isset($rrules['COUNT'])) {
						$count++;

						// If RRULE[COUNT] is reached then break
						if ($count >= $countLimit) {
							break 2;
						}
					}
				}

				// Move forwards $interval $frequency.
				$monthPreMove = $frequencyRecurringDateTime->format('m');
				$frequencyRecurringDateTime->modify("{$interval} {$this->frequencyConversion[$frequency]}");

				// As noted in Example #2 on https://www.php.net/manual/en/datetime.modify.php,
				// there are some occasions where adding months doesn't give the month you might
				// expect. For instance: January 31st + 1 month == March 3rd (March 2nd on a leap
				// year.) The following code crudely rectifies this.
				if ($frequency === 'MONTHLY') {
					$monthDiff = $frequencyRecurringDateTime->format('m') - $monthPreMove;

					if (($monthDiff > 0 && $monthDiff > $interval) || ($monthDiff < 0 && $monthDiff > $interval - 12)) {
						$frequencyRecurringDateTime->modify('-1 month');
					}
				}
			}

			// Determine event length
			$eventLength = 0;
			if (isset($anEvent['DURATION'])) {
				$clonedDateTime = clone $initialEventDate;
				$endDate = $clonedDateTime->add($anEvent['DURATION_array'][2]);
				$eventLength = $endDate->getTimestamp() - $anEvent['DTSTART_array'][2];
			} elseif (isset($anEvent['DTEND_array'])) {
				$eventLength = $anEvent['DTEND_array'][2] - $anEvent['DTSTART_array'][2];
			}

			// Whether or not the initial date was UTC
			$initialDateWasUTC = substr($anEvent['DTSTART'], -1) === 'Z';

			// Build the param array
			$dateParamArray = array();
			if (
				!$initialDateWasUTC
				&& isset($anEvent['DTSTART_array'][0]['TZID'])
				&& $this->isValidTimeZoneId($anEvent['DTSTART_array'][0]['TZID'])
			) {
				$dateParamArray['TZID'] = $anEvent['DTSTART_array'][0]['TZID'];
			}

			// Populate the `DT{START|END}[_array]`s
			$eventRecurrences = array_map(
				function ($recurringDatetime) use ($anEvent, $eventLength, $initialDateWasUTC, $dateParamArray) {
					$tzidPrefix = (isset($dateParamArray['TZID'])) ? 'TZID=' . $this->escapeParamText($dateParamArray['TZID']) . ':' : '';

					foreach (array('DTSTART', 'DTEND') as $dtkey) {
						$anEvent[$dtkey] = $recurringDatetime->format(self::DATE_TIME_FORMAT) . (($initialDateWasUTC) ? 'Z' : '');

						$anEvent["{$dtkey}_array"] = array(
							$dateParamArray,                    // [0] Array of params (incl. TZID)
							$anEvent[$dtkey],                   // [1] ICalDateTime string w/o TZID
							$recurringDatetime->getTimestamp(), // [2] Unix Timestamp
							"{$tzidPrefix}{$anEvent[$dtkey]}",  // [3] Full ICalDateTime string
						);

						if ($dtkey !== 'DTEND') {
							$recurringDatetime->modify("{$eventLength} seconds");
						}
					}

					return $anEvent;
				},
				$eventRecurrences
			);

			$allEventRecurrences = array_merge($allEventRecurrences, $eventRecurrences);
		}

		// Nullify the initial events that are also EXDATEs
		foreach ($eventKeysToRemove as $eventKeyToRemove) {
			$events[$eventKeyToRemove] = null;
		}

		$events = array_merge($events, $allEventRecurrences);
	}

	/**
	 * Find all days of a month that match the BYDAY stanza of an RRULE.
	 *
	 * With no {ordwk}, then return the day number of every {weekday}
	 * within the month.
	 *
	 * With a +ve {ordwk}, then return the {ordwk} {weekday} within the
	 * month.
	 *
	 * With a -ve {ordwk}, then return the {ordwk}-to-last {weekday}
	 * within the month.
	 *
	 * RRule Syntax:
	 *   BYDAY={bywdaylist}
	 *
	 * Where:
	 *   bywdaylist = {weekdaynum}[,{weekdaynum}...]
	 *   weekdaynum = [[+]{ordwk} || -{ordwk}]{weekday}
	 *   ordwk      = 1 to 53
	 *   weekday    = SU || MO || TU || WE || TH || FR || SA
	 *
	 * @param array $byDays
	 * @param DateTime $initialDateTime
	 * @return array
	 */
	protected function getDaysOfMonthMatchingByDayRRule(array $byDays, DateTime $initialDateTime): array
	{
		$matchingDays = array();

		foreach ($byDays as $weekday) {
			$bydayDateTime = clone $initialDateTime;

			$ordwk = intval(substr($weekday, 0, -2));

			// Quantise the date to the first instance of the requested day in a month
			// (Or last if we have a -ve {ordwk})
			$bydayDateTime->modify(
				(($ordwk < 0) ? 'Last' : 'First')
				. ' '
				. $this->weekdays[substr($weekday, -2)]  // e.g. "Monday"
				. ' of ' . $initialDateTime->format('F') // e.g. "June"
			);

			if ($ordwk < 0) { // -ve {ordwk}
				$bydayDateTime->modify((++$ordwk) . ' week');
				$matchingDays[] = $bydayDateTime->format('j');
			} elseif ($ordwk > 0) { // +ve {ordwk}
				$bydayDateTime->modify((--$ordwk) . ' week');
				$matchingDays[] = $bydayDateTime->format('j');
			} else { // No {ordwk}
				while ($bydayDateTime->format('n') === $initialDateTime->format('n')) {
					$matchingDays[] = $bydayDateTime->format('j');
					$bydayDateTime->modify('+1 week');
				}
			}
		}

		// Sort into ascending order.
		sort($matchingDays);

		return $matchingDays;
	}

	/**
	 * Filters a provided values-list by applying a BYSETPOS RRule.
	 *
	 * Where a +ve {daynum} is provided, the {ordday} position'd value as
	 * measured from the start of the list of values should be retained.
	 *
	 * Where a -ve {daynum} is provided, the {ordday} position'd value as
	 * measured from the end of the list of values should be retained.
	 *
	 * RRule Syntax:
	 *   BYSETPOS={bysplist}
	 *
	 * Where:
	 *   bysplist  = {setposday}[,{setposday}...]
	 *   setposday = {daynum}
	 *   daynum    = [+ || -] {ordday}
	 *   ordday    = 1 to 366
	 *
	 * @param array $bySetPos
	 * @param array $valuesList
	 * @return array
	 */
	protected function filterValuesUsingBySetPosRRule(array $bySetPos, array $valuesList): array
	{
		$filteredMatches = array();

		foreach ($bySetPos as $setPosition) {
			if ($setPosition < 0) {
				$setPosition = count($valuesList) + ++$setPosition;
			}

			// Positioning starts at 1, array indexes start at 0
			if (isset($valuesList[$setPosition - 1])) {
				$filteredMatches[] = $valuesList[$setPosition - 1];
			}
		}

		return $filteredMatches;
	}

	/**
	 * Processes date conversions using the time zone
	 *
	 * Add keys `DTSTART_tz` and `DTEND_tz` to each Event
	 * These keys contain dates adapted to the calendar
	 * time zone depending on the event `TZID`.
	 *
	 * @return void
	 */
	protected function processDateConversions()
	{
		if (empty($this->cal['VEVENT']))
			return;

		$events =& $this->cal['VEVENT'];
		foreach ($events as $key => $anEvent) {
			if (is_null($anEvent) || !$this->isValidDate($anEvent['DTSTART'])) {
				unset($events[$key]);
				$this->eventCount--;

				continue;
			}

			$events[$key]['DTSTART_tz'] = $this->iCalDateWithTimeZone($anEvent, 'DTSTART');

			if ($this->iCalDateWithTimeZone($anEvent, 'DTEND')) {
				$events[$key]['DTEND_tz'] = $this->iCalDateWithTimeZone($anEvent, 'DTEND');
			} elseif ($this->iCalDateWithTimeZone($anEvent, 'DURATION')) {
				$events[$key]['DTEND_tz'] = $this->iCalDateWithTimeZone($anEvent, 'DURATION');
			} else {
				$events[$key]['DTEND_tz'] = $events[$key]['DTSTART_tz'];
			}
		}
	}

	/**
	 * Returns an array of Events.
	 * Every event is a class with the event
	 * details being properties within it.
	 *
	 * @return ICalEvent[]
	 */
	public function events(): array
	{
		if (empty($this->cal) || empty($this->cal['VEVENT']))
			return [];

		$events = array();
		foreach ($this->cal['VEVENT'] as $event) {
			$events[] = new ICalEvent($event);
		}

		return $events;
	}

	/**
	 * Returns the calendar name
	 *
	 * @return string
	 */
	public function calendarName(): string
	{
		return $this->cal['VCALENDAR']['X-WR-CALNAME'] ?? '';
	}

	/**
	 * Returns the calendar description
	 *
	 * @return string
	 */
	public function calendarDescription(): string
	{
		return $this->cal['VCALENDAR']['X-WR-CALDESC'] ?? '';
	}

	/**
	 * Returns the calendar time zone
	 *
	 * @param boolean $ignoreUtc
	 * @return string
	 */
	public function calendarTimeZone(bool $ignoreUtc = false): ?string
	{
		if (isset($this->cal['VCALENDAR']['X-WR-TIMEZONE'])) {
			$timeZone = $this->cal['VCALENDAR']['X-WR-TIMEZONE'];
		} elseif (isset($this->cal['VTIMEZONE']['TZID'])) {
			$timeZone = $this->cal['VTIMEZONE']['TZID'];
		} else {
			$timeZone = $this->defaultTimeZone;
		}

		// Validate the time zone, falling back to the time zone set in the PHP environment.
		$timeZone = $this->timeZoneStringToDateTimeZone($timeZone)->getName();

		if ($ignoreUtc && strtoupper($timeZone) === self::TIME_ZONE_UTC) {
			return null;
		}

		return $timeZone;
	}

	/**
	 * Returns an array of arrays with all free/busy events.
	 * Every event is an associative array and each property
	 * is an element it.
	 *
	 * @return array
	 */
	public function freeBusyEvents(): array
	{
		$array = $this->cal;

		return $array['VFREEBUSY'] ?? array();
	}

	/**
	 * Returns a sorted array of the events in a given range,
	 * or an empty array if no events exist in the range.
	 *
	 * Events will be returned if the start or end date is contained within the
	 * range (inclusive), or if the event starts before and end after the range.
	 *
	 * If a start date is not specified or of a valid format, then the start
	 * of the range will default to the current time and date of the server.
	 *
	 * If an end date is not specified or of a valid format, then the end of
	 * the range will default to the current time and date of the server,
	 * plus 20 years.
	 *
	 * Note that this function makes use of Unix timestamps. This might be a
	 * problem for events on, during, or after 29 Jan 2038.
	 * See https://en.wikipedia.org/wiki/Unix_time#Representing_the_number
	 *
	 * @param string|null $rangeStart
	 * @param string|null $rangeEnd
	 * @return array
	 * @throws Exception
	 */
	public function eventsFromRange(string $rangeStart = null, string $rangeEnd = null): array
	{
		// Sort events before processing range
		$events = $this->sortEventsWithOrder($this->events());

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

		$extendedEvents = array();

		if (!is_null($rangeStart)) {
			try {
				$rangeStart = new DateTime($rangeStart, new DateTimeZone($this->defaultTimeZone));
			} catch (Exception $exception) {
				error_log("ICal::eventsFromRange: Invalid date passed ({$rangeStart})");
				$rangeStart = false;
			}
		} else {
			$rangeStart = new DateTime('now', new DateTimeZone($this->defaultTimeZone));
		}

		if (!is_null($rangeEnd)) {
			try {
				$rangeEnd = new DateTime($rangeEnd, new DateTimeZone($this->defaultTimeZone));
			} catch (Exception $exception) {
				error_log("ICal::eventsFromRange: Invalid date passed ({$rangeEnd})");
				$rangeEnd = false;
			}
		} else {
			$rangeEnd = new DateTime('now', new DateTimeZone($this->defaultTimeZone));
			$rangeEnd->modify('+20 years');
		}

		// If start and end are identical and are dates with no times...
		if ($rangeEnd->format('His') == 0 && $rangeStart->getTimestamp() === $rangeEnd->getTimestamp()) {
			$rangeEnd->modify('+1 day');
		}

		$rangeStart = $rangeStart->getTimestamp();
		$rangeEnd = $rangeEnd->getTimestamp();

		foreach ($events as $anEvent) {
			$eventStart = $anEvent->dtstart_array[2];
			$eventEnd = (isset($anEvent->dtend_array[2])) ? $anEvent->dtend_array[2] : null;

			if (
				($eventStart >= $rangeStart && $eventStart < $rangeEnd)         // Event start date contained in the range
				|| ($eventEnd !== null
					&& (
						($eventEnd > $rangeStart && $eventEnd <= $rangeEnd)     // Event end date contained in the range
						|| ($eventStart < $rangeStart && $eventEnd > $rangeEnd) // Event starts before and finishes after range
					)
				)
			) {
				$extendedEvents[] = $anEvent;
			}
		}

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

		return $extendedEvents;
	}

	/**
	 * Sorts events based on a given sort order
	 *
	 * @param array $events
	 * @param integer $sortOrder Either SORT_ASC, SORT_DESC, SORT_REGULAR, SORT_NUMERIC, SORT_STRING
	 * @return array
	 */
	public function sortEventsWithOrder(array $events, int $sortOrder = SORT_ASC): array
	{
		$extendedEvents = array();
		$timestamp = array();

		foreach ($events as $key => $anEvent) {
			$extendedEvents[] = $anEvent;
			$timestamp[$key] = $anEvent->dtstart_array[2];
		}

		array_multisort($timestamp, $sortOrder, $extendedEvents);

		return $extendedEvents;
	}

	/**
	 * Checks if a time zone is valid (IANA, CLDR, or Windows)
	 *
	 * @param string $timeZone
	 * @return boolean
	 */
	protected function isValidTimeZoneId(string $timeZone): bool
	{
		return $this->isValidIanaTimeZoneId($timeZone) !== false
			|| $this->isValidCldrTimeZoneId($timeZone) !== false
			|| $this->isValidWindowsTimeZoneId($timeZone) !== false;
	}

	/**
	 * Checks if a time zone is a valid IANA time zone
	 *
	 * @param string $timeZone
	 * @return boolean
	 */
	protected function isValidIanaTimeZoneId(string $timeZone): bool
	{
		if (in_array($timeZone, $this->validIanaTimeZones)) {
			return true;
		}

		$valid = array();
		$tza = timezone_abbreviations_list();

		foreach ($tza as $zone) {
			foreach ($zone as $item) {
				$valid[$item['timezone_id']] = true;
			}
		}

		unset($valid['']);

		if (isset($valid[$timeZone]) || in_array($timeZone, timezone_identifiers_list(DateTimeZone::ALL_WITH_BC))) {
			$this->validIanaTimeZones[] = $timeZone;

			return true;
		}

		return false;
	}

	/**
	 * Checks if a time zone is a valid CLDR time zone
	 *
	 * @param string $timeZone
	 * @return boolean
	 */
	public function isValidCldrTimeZoneId(string $timeZone): bool
	{
		return array_key_exists(html_entity_decode($timeZone), self::$cldrTimeZonesMap);
	}

	/**
	 * Checks if a time zone is a recognised Windows (non-CLDR) time zone
	 *
	 * @param string $timeZone
	 * @return boolean
	 */
	public function isValidWindowsTimeZoneId(string $timeZone): bool
	{
		return array_key_exists(html_entity_decode($timeZone), self::$windowsTimeZonesMap);
	}

	/**
	 * Parses a duration and applies it to a date
	 *
	 * @return integer|DateTime
	 */
	protected function parseDuration(string $date, DateInterval $duration, ?string $format = self::UNIX_FORMAT)
	{
		$dateTime = date_create($date);
		$dateTime->modify("{$duration->y} year");
		$dateTime->modify("{$duration->m} month");
		$dateTime->modify("{$duration->d} day");
		$dateTime->modify("{$duration->h} hour");
		$dateTime->modify("{$duration->i} minute");
		$dateTime->modify("{$duration->s} second");

		if (is_null($format)) {
			$output = $dateTime;
		} elseif ($format === self::UNIX_FORMAT) {
			$output = $dateTime->getTimestamp();
		} else {
			$output = $dateTime->format($format);
		}

		return $output;
	}

	/**
	 * Removes unprintable ASCII and UTF-8 characters
	 *
	 * @param string $data
	 * @return string
	 */
	protected function removeUnprintableChars(string $data): string
	{
		$data = iconv('utf8', 'utf8//IGNORE', $data);
		return preg_replace('/[\x00-\x1F\x7F\xA0]/u', '', $data);
	}

	/**
	 * Places double-quotes around texts that have characters not permitted
	 * in parameter-texts, but are permitted in quoted-texts.
	 *
	 * @param string $candidateText
	 * @return string
	 */
	protected function escapeParamText(string $candidateText): string
	{
		if (strpbrk($candidateText, ':;,') !== false) {
			return '"' . $candidateText . '"';
		}

		return $candidateText;
	}

	/**
	 * Parses a list of excluded dates
	 * to be applied to an Event
	 *
	 * @param array $event
	 * @return array
	 */
	public function parseExdates(array $event): array
	{
		if (empty($event['EXDATE_array'])) {
			return array();
		}
		$exdates = $event['EXDATE_array'];

		$output = array();
		$currentTimeZone = $this->defaultTimeZone;

		foreach ($exdates as $subArray) {
			end($subArray);
			$finalKey = key($subArray);

			foreach (array_keys($subArray) as $key) {
				if ($key === 'TZID') {
					$currentTimeZone = $subArray[$key];
				} elseif (is_numeric($key)) {
					$icalDate = $subArray[$key];

					if (substr($icalDate, -1) === 'Z') {
						$currentTimeZone = self::TIME_ZONE_UTC;
					}

					$output[] = new DateTimeImmutable($icalDate, $this->timeZoneStringToDateTimeZone($currentTimeZone));

					if ($key === $finalKey) {
						// Reset to default
						$currentTimeZone = $this->defaultTimeZone;
					}
				}
			}
		}

		return $output;
	}

	/**
	 * Checks if a date string is a valid date
	 *
	 * @param string $value
	 * @return boolean
	 */
	public function isValidDate(string $value): bool
	{
		if (!$value) {
			return false;
		}

		try {
			new DateTime($value);

			return true;
		} catch (Exception $exception) {
			return false;
		}
	}

	/**
	 * Returns a `DateTimeZone` object based on a string containing a time zone name.
	 * Falls back to the default time zone if string passed not a recognised time zone.
	 *
	 * @param DateTimeZone|string $timeZoneString
	 * @return DateTimeZone
	 */
	public function timeZoneStringToDateTimeZone($timeZoneString): DateTimeZone
	{
		if ($timeZoneString instanceof DateTimeZone)
			return $timeZoneString;
		// Some time zones contain characters that are not permitted in param-texts,
		// but are within quoted texts. We need to remove the quotes as they're not
		// actually part of the time zone.
		$timeZoneString = trim($timeZoneString, '"');
		$timeZoneString = html_entity_decode($timeZoneString);

		if ($this->isValidIanaTimeZoneId($timeZoneString)) {
			return new DateTimeZone($timeZoneString);
		}

		if ($this->isValidCldrTimeZoneId($timeZoneString)) {
			return new DateTimeZone(self::$cldrTimeZonesMap[$timeZoneString]);
		}

		if ($this->isValidWindowsTimeZoneId($timeZoneString)) {
			return new DateTimeZone(self::$windowsTimeZonesMap[$timeZoneString]);
		}

		return new DateTimeZone($this->defaultTimeZone);
	}
}