<?php /*

 Composr
 Copyright (c) ocProducts, 2004-2016

 See text/EN/licence.txt for full licencing information.


 NOTE TO PROGRAMMERS:
   Do not edit this file. If you need to make changes, save your changed file to the appropriate *_custom folder
   **** If you ignore this advice, then your website upgrades (e.g. for bug fixes) will likely kill your changes ****

*/

/**
 * @license    http://opensource.org/licenses/cpal_1.0 Common Public Attribution License
 * @copyright  ocProducts Ltd
 * @package    calendar
 */

/**
 * Standard code module init function.
 */
function init__calendar()
{
    require_code('temporal2');

    if (!defined('DETECT_CONFLICT_SCOPE_NONE')) {
        define('DETECT_CONFLICT_SCOPE_NONE', 0);
        define('DETECT_CONFLICT_SCOPE_SAME_MEMBER', 1);
        define('DETECT_CONFLICT_SCOPE_SAME_MEMBER_OR_SAME_TYPE_IF_GLOBAL', 2);
        define('DETECT_CONFLICT_SCOPE_SAME_MEMBER_OR_SAME_TYPE', 2);
        define('DETECT_CONFLICT_SCOPE_ALL', 3);
    }

    require_lang('calendar');
}

/**
 * Render an event box.
 *
 * @param  array $row Event row
 * @param  ID_TEXT $zone Zone to link through to
 * @param  boolean $give_context Whether to include context (i.e. say WHAT this is, not just show the actual content)
 * @param  ID_TEXT $guid Overridden GUID to send to templates (blank: none)
 * @param  ?Tempcode $text_summary Text summary for result (e.g. highlighted portion of actual file from search result) (null: none)
 * @return Tempcode The event box
 */
function render_event_box($row, $zone = '_SEARCH', $give_context = true, $guid = '', $text_summary = null)
{
    if (is_null($row)) { // Should never happen, but we need to be defensive
        return new Tempcode();
    }

    require_css('calendar');
    require_lang('calendar');

    $url = build_url(array('page' => 'calendar', 'type' => 'view', 'id' => $row['id']), $zone);

    if ($text_summary === null) {
        $just_event_row = db_map_restrict($row, array('id', 'e_content'));
        $summary = get_translated_tempcode('calendar_events', $just_event_row, 'e_content');
    } else {
        $summary = $text_summary;
    }

    return do_template('CALENDAR_EVENT_BOX', array(
        '_GUID' => ($guid != '') ? $guid : '0eaa10d9fab32599ff095e1121d41c43',
        'ID' => strval($row['id']),
        'TITLE' => get_translated_text($row['e_title']),
        'SUMMARY' => $summary,
        'URL' => $url,
        'GIVE_CONTEXT' => $give_context,
    ));
}

/**
 * Get Tempcode for a calendar type 'feature box' for the given row
 *
 * @param  array $row The database field row of it
 * @param  ID_TEXT $zone The zone to use
 * @param  boolean $give_context Whether to include context (i.e. say WHAT this is, not just show the actual content)
 * @param  ID_TEXT $guid Overridden GUID to send to templates (blank: none)
 * @return Tempcode A box for it, linking to the full page
 */
function render_calendar_type_box($row, $zone = '_SEARCH', $give_context = true, $guid = '')
{
    if (is_null($row)) { // Should never happen, but we need to be defensive
        return new Tempcode();
    }

    require_lang('calendar');

    $map = array('page' => 'calendar', 'type' => 'browse');
    $map['int_' . strval($row['id'])] = 1;
    $url = build_url($map, $zone);

    require_lang('calendar');

    $_title = get_translated_text($row['t_title']);
    $title = $give_context ? do_lang('CONTENT_IS_OF_TYPE', do_lang('EVENT_TYPE'), $_title) : $_title;

    $num_entries = $GLOBALS['SITE_DB']->query_select_value('calendar_events', 'COUNT(*)', array('e_type' => $row['id'], 'validated' => 1));
    $entry_details = do_lang_tempcode('CATEGORY_SUBORDINATE_2', escape_html(integer_format($num_entries)));

    return do_template('SIMPLE_PREVIEW_BOX', array(
        '_GUID' => ($guid != '') ? $guid : '0eaa10d9fab32599ff095e1121d41c49',
        'ID' => strval($row['id']),
        'TITLE' => $title,
        'TITLE_PLAIN' => $_title,
        'SUMMARY' => '',
        'ENTRY_DETAILS' => $entry_details,
        'URL' => $url,
        'FRACTIONAL_EDIT_FIELD_NAME' => $give_context ? null : 'title',
        'FRACTIONAL_EDIT_FIELD_URL' => $give_context ? null : '_SEARCH:cms_catalogues:__edit_category:' . strval($row['id']),
        'RESOURCE_TYPE' => 'calendar_type',
    ));
}

/**
 * Get the week number for a time.
 *
 * @param  TIME $timestamp The week timestamp
 * @param  boolean $no_year Whether to do it contextually to the year, rather than including the year
 * @return string The week number
 */
function get_week_number_for($timestamp, $no_year = false)
{
    $ssw = (get_option('ssw') == '1');

    $format = $no_year ? 'W' : 'o-W';
    if (!$ssw) {
        $ret = date($format, $timestamp);
    } else { // For SSW: week starts one day earlier (inconsistent with other PHP date stuff!), so we actually push 6 days of the week back onto the previous one (where the Sunday is)
        $ret = date($format, $timestamp - 60 * 60 * 24 * 6);
    }
    return $ret;
}

/**
 * Converts year+week to year+month+day. This is really complex. The first week of a year may actually start in December. The first day of the first week is a Monday or a Sunday, depending on configuration.
 *
 * @param  integer $year Year #
 * @param  integer $week Week #
 * @return array Month #,Day #,Year #
 */
function date_from_week_of_year($year, $week)
{
    $ssw = (get_option('ssw') == '1');

    $basis = strval($year) . '-' . str_pad(strval($week), 2, '0', STR_PAD_LEFT);
    $time = mktime(12, 0, 0, 1, 1, $year);
    $days_in_year = intval(date('z', mktime(0, 0, 0, 12, 31, $year))) + 1;
    for ($i = ($week == 52) ? 350/*conditional to stop it finding week as previous year overlap week of same number*/ : -7; $i < $days_in_year + 7; $i++) {
        $new_time = $time + 60 * 60 * 24 * $i;
        $w = intval(date('w', $new_time));
        if ((($ssw) && ($w == 0)) || ((!$ssw) && ($w == 1))) {
            $test = get_week_number_for($new_time);

            if ($test == $basis) {
                $exploded = explode('-', date('m-d-Y', $new_time));
                return array(intval($exploded[0]), intval($exploded[1]), intval($exploded[2]));
            }
        }
    }
    return array(null, null, null);
}

/**
 * Find a list of pairs specifying the times the event occurs, for 20 years into the future, in user-time.
 *
 * @param  ID_TEXT $timezone The timezone of the event
 * @param  BINARY $do_timezone_conv Whether the time should be converted to the viewer's own timezone
 * @param  integer $start_year The year the event starts at. This and the below are in server time
 * @param  integer $start_month The month the event starts at
 * @param  integer $start_day The day the event starts at
 * @param  ID_TEXT $start_monthly_spec_type In-month specification type for start date
 * @set day_of_month day_of_month_backwards dow_of_month dow_of_month_backwards
 * @param  ?integer $start_hour The hour the event starts at (null: full day event)
 * @param  ?integer $start_minute The minute the event starts at (null: full day event)
 * @param  ?integer $end_year The year the event ends at (null: not a multi day event)
 * @param  ?integer $end_month The month the event ends at (null: not a multi day event)
 * @param  ?integer $end_day The day the event ends at (null: not a multi day event)
 * @param  ID_TEXT $end_monthly_spec_type In-month specification type for end date
 * @set day_of_month day_of_month_backwards dow_of_month dow_of_month_backwards
 * @param  ?integer $end_hour The hour the event ends at (null: not a multi day event / all day event)
 * @param  ?integer $end_minute The minute the event ends at (null: not a multi day event / all day event)
 * @param  string $recurrence The event recurrence
 * @param  ?integer $recurrences The number of recurrences (null: none/infinite)
 * @param  ?TIME $period_start The timestamp that found times must exceed. In user-time (null: now)
 * @param  ?TIME $period_end The timestamp that found times must not exceed. In user-time (null: 20 years time)
 * @return array A list of pairs for period times (timestamps, in user-time). Actually a series of pairs, 'window-bound timestamps' is first pair, then 'true coverage timestamps', then 'true coverage timestamps without timezone conversions'
 */
function find_periods_recurrence($timezone, $do_timezone_conv, $start_year, $start_month, $start_day, $start_monthly_spec_type, $start_hour, $start_minute, $end_year, $end_month, $end_day, $end_monthly_spec_type, $end_hour, $end_minute, $recurrence, $recurrences, $period_start = null, $period_end = null)
{
    if ($recurrences === 0) {
        return array();
    }

    if (is_null($period_start)) {
        $period_start = utctime_to_usertime(time());
    }
    if (is_null($period_end)) {
        $period_end = time() + 60 * 60 * 24 * 365 * 20;
        if (is_float($period_end)) {
            $period_end = max($period_start, 2147483647 - 60 * 60 * 24 * 365 * 2); // Y-2038 issue, so bring back comfortably before that
        }
        $period_end = utctime_to_usertime($period_end);
    }

    $initial_start_year = $start_year;
    $initial_start_month = $start_month;
    $initial_start_day = $start_day;
    $initial_end_year = $end_year;
    $initial_end_month = $end_month;
    $initial_end_day = $end_day;

    $times = array();
    $i = 0;
    $happened_count = 0;
    $parts = explode(' ', $recurrence);
    if (count($parts) != 1) {
        $recurrence = $parts[0];
        $mask = $parts[1];
        $mask_len = strlen($mask);
    } else {
        $mask = '1';
        $mask_len = 1;
    }

    $a = 0;

    $dif_day = 0;
    $dif_month = 0;
    $dif_year = 0;
    $_start_hour = ($start_hour === null) ? find_timezone_start_hour_in_utc($timezone, $start_year, $start_month, $start_day, $start_monthly_spec_type) : $start_hour;
    $_start_minute = ($start_minute === null) ? find_timezone_start_minute_in_utc($timezone, $start_year, $start_month, $start_day, $start_monthly_spec_type) : $start_minute;
    $day_of_month = find_concrete_day_of_month($start_year, $start_month, $start_day, $start_monthly_spec_type, $_start_hour, $_start_minute, $timezone, $do_timezone_conv == 1);
    $_end_hour = ($end_hour === null) ? find_timezone_end_hour_in_utc($timezone, $end_year, $end_month, $end_day, $end_monthly_spec_type) : $end_hour;
    $_end_minute = ($end_minute === null) ? find_timezone_end_minute_in_utc($timezone, $end_year, $end_month, $end_day, $end_monthly_spec_type) : $end_minute;
    $end_day_of_month = find_concrete_day_of_month($end_year, $end_month, $end_day, $end_monthly_spec_type, $_end_hour, $_end_minute, $timezone, $do_timezone_conv == 1);
    if ($end_monthly_spec_type != 'day_of_month') {
        $dif_days = get_days_between($initial_start_month, $day_of_month, $initial_start_year, $initial_end_month, $end_day_of_month, $initial_end_year);
    }

    $start_timestamp = mktime($_start_hour, $_start_minute, 0, $start_month, $day_of_month, $start_year);
    $dif = $period_start - utctime_to_usertime($start_timestamp);
    $start_day_of_month = $day_of_month;
    if ($recurrence != 'monthly') { // Defensiveness (this should be automatic)
        $start_monthly_spec_type = 'day_of_month';
        $end_monthly_spec_type = 'day_of_month';
    }
    $optimise_recurrence_via_zoom = true; // This code is easy to get wrong, so found bugs are more likely in here. When debugging set to 'false' to confirm the problem is in here
    switch ($recurrence) { // Set dif period / If a long way out of range, accelerate forward before steadily looping forward till we might find a match (doesn't jump fully forward, due to possibility of timezones complicating things)
        case 'daily':
            $dif_day = 1;
            if (($dif > 60 * 60 * 24 * 10) && ($mask_len == 0) && ($optimise_recurrence_via_zoom)) {
                $zoom = $dif_day * intval(floor(floatval($dif) / (60.0 * 60.0 * 24.0)));
                $start_day += $zoom;
                if (!is_null($end_day)) {
                    $end_day += $zoom;
                }

                _compensate_for_dst_change($start_hour, $start_minute, $start_day_of_month, $start_month, $start_year, $timezone, $do_timezone_conv, $zoom, 0, 0);
                if (!is_null($end_hour)) {
                    _compensate_for_dst_change($end_hour, $end_minute, $end_day_of_month, $end_month, $end_year, $timezone, $do_timezone_conv, $zoom, 0, 0);
                }
            }
            break;
        case 'weekly':
            $dif_day = 7;
            if (($dif > 60 * 60 * 24 * 70) && ($mask_len == 0) && ($optimise_recurrence_via_zoom)) {
                $zoom = $dif_day * intval(floor(floatval($dif) / (60.0 * 60.0 * 24.0))) - 70;
                $start_day += $zoom;
                if (!is_null($end_day)) {
                    $end_day += $zoom;
                }

                _compensate_for_dst_change($start_hour, $start_minute, $start_day_of_month, $start_month, $start_year, $timezone, $do_timezone_conv, $zoom, 0, 0);
                if (!is_null($end_hour)) {
                    _compensate_for_dst_change($end_hour, $end_minute, $end_day_of_month, $end_month, $end_year, $timezone, $do_timezone_conv, $zoom, 0, 0);
                }
            }
            break;
        case 'monthly':
            $dif_month = 1;
            if (($dif > 60 * 60 * 24 * 31 * 10) && ($mask_len == 0) && ($optimise_recurrence_via_zoom)) {
                $zoom = $dif_month * intval(floor(floatval($dif) / (60.0 * 60.0 * 24.0 * 28.0))) - 10;
                $start_month += $zoom;
                if (!is_null($end_month)) {
                    $end_month += $zoom;
                }
                $start_day_of_month = find_concrete_day_of_month($start_year, $start_month, $start_day, $start_monthly_spec_type, $_start_hour, $_start_minute, $timezone, $do_timezone_conv == 1);

                _compensate_for_dst_change($start_hour, $start_minute, $start_day_of_month, $start_month, $start_year, $timezone, $do_timezone_conv, 0, $zoom, 0);
                if (!is_null($end_hour)) {
                    _compensate_for_dst_change($end_hour, $end_minute, $end_day_of_month, $end_month, $end_year, $timezone, $do_timezone_conv, 0, $zoom, 0);
                }
            }
            break;
        case 'yearly':
            $dif_year = 1;
            if (($dif > 60 * 60 * 24 * 365 * 10) && ($mask_len == 0) && ($optimise_recurrence_via_zoom)) {
                $days_in_year = intval(date('z', mktime(0, 0, 0, 12, 31, $start_year))) + 1;
                $zoom = $dif_year * intval(floor(floatval($dif) / (60.0 * 60.0 * 24.0 * floatval($days_in_year)))) - 1;
                $start_year += $zoom;
                if (!is_null($end_year)) {
                    $end_year += $zoom;
                }

                _compensate_for_dst_change($start_hour, $start_minute, $start_day_of_month, $start_month, $start_year, $timezone, $do_timezone_conv, 0, 0, $zoom);
                if (!is_null($end_hour)) {
                    _compensate_for_dst_change($end_hour, $end_minute, $end_day_of_month, $end_month, $end_year, $timezone, $do_timezone_conv, 0, 0, $zoom);
                }
            }
            break;
    }
    $_b = mixed();
    $b = mixed();

    $all_day = false;

    if ((is_null($start_hour)) && (is_null($end_year) || is_null($end_month) || is_null($end_day))) { // All day event with no end date, should be same as start date.
        if ($start_monthly_spec_type == 'day_of_month') {
            $end_day = $start_day;
        } else {
            $end_day = $start_day_of_month;
        }
        if (find_timezone_offset($start_timestamp, $timezone) > 0) {
            $end_day++;
        }
        $end_month = $start_month;
        $end_year = $start_year;
        $all_day = true;

        // Should not be needed, but normalise possible database error
        $start_minute = null;
        $start_hour = null;
        $end_minute = null;
        $end_hour = null;
    }

    if (!is_null($end_year) && !is_null($end_month) && !is_null($end_day)) { // Must define end date relative to start date; we will calculate $end_monthly_spec_type. This code is re-run at the end of the loop, as we need to re-sync each time
        if ($end_monthly_spec_type != 'day_of_month') {
            // Work out using deltas
            $end_day = $start_day_of_month + $dif_days;
            $end_month = $start_month;
            $end_year = $start_year;
        }
    }

    do {
        /*
        Consider this scenario...

        An event ends at end of 1/1/2012 (23:59), which is 22:59 in UTC if they are in +1 timezone

        Therefore the event, which is stored in UTC, needs a server time of 22:59 before going through cal_utctime_to_usertime

        The server already has the day stored UTC which may be different to the day stored for the +1 timezone (in fact either the start or end day will be stored differently, assuming there is an end day)
        */
        $_start_hour = ($start_hour === null) ? find_timezone_start_hour_in_utc($timezone, $start_year, $start_month, $start_day, $start_monthly_spec_type) : $start_hour;
        $_start_minute = ($start_minute === null) ? find_timezone_start_minute_in_utc($timezone, $start_year, $start_month, $start_day, $start_monthly_spec_type) : $start_minute;
        $_end_hour = ($end_hour === null) ? find_timezone_end_hour_in_utc($timezone, $end_year, $end_month, $end_day, $end_monthly_spec_type) : $end_hour;
        $_end_minute = ($end_minute === null) ? find_timezone_end_minute_in_utc($timezone, $end_year, $end_month, $end_day, $end_monthly_spec_type) : $end_minute;

        $_a = cal_get_start_utctime_for_event($timezone, $start_year, $start_month, $start_day, $start_monthly_spec_type, $_start_hour, $_start_minute, $do_timezone_conv == 1);
        $a = cal_utctime_to_usertime(
            $_a,
            $do_timezone_conv == 1
        );
        if (is_null($end_year) || is_null($end_month) || is_null($end_day)) {
            $_b = null;
            $b = null;
        } else {
            $_b = cal_get_end_utctime_for_event($timezone, $end_year, $end_month, $end_day, 'day_of_month'/*Can't have loose end $end_monthly_spec_type*/, $_end_hour, $_end_minute, $do_timezone_conv == 1);
            $b = cal_utctime_to_usertime(
                $_b,
                $do_timezone_conv == 1
            );
        }
        $starts_within = (($a >= $period_start) && ($a < $period_end));
        $ends_within = (($b > $period_start) && ($b <= $period_end));
        $spans = (($a < $period_start) && ($b > $period_end));
        $mask_covers = (in_array($mask[$i % $mask_len], array('1', 'y')));
        if ($mask_covers) {
            if ($starts_within || $ends_within || $spans) {
                $times[] = array(max($period_start, $a), min($period_end, $b), $a, $b, $_a, $_b);
            }
            $happened_count++;
        }
        $i++;

        // Bump start date forward
        $start_year += $dif_year;
        $start_month += $dif_month;
        if ($start_monthly_spec_type == 'day_of_month') {
            $start_day += $dif_day;
            //$start_day_of_month = $start_day;   Is static actually
            list($start_minute, $start_hour, $start_month, $start_day, $start_year) = normalise_time_array(array($start_minute, $start_hour, $start_month, $start_day, $start_year), $timezone);
        } else {
            $_start_hour = ($start_hour === null) ? find_timezone_start_hour_in_utc($timezone, $start_year, $start_month, $start_day, $start_monthly_spec_type) : $start_hour;
            $_start_minute = ($start_minute === null) ? find_timezone_start_minute_in_utc($timezone, $start_year, $start_month, $start_day, $start_monthly_spec_type) : $start_minute;
            $start_day_of_month = find_concrete_day_of_month($start_year, $start_month, $start_day, $start_monthly_spec_type, $_start_hour, $_start_minute, $timezone, $do_timezone_conv == 1);
        }
        // Bump end date forward - or reset it relative to the start date
        if (!is_null($end_year) && !is_null($end_month) && !is_null($end_day)) {
            if ($end_monthly_spec_type == 'day_of_month') {
                // Bump forward simply
                $end_year += $dif_year;
                $end_month += $dif_month;
                $end_day += $dif_day;
                list($end_minute, $end_hour, $end_month, $end_day, $end_year) = normalise_time_array(array($end_minute, $end_hour, $end_month, $end_day, $end_year), $timezone);
            } else {
                // Work out using deltas
                $end_day = $start_day_of_month + $dif_days;
                $end_month = $start_month;
                $end_year = $start_year;
            }
        }

        // Crossing a DST in our reference timezone? (as we store in UTC, which is DST-less, we need to specially accommodate for this)
        _compensate_for_dst_change($start_hour, $start_minute, $start_day, $start_month, $start_year, $timezone, $do_timezone_conv, $dif_day, $dif_month, $dif_year);
        if (!is_null($end_hour)) {
            _compensate_for_dst_change($end_hour, $end_minute, $end_day, $end_month, $end_year, $timezone, $do_timezone_conv, $dif_day, $dif_month, $dif_year);
        }

        // Let it reset
        if ($all_day) {
            $start_hour = null;
            $start_minute = null;
            $end_hour = null;
            $end_minute = null;
            $end_day = null;
            $end_month = null;
            $end_year = null;
        }

        if ($i == intval(get_option('general_safety_listing_limit'))) {
            break; // Let's be reasonable
        }

    } while (
        ($recurrence != '') &&
        ($recurrence != 'none') &&
        ($a < $period_end) &&
        (($recurrences === null) || ($happened_count < $recurrences))
    );

    return $times;
}

/**
 * We have just jumped a UTC-based date (i.e. timezoneless) forward by calendar units, compensate for any DST ramifications in the target timezone.
 *
 * @param  ?integer $hour Current hour (null: no start time)
 * @param  ?integer $minute Current minute (null: no start time)
 * @param  integer $day_of_month Current day
 * @param  integer $month Current month
 * @param  integer $year Current year
 * @param  ID_TEXT $timezone The timezone of the event
 * @param  BINARY $do_timezone_conv Whether the time should be converted to the viewer's own timezone. NOT ACTUALLY USED
 * @param  integer $dif_day Jump in days that just happened
 * @param  integer $dif_month Jump in month that just happened
 * @param  integer $dif_year Jump in year that just happened
 *
 * @ignore
 */
function _compensate_for_dst_change(&$hour, &$minute, $day_of_month, $month, $year, $timezone, $do_timezone_conv, $dif_day, $dif_month, $dif_year)
{
    // NB: When debugging this be a little careful that the DST change for viewing timezone may not be the same as $timezone, so the hour may indeed jump about in expected output  ---  also check e_do_timezone_conv may not be set on the event, as that heavily effects what you see in the timestamp pipeline

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

    $new_time_utc = mktime($hour, $minute, 0, $month, $day_of_month, $year);
    $old_time_utc = mktime($hour, $minute, 0, $month - $dif_month, $day_of_month - $dif_day, $year - $dif_year);
    $second_dif_utc = ($new_time_utc - $old_time_utc);

    $new_time = tz_time($new_time_utc, $timezone);
    $old_time = tz_time($old_time_utc, $timezone);

    $time_dif = ((float)($new_time - $old_time - $second_dif_utc)) / 60.0 / 60.0; // Hours, as a float, that changed
    if (abs($time_dif) >= 1.0) {
        $hour -= intval($time_dif);
        $time_dif -= intval($time_dif);
    }
    $minute -= intval($time_dif * 60.0);
}

/**
 * Get the number of days between two dates (so first+dif=second).
 *
 * @param  integer $initial_start_month Start month
 * @param  integer $initial_start_day Start day
 * @param  integer $initial_start_year Start year
 * @param  integer $initial_end_month End month
 * @param  integer $initial_end_day End day
 * @param  integer $initial_end_year End year
 * @return integer The number of days
 */
function get_days_between($initial_start_month, $initial_start_day, $initial_start_year, $initial_end_month, $initial_end_day, $initial_end_year)
{
    $a_new = mktime(12, 0, 0, $initial_start_month, $initial_start_day, $initial_start_year);
    $b_new = mktime(12, 0, 0, $initial_end_month, $initial_end_day, $initial_end_year);
    return intval(round(floatval($b_new - $a_new) / 86400.0));
}

/**
 * Get a list of event types, taking security into account against the current member.
 *
 * @param  ?AUTO_LINK $it The event type to select by default (null: none)
 * @param  ?TIME $updated_since Time from which content must be updated (null: no limit).
 * @return Tempcode The list
 */
function create_selection_list_event_types($it = null, $updated_since = null)
{
    $type_list = new Tempcode();
    $where = '1=1';
    if (!is_null($updated_since)) {
        $extra_join = '';
        $extra_where = '';
        if (addon_installed('content_privacy')) {
            require_code('content_privacy');
            list($extra_join, $extra_where) = get_privacy_where_clause('event', 'e', $GLOBALS['FORUM_DRIVER']->get_guest_id());
        }
        if (get_option('filter_regions') == '1') {
            require_code('locations');
            $extra_where .= sql_region_filter('event', 'e.id');
        }
        $where .= ' AND EXISTS(SELECT * FROM ' . get_table_prefix() . 'calendar_events e' . $extra_join . ' WHERE validated=1 AND e_add_date>' . strval($updated_since) . $extra_where . ')';
    }
    $types = $GLOBALS['SITE_DB']->query('SELECT id,t_title FROM ' . get_table_prefix() . 'calendar_types WHERE ' . $where, null, null, false, true);
    $first_type = null;
    foreach ($types as $i => $type) {
        $types[$i]['t_title_deref'] = get_translated_text($type['t_title']);
    }
    sort_maps_by($types, 't_title_deref');
    foreach ($types as $type) {
        if (!has_category_access(get_member(), 'calendar', strval($type['id']))) {
            continue;
        }
        if (!has_submit_permission('low', get_member(), get_ip_address(), 'cms_calendar', array('calendar', $type['id']))) {
            continue;
        }

        if ($type['id'] != db_get_first_id()/*not the Commandr-command event type*/) {
            $type_list->attach(form_input_list_entry(strval($type['id']), $type['id'] == $it, get_translated_text($type['t_title'])));
        } else {
            $first_type = $type;
        }
    }
    if ((addon_installed('commandr')) && (has_actual_page_access(get_member(), 'admin_commandr')) && (!is_null($first_type)) && (is_null($GLOBALS['CURRENT_SHARE_USER']))) {
        $type_list->attach(form_input_list_entry(strval(db_get_first_id()), db_get_first_id() == $it, get_translated_text($first_type['t_title'])));
    }

    return $type_list;
}

/**
 * Regenerate all the calendar jobs for reminders for next occurance of an event (because the event was added or edited).
 *
 * @param  AUTO_LINK $id The ID of the event
 * @param  boolean $force Force evaluation even if it's in the past. Only valid for code events
 */
function regenerate_event_reminder_jobs($id, $force = false)
{
    $events = $GLOBALS['SITE_DB']->query_select('calendar_events', array('*'), array('id' => $id), '', 1);

    if (!array_key_exists(0, $events)) {
        return;
    }
    $event = $events[0];

    $GLOBALS['SITE_DB']->query_delete('calendar_jobs', array('j_event_id' => $id));

    $period_start = $force ? 0 : null;
    $start_hour = ($event['e_start_hour'] === null) ? find_timezone_start_hour_in_utc($event['e_timezone'], $event['e_start_year'], $event['e_start_month'], $event['e_start_day'], $event['e_start_monthly_spec_type']) : $event['e_start_hour'];
    $start_minute = ($event['e_start_minute'] === null) ? find_timezone_start_minute_in_utc($event['e_timezone'], $event['e_start_year'], $event['e_start_month'], $event['e_start_day'], $event['e_start_monthly_spec_type']) : $event['e_start_minute'];
    $end_hour = ($event['e_end_hour'] === null) ? find_timezone_end_hour_in_utc($event['e_timezone'], $event['e_end_year'], $event['e_end_month'], $event['e_end_day'], $event['e_end_monthly_spec_type']) : $event['e_end_hour'];
    $end_minute = ($event['e_end_minute'] === null) ? find_timezone_end_minute_in_utc($event['e_timezone'], $event['e_end_year'], $event['e_end_month'], $event['e_end_day'], $event['e_end_monthly_spec_type']) : $event['e_end_minute'];
    $recurrences = find_periods_recurrence($event['e_timezone'], $event['e_do_timezone_conv'], $event['e_start_year'], $event['e_start_month'], $event['e_start_day'], $event['e_start_monthly_spec_type'], $start_hour, $start_minute, $event['e_end_year'], $event['e_end_month'], $event['e_end_day'], $event['e_end_monthly_spec_type'], $end_hour, $end_minute, $event['e_recurrence'], min(1, $event['e_recurrences']), $period_start);
    if ((array_key_exists(0, $recurrences)) && ($recurrences[0][0] == $recurrences[0][2]/*really starts in window, not just spanning it*/)) {
        if ($event['e_type'] == db_get_first_id()) { // Add system command job if necessary
            $GLOBALS['SITE_DB']->query_insert('calendar_jobs', array(
                'j_time' => usertime_to_utctime($recurrences[0][0]),
                'j_reminder_id' => null,
                'j_member_id' => null,
                'j_event_id' => $id
            ));
        } else {
            if (php_function_allowed('set_time_limit')) {
                @set_time_limit(0);
            }

            $start = 0;
            do {
                $reminders = $GLOBALS['SITE_DB']->query_select('calendar_reminders', array('*'), array('e_id' => $id), '', 500, $start);

                foreach ($reminders as $reminder) {
                    $GLOBALS['SITE_DB']->query_insert('calendar_jobs', array(
                        'j_time' => usertime_to_utctime($recurrences[0][0], $reminder['n_member_id']) - $reminder['n_seconds_before'],
                        'j_reminder_id' => $reminder['id'],
                        'j_member_id' => $reminder['n_member_id'],
                        'j_event_id' => $event['id']
                    ));
                }
                $start += 500;
            } while (array_key_exists(0, $reminders));
        }
    }
}

/**
 * Create a neatly human-readable date range, using various user-friendly readability tricks.
 *
 * @param  TIME $from From time in user time
 * @param  ?TIME $to To time in user time (null: no actual to time)
 * @param  boolean $do_time Whether time is included in this date range
 * @param  boolean $force_absolute Whether to force absolute display
 * @param  string $timezone Display timezone
 * @return string Textual specially-formatted range
 */
function date_range($from, $to, $do_time = true, $force_absolute = false, $timezone = '')
{
    if (is_null($to)) {
        return get_timezoned_date($from, true, true, false, true);
    }

    $days = ($to - $from) / (60 * 60 * 24.0);
    if (($to - $from > 60 * 60 * 24) || (!$do_time)) {
        if ($days - intval($days) < 0.1) {
            $days = floor($days); // If it's only 0.1 above a day, we will actually round down. It's useful for stopping confusion around DST changes in particular.
        }
        $_length = do_lang('DAYS', integer_format(intval(ceil($days))));
    } else {
        $_length = display_time_period($to - $from);
    }

    if (($to - $from > 60 * 60 * 24) || (!$do_time) || ($force_absolute)) {
        if (!$do_time) {
            if ($force_absolute) { // Absolute, no time (with length)
                $date1 = cms_strftime(do_lang('calendar_date_verbose'), $from);
                $date2 = cms_strftime(do_lang('calendar_date_verbose'), $to);
            } else {
                return $_length; // No time (with length)
            }
        } else { // Absolute, time (with length)
            $date1 = cms_strftime(do_lang(($to - $from > 60 * 60 * 24 * 5) ? 'calendar_date_range_single_long' : 'calendar_date_range_single'), $from);
            $date2 = cms_strftime(do_lang(($to - $from > 60 * 60 * 24 * 5) ? 'calendar_date_range_single_long' : 'calendar_date_range_single'), $to);
        }
    } else { // Just time (with length)
        $pm_a = date('a', $from);
        $pm_b = date('a', $to);
        if ($pm_a == $pm_b) {
            $date1 = cms_strftime(do_lang('calendar_minute_ampm_known'), $from);
            $date2 = cms_strftime(do_lang('calendar_minute'), $to);
        } else {
            $date1 = cms_strftime(do_lang('calendar_minute'), $from);
            $date2 = cms_strftime(do_lang('calendar_minute'), $to);
        }
        $_date1 = str_replace(do_lang('calendar_minute_no_minutes'), '', $date1);
        $_date2 = str_replace(do_lang('calendar_minute_no_minutes'), '', $date2);
        if ($_date1 != $date1 && $_date2 != $date2) {
            $date1 = $_date1;
            $date2 = $_date2;
        }
        $date = cms_strftime(do_lang('calendar_date_verbose'), $from);
        return do_lang('EVENT_TIME_RANGE_WITHIN_DAY' . (($timezone == '') ? '' : '_WITH_TIMEZONE'), $date, $date1, array($date2, $_length, $timezone));
    }

    return do_lang('EVENT_TIME_RANGE' . (($timezone == '') ? '' : '_WITH_TIMEZONE'), $date1, $date2, array($_length, $timezone));
}

/**
 * Detect calendar matches in a time period, in user-time.
 *
 * @param  MEMBER $auth_member_id The member we are running authentication against
 * @param  MEMBER $member_id The member to detect matches for
 * @param  boolean $restrict Whether to restrict only to viewable events for the current member (rarely pass this as false!)
 * @param  ?TIME $period_start The timestamp that found times must exceed. In user-time (null: use find_periods_recurrence default)
 * @param  ?TIME $period_end The timestamp that found times must not exceed. In user-time (null: use find_periods_recurrence default)
 * @param  ?array $filter The type filter, as used by the calendar module internally (null: none)
 * @param  boolean $do_rss Whether to include RSS/iCal events in the results
 * @param  ?BINARY $private Whether to show private events (1) or public events (0) (null: both public and private)
 * @return array A list of events happening, with time details
 */
function calendar_matches($auth_member_id, $member_id, $restrict, $period_start, $period_end, $filter = null, $do_rss = true, $private = null)
{
    if (is_null($period_start)) {
        $period_start = utctime_to_usertime(time());
    }
    if (is_null($period_end)) {
        $period_end = time() + 60 * 60 * 24 * 365 * 20;
        if (is_float($period_end)) {
            $period_end = max($period_start, 2147483647 - 60 * 60 * 24 * 365 * 2); // Y-2038 issue, so bring back comfortably before that // TODO: #3046 in tracker
        }
        $period_end = utctime_to_usertime($period_end);
    }

    $matches = array();
    $where = '1=1';
    $privacy_join = '';
    if ($restrict) { // privacy permission
        if (addon_installed('content_privacy')) {
            require_code('content_privacy');
            list($privacy_join, $privacy_where) = get_privacy_where_clause('event', 'e', $auth_member_id, 'e.e_member_calendar=' . strval($auth_member_id));
            $where .= $privacy_where;
            if (get_option('filter_regions') == '1') {
                require_code('locations');
                $where .= sql_region_filter('event', 'e.id');
            }
        }
    }
    if ($private === 1) {
        if ($where != '') {
            $where .= ' AND ';
        }
        $where .= '((e_member_calendar=' . strval($member_id) . ') OR (e_submitter=' . strval($member_id) . ' AND e_member_calendar IS NOT NULL))';
    }
    if ($private === 0) {
        if ($where != '') {
            $where .= ' AND ';
        }
        $where .= '(e_member_calendar IS NULL)';
    }
    if (!is_null($filter)) {
        foreach ($filter as $a => $b) {
            if ($b == 0) {
                if ($where != '') {
                    $where .= ' AND ';
                }
                $where .= 'e_type<>' . strval(intval(substr($a, 4)));
            }
        }
    }
    if (!has_privilege($auth_member_id, 'see_unvalidated')) {
        if ($where != '') {
            $where .= ' AND ';
        }
        $where .= '(validated=1 OR e_member_calendar=' . strval($auth_member_id) . ' OR e_submitter=' . strval($auth_member_id) . ')';
    }

    if ((addon_installed('syndication_blocks')) && ($do_rss)) {
        // Determine what feeds to overlay
        $feed_urls_todo = array();
        for ($i = 0; $i < 10; $i++) {
            $feed_url = post_param_string('feed_' . strval($i), cms_admirecookie('feed_' . strval($i), ''));
            require_code('users_active_actions');
            cms_setcookie('feed_' . strval($i), $feed_url);
            if (($feed_url != '') && (preg_match('#^[\w\-]*$#', $feed_url) == 0)) {
                $feed_urls_todo[$feed_url] = null;
            }
        }
        $_event_types = list_to_map('id', $GLOBALS['SITE_DB']->query_select('calendar_types', array('id', 't_title', 't_logo', 't_external_feed')));
        foreach ($_event_types as $j => $_event_type) {
            if (($_event_type['t_external_feed'] != '') && ((is_null($filter)) || (!array_key_exists($_event_type['id'], $filter)) || ($filter[$_event_type['id']] == 1)) && (has_category_access(get_member(), 'calendar', strval($_event_type['id'])))) {
                $feed_urls_todo[$_event_type['t_external_feed']] = $_event_type['id'];
            }

            $_event_types[$j]['_title'] = get_translated_text($_event_type['t_title']);
        }
        $event_types = collapse_2d_complexity('_title', 't_logo', $_event_types);

        // Overlay it
        foreach ($feed_urls_todo as $feed_url => $event_type) {
            $temp_file_path = cms_tempnam();
            require_code('files');
            $write_to_file = fopen($temp_file_path, 'wb');
            http_download_file($feed_url, 1024 * 512, false, false, 'Composr', null, null, null, null, null, $write_to_file);

            if (($GLOBALS['HTTP_DOWNLOAD_MIME_TYPE'] == 'text/calendar') || ($GLOBALS['HTTP_DOWNLOAD_MIME_TYPE'] == 'application/octet-stream')) {
                $data = file_get_contents($temp_file_path);

                require_code('calendar_ical');

                $_whole = explode('BEGIN:VCALENDAR', $data);
                $whole = end($_whole);

                $events = explode('BEGIN:VEVENT', $whole);

                $calendar_nodes = array();

                foreach ($events as $key => $items) {
                    $items = preg_replace('#(.+)\n +(.*)\r?\n#', '${1}${2}' . "\n", $items); // Merge split lines

                    $nodes = explode("\n", $items);

                    foreach ($nodes as $_child) {
                        if (strpos($_child, ':') === false) {
                            continue;
                        }

                        $child = array('', '');
                        $in_quotes = false;
                        $j = 0;
                        for ($i = 0; $i < strlen($_child); $i++) {
                            $char = $_child[$i];
                            if ($char == '"') {
                                $in_quotes = !$in_quotes;
                            }
                            if (($j != 1) && (!$in_quotes) && ($char == ':')) {
                                $j++;
                            } else {
                                $child[$j] .= $char;
                            }
                        }

                        $matches2 = array();
                        if (preg_match('#;TZID=(.*)#', $child[0], $matches2)) {
                            $calendar_nodes[$key]['TZID'] = $matches2[1];
                        }
                        $child[0] = preg_replace('#;.*#', '', $child[0]);

                        if (array_key_exists("1", $child) && $child[0] !== 'PRODID' && $child[0] !== 'VERSION' && $child[0] !== 'END') {
                            $calendar_nodes[$key][$child[0]] = str_replace(array('\n', '\,'), array("\n", ','), trim($child[1]));
                        }
                    }
                    if ($key != 0) {
                        list($full_url, $type_id, $type, $recurrence, $recurrences, $seg_recurrences, $title, $content, $priority, , $start_year, $start_month, $start_day, $start_monthly_spec_type, $start_hour, $start_minute, $end_year, $end_month, $end_day, $end_monthly_spec_type, $end_hour, $end_minute, $timezone, $validated, $allow_rating, $allow_comments, $allow_trackbacks, $notes) = get_event_data_ical($calendar_nodes[$key]);

                        $event = array('e_recurrence' => $recurrence, 'e_content' => $content, 'e_title' => $title, 'e_id' => $feed_url, 'e_priority' => $priority, 't_logo' => 'calendar/rss', 'e_recurrences' => $recurrences, 'e_seg_recurrences' => $seg_recurrences, 'e_start_year' => $start_year, 'e_start_month' => $start_month, 'e_start_day' => $start_day, 'e_start_hour' => $start_hour, 'e_start_minute' => $start_minute, 'e_end_year' => $end_year, 'e_end_month' => $end_month, 'e_end_day' => $end_day, 'e_end_hour' => $end_hour, 'e_end_minute' => $end_minute, 'e_timezone' => $timezone, 'e_start_monthly_spec_type' => 'day_of_month', 'e_end_monthly_spec_type' => 'day_of_month', 'validated' => 1);
                        if (!is_null($event_type)) {
                            $event['t_logo'] = $_event_types[$event_type]['t_logo'];
                        }
                        if (!is_null($type)) {
                            $event['t_title'] = $type;
                            if (array_key_exists($type, $event_types)) {
                                $event['t_logo'] = $event_types[$type];
                            }
                        }

                        $their_times = find_periods_recurrence($timezone, 0, $start_year, $start_month, $start_day, $start_monthly_spec_type, $start_hour, $start_minute, $end_year, $end_month, $end_day, $end_monthly_spec_type, $end_hour, $end_minute, $recurrence, $recurrences, $period_start, $period_end);

                        // Now search every combination to see if we can get a hit
                        foreach ($their_times as $their) {
                            $matches[] = array($full_url, $event, $their[0], $their[1], $their[2], $their[3], $their[4], $their[5]);
                        }
                    }
                }
            } else {
                require_code('rss');

                $rss = new CMS_RSS($temp_file_path, true);

                $content = new Tempcode();
                foreach ($rss->gleamed_items as $item) {
                    if (array_key_exists('guid', $item)) {
                        $full_url = $item['guid'];
                    } elseif (array_key_exists('comment_url', $item)) {
                        $full_url = $item['comment_url'];
                    } elseif (array_key_exists('full_url', $item)) {
                        $full_url = $item['full_url'];
                    } else {
                        $full_url = '';
                    }
                    if ((array_key_exists('title', $item)) && (array_key_exists('clean_add_date', $item)) && ($full_url != '')) {
                        $event = array('e_recurrence' => 'none', 'e_content' => array_key_exists('news', $item) ? $item['news'] : '', 'e_title' => $item['title'], 'e_id' => $full_url, 'e_priority' => 'na', 't_logo' => 'calendar/rss', 'e_recurrences' => 1, 'e_seg_recurrences' => '', 'e_timezone' => get_users_timezone(), 'validated' => 1);
                        if (!is_null($event_type)) {
                            $event['t_logo'] = $_event_types[$event_type]['t_logo'];
                        }
                        if (array_key_exists('category', $item)) {
                            $event['t_title'] = $item['category'];
                            if (array_key_exists($item['category'], $event_types)) {
                                $event['t_logo'] = $event_types[$item['category']];
                            }
                        }
                        $from = utctime_to_usertime($item['clean_add_date']);
                        if (($from >= $period_start) && ($from < $period_end)) {
                            $event += array('e_start_year' => intval(date('Y', $from)), 'e_start_month' => intval(date('m', $from)), 'e_start_day' => intval(date('D', $from)), 'e_start_hour' => intval(date('H', $from)), 'e_start_minute' => intval(date('i', $from)), 'e_end_year' => null, 'e_end_month' => null, 'e_end_day' => null, 'e_end_hour' => null, 'e_end_minute' => null, 'e_start_monthly_spec_type' => 'day_of_month', 'e_end_monthly_spec_type' => 'day_of_month');
                            $matches[] = array($full_url, $event, $from, null, $from, null, $from, null);
                        }
                    }
                }
            }

            @unlink($temp_file_path);
        }
    }

    if ($where != '') {
        $where .= ' AND ';
    }
    // Limitation: won't find anything that started *over* a month before $period_start. Which is reasonable.
    $where .= '(((e_start_month>=' . strval(intval(date('m', $period_start)) - 1) . ' AND e_start_year=' . date('Y', $period_start) . ' OR e_start_year>' . date('Y', $period_start) . ') AND (e_start_month<=' . strval(intval(date('m', $period_end)) + 1) . ' AND e_start_year=' . date('Y', $period_end) . ' OR e_start_year<' . date('Y', $period_end) . ')) OR ' . db_string_not_equal_to('e_recurrence', 'none') . ')';

    $where = ' WHERE ' . $where;
    $event_count = $GLOBALS['SITE_DB']->query_value_if_there('SELECT COUNT(*) FROM ' . $GLOBALS['SITE_DB']->get_table_prefix() . 'calendar_events e' . $privacy_join . ' LEFT JOIN ' . $GLOBALS['SITE_DB']->get_table_prefix() . 'calendar_types t ON e.e_type=t.id' . $where);
    if ($event_count > intval(get_option('general_safety_listing_limit')) * 5) {
        attach_message(do_lang_tempcode('TOO_MANY_TO_CHOOSE_FROM'), 'notice');
        return array();
    }
    $events = $GLOBALS['SITE_DB']->query('SELECT *,e.id AS e_id FROM ' . $GLOBALS['SITE_DB']->get_table_prefix() . 'calendar_events e' . $privacy_join . ' LEFT JOIN ' . $GLOBALS['SITE_DB']->get_table_prefix() . 'calendar_types t ON e.e_type=t.id' . $where);
    foreach ($events as $event) {
        if (!has_category_access(get_member(), 'calendar', strval($event['e_type']))) {
            continue;
        }

        $their_times = find_periods_recurrence($event['e_timezone'], $event['e_do_timezone_conv'], $event['e_start_year'], $event['e_start_month'], $event['e_start_day'], $event['e_start_monthly_spec_type'], $event['e_start_hour'], $event['e_start_minute'], $event['e_end_year'], $event['e_end_month'], $event['e_end_day'], $event['e_end_monthly_spec_type'], $event['e_end_hour'], $event['e_end_minute'], $event['e_recurrence'], $event['e_recurrences'], $period_start, $period_end);

        // Now search every combination to see if we can get a hit
        foreach ($their_times as $their) {
            $matches[] = array($event['e_id'], $event, $their[0], $their[1], $their[2], $their[3], $their[4], $their[5]);
        }
    }

    sort_maps_by($matches, 2);

    return $matches;
}

/**
 * Get a list of events to edit.
 *
 * @param  ?MEMBER $only_owned Only show events owned by this member (null: no such limitation)
 * @param  ?AUTO_LINK $it Event to select by default (null: no specific default)
 * @param  boolean $edit_viewable_events Whether owned public events should be shown
 * @return Tempcode The list
 */
function create_selection_list_events($only_owned, $it, $edit_viewable_events = true)
{
    $where = array();
    if (!is_null($only_owned)) {
        $where['e_submitter'] = $only_owned;
    }
    if ($GLOBALS['SITE_DB']->query_select_value('calendar_events', 'COUNT(*)') > intval(get_option('general_safety_listing_limit'))) {
        warn_exit(do_lang_tempcode('TOO_MANY_TO_CHOOSE_FROM'));
    }
    $events = $GLOBALS['SITE_DB']->query_select('calendar_events', array('id', 'e_title', 'e_type'), $where);
    $list = new Tempcode();
    foreach ($events as $event) {
        if (!has_category_access(get_member(), 'calendar', strval($event['e_type']))) {
            continue;
        }

        $list->attach(form_input_list_entry(strval($event['id']), $event['id'] == $it, get_translated_text($event['e_title'])));
    }

    return $list;
}

/**
 * Detect conflicts with an event at a certain time.
 * NB: Only detects future conflicts, not conflicts on past scheduling.
 *
 * @param  MEMBER $member_id The member to detect conflicts for
 * @param  ?AUTO_LINK $skip_id The event ID that we are detecting conflicts with (we need this so we don't think we conflict with ourself) (null: not added yet)
 * @param  ?integer $start_year The year the event starts at. This and the below are in server time (null: default)
 * @param  ?integer $start_month The month the event starts at (null: default)
 * @param  ?integer $start_day The day the event starts at (null: default)
 * @param  ID_TEXT $start_monthly_spec_type In-month specification type for start date
 * @set day_of_month day_of_month_backwards dow_of_month dow_of_month_backwards
 * @param  ?integer $start_hour The hour the event starts at (null: default)
 * @param  ?integer $start_minute The minute the event starts at (null: default)
 * @param  ?integer $end_year The year the event ends at (null: not a multi day event)
 * @param  ?integer $end_month The month the event ends at (null: not a multi day event)
 * @param  ?integer $end_day The day the event ends at (null: not a multi day event)
 * @param  ID_TEXT $end_monthly_spec_type In-month specification type for end date
 * @set day_of_month day_of_month_backwards dow_of_month dow_of_month_backwards
 * @param  ?integer $end_hour The hour the event ends at (null: not a multi day event)
 * @param  ?integer $end_minute The minute the event ends at (null: not a multi day event)
 * @param  string $recurrence The event recurrence
 * @param  ?integer $recurrences The number of recurrences (null: none/infinite)
 * @param  AUTO_LINK $type The event type
 * @param  ?MEMBER $member_calendar The member calendar (null: none)
 * @param  integer $scope_type The scope type, DETECT_CONFLICT_SCOPE_*
 * @return ?Tempcode Information about conflicts (null: none)
 */
function detect_conflicts($member_id, $skip_id, $start_year, $start_month, $start_day, $start_monthly_spec_type, $start_hour, $start_minute, $end_year, $end_month, $end_day, $end_monthly_spec_type, $end_hour, $end_minute, $recurrence, $recurrences, $type, $member_calendar, $scope_type)
{
    if ($scope_type == DETECT_CONFLICT_SCOPE_NONE) {
        return null;
    }

    $our_times = find_periods_recurrence(get_users_timezone(), 1, $start_year, $start_month, $start_day, $start_monthly_spec_type, $start_hour, $start_minute, $end_year, $end_month, $end_day, $end_monthly_spec_type, $end_hour, $end_minute, $recurrence, $recurrences);

    $conflicts = detect_happening_at($member_id, $skip_id, $our_times, !has_privilege(get_member(), 'sense_personal_conflicts'));

    $out = new Tempcode();
    $found_ids = array();
    foreach ($conflicts as $conflict) {
        list($id, $event, ,) = $conflict;

        // Only show a conflict once
        if (array_key_exists($id, $found_ids)) {
            continue;
        }
        $found_ids[$id] = 1;

        if (is_null($event['e_member_calendar'])) {
            switch ($scope_type) {
                case DETECT_CONFLICT_SCOPE_SAME_MEMBER:
                    continue 2; // Not on a member calendar, so do nothing
                    break;
                case DETECT_CONFLICT_SCOPE_SAME_MEMBER_OR_SAME_TYPE_IF_GLOBAL:
                    if (is_null($member_calendar)) { // if neither global
                        if ($type != $event['e_type']) {
                            continue 2;
                        }
                    }
                    break;
                case DETECT_CONFLICT_SCOPE_SAME_MEMBER_OR_SAME_TYPE:
                    if ($type != $event['e_type']) {
                        /*we know it's not going to be same member, as event is not for a member*/
                        continue 2;
                    }
                    break;
                case DETECT_CONFLICT_SCOPE_ALL:
                    // Always shows conflicts
                    break;
            }
        } else {
            switch ($scope_type) {
                case DETECT_CONFLICT_SCOPE_SAME_MEMBER:
                case DETECT_CONFLICT_SCOPE_SAME_MEMBER_OR_SAME_TYPE_IF_GLOBAL:
                    if ($member_calendar !== $event['e_member_calendar']) {
                        continue 2; // we know one is not global, so we can do a direct compare, knowing null will not equal any member value
                    }
                    break;
                case DETECT_CONFLICT_SCOPE_SAME_MEMBER_OR_SAME_TYPE:
                    if (($type != $event['e_type']) && ($member_calendar !== $event['e_member_calendar']/*we know we don't need to consider a null to null match separately as it can't happen in this branch*/)) {
                        continue 2;
                    }
                    break;
                case DETECT_CONFLICT_SCOPE_ALL:
                    // Always shows conflicts
                    break;
            }
        }

        $protected = false;
        if (addon_installed('content_privacy')) {
            require_code('content_privacy');
            $protected = !has_privacy_access('event', strval($event['id']));
        }

        $url = build_url(array('page' => 'calendar', 'type' => 'view', 'id' => $id), get_module_zone('calendar'));
        $conflict = (!$protected) ? make_string_tempcode(get_translated_text($event['e_title'])) : do_lang_tempcode('PRIVATE_HIDDEN');
        $out->attach(do_template('CALENDAR_EVENT_CONFLICT', array('_GUID' => '2e209eae2dfe2ee74df61c0f4ffe1651', 'URL' => $url, 'ID' => strval($id), 'TITLE' => $conflict)));
    }

    if (!$out->is_empty()) {
        return $out;
    }
    return null;
}

/**
 * Find first hour in day for a timezone.
 *
 * @param  ID_TEXT $timezone The timezone of the event
 * @param  integer $year Year
 * @param  integer $month Month
 * @param  integer $day Day
 * @param  ID_TEXT $monthly_spec_type In-month specification type
 * @set day_of_month day_of_month_backwards dow_of_month dow_of_month_backwards
 * @return integer Hour
 */
function find_timezone_start_hour_in_utc($timezone, $year, $month, $day, $monthly_spec_type)
{
    $_hour = 0;
    $_minute = 0;
    $day = find_concrete_day_of_month($year, $month, $day, $monthly_spec_type, $_hour, $_minute, $timezone, true);
    $t1 = mktime(0, 0, 0, $month, $day, $year);
    $t2 = tz_time($t1, $timezone);
    $t2 -= 2 * ($t2 - $t1);
    $ret = intval(date('H', $t2));
    return $ret;
}

/**
 * Find first minute in day for a timezone. Usually 0, but some timezones have 30 min offsets.
 *
 * @param  ID_TEXT $timezone The timezone of the event
 * @param  integer $year Year
 * @param  integer $month Month
 * @param  integer $day Day
 * @param  ID_TEXT $monthly_spec_type In-month specification type
 * @set day_of_month day_of_month_backwards dow_of_month dow_of_month_backwards
 * @return integer Hour
 */
function find_timezone_start_minute_in_utc($timezone, $year, $month, $day, $monthly_spec_type)
{
    $_hour = 0;
    $_minute = 0;
    $day = find_concrete_day_of_month($year, $month, $day, $monthly_spec_type, $_hour, $_minute, $timezone, true);
    $t1 = mktime(0, 0, 0, $month, $day, $year);
    $t2 = tz_time($t1, $timezone);
    $t2 -= 2 * ($t2 - $t1);
    $ret = intval(date('i', $t2));
    return $ret;
}

/**
 * Find last hour in day for a timezone.
 *
 * @param  ID_TEXT $timezone The timezone of the event
 * @param  integer $year Year
 * @param  integer $month Month
 * @param  integer $day Day
 * @param  ID_TEXT $monthly_spec_type In-month specification type
 * @set day_of_month day_of_month_backwards dow_of_month dow_of_month_backwards
 * @return integer Hour
 */
function find_timezone_end_hour_in_utc($timezone, $year, $month, $day, $monthly_spec_type)
{
    $_hour = 0;
    $_minute = 0;
    $day = find_concrete_day_of_month($year, $month, $day, $monthly_spec_type, $_hour, $_minute, $timezone, true);
    $t1 = mktime(23, 59, 0, $month, $day, $year);
    $t2 = tz_time($t1, $timezone);
    $t2 -= 2 * ($t2 - $t1);
    $ret = intval(date('H', $t2));
    return $ret;
}

/**
 * Find last minute in day for a timezone. Usually 59, but some timezones have 30 min offsets.
 *
 * @param  ID_TEXT $timezone The timezone of the event
 * @param  integer $year Year
 * @param  integer $month Month
 * @param  integer $day Day
 * @param  ID_TEXT $monthly_spec_type In-month specification type
 * @set day_of_month day_of_month_backwards dow_of_month dow_of_month_backwards
 * @return integer Hour
 */
function find_timezone_end_minute_in_utc($timezone, $year, $month, $day, $monthly_spec_type)
{
    $_hour = 0;
    $_minute = 0;
    $day = find_concrete_day_of_month($year, $month, $day, $monthly_spec_type, $_hour, $_minute, $timezone, true);
    $t1 = mktime(23, 59, 0, $month, $day, $year);
    $t2 = tz_time($t1, $timezone);
    $t2 -= 2 * ($t2 - $t1);
    $ret = intval(date('i', $t2));
    return $ret;
}

/**
 * For a time array that may have out-of-range components (in mktime order except without seconds), adjust them so they are in range by rolling other components over as appropriate.
 *
 * @param  array $arr Array of components
 * @param  ID_TEXT $zone The timezone the components are in (this is important as DST-rollovers will vary, so we need to know this to interpret things correctly)
 * @return array Adjusted components
 */
function normalise_time_array($arr, $zone)
{
    list($minute, $hour, $month, $day, $year) = $arr;

    if ($month === null) { // Nothing here
        return $arr;
    }

    @date_default_timezone_set($zone);

    $timestamp = mktime($hour, $minute, 0, $month, $day, $year);

    if ($hour !== null) { // If time known
        $hour = intval(date('H', $timestamp));
        $minute = intval(date('i', $timestamp));
    }
    $month = intval(date('m', $timestamp));
    $day = intval(date('d', $timestamp));
    $year = intval(date('Y', $timestamp));

    date_default_timezone_set('UTC');

    return array($minute, $hour, $month, $day, $year);
}

/**
 * Get the UTC start time for a specified UTC time event.
 *
 * @param  ID_TEXT $timezone The timezone of the event; used to derive $hour and $minute if those are null, such that they start the day correctly for this timezone
 * @param  integer $year Year
 * @param  integer $month Month
 * @param  integer $day Day
 * @param  ID_TEXT $monthly_spec_type In-month specification type
 * @set day_of_month day_of_month_backwards dow_of_month dow_of_month_backwards
 * @param  ?integer $hour Hour (null: start hour of day in the timezone expressed as UTC, for whatever day the given midnight day/month/year shifts to after timezone conversion)
 * @param  ?integer $minute Minute (null: start minute of day in the timezone expressed as UTC, for whatever day the given midnight day/month/year shifts to after timezone conversion)
 * @param  boolean $show_in_users_timezone Whether the time will be converted to the $timezone instead of UTC *later*. If not then the "UTC time" returned is actually guaged for $timezone, as that's how it was opted to be displayed.
 * @return TIME Timestamp
 */
function cal_get_start_utctime_for_event($timezone, $year, $month, $day, $monthly_spec_type, $hour, $minute, $show_in_users_timezone)
{
    $day = find_concrete_day_of_month($year, $month, $day, $monthly_spec_type, $hour, $minute, $timezone, $show_in_users_timezone);

    $_hour = ($hour === null) ? find_timezone_start_hour_in_utc($timezone, $year, $month, $day, $monthly_spec_type) : $hour;
    $_minute = ($minute === null) ? find_timezone_start_minute_in_utc($timezone, $year, $month, $day, $monthly_spec_type) : $minute;

    $timestamp = mktime(
        $_hour,
        $_minute,
        0,
        $month,
        $day,
        $year
    );

    if ((!$show_in_users_timezone) || (get_option('allow_international') === '0')) { // Move into timezone, as if that is UTC, as it won't get converted later
        $timestamp = tz_time($timestamp, $timezone);
    }

    return $timestamp;
}

/**
 * Get the UTC end time for a specified UTC time event.
 *
 * @param  ID_TEXT $timezone The timezone of the event
 * @param  integer $year Year
 * @param  integer $month Month
 * @param  integer $day Day
 * @param  ID_TEXT $monthly_spec_type In-month specification type
 * @set day_of_month day_of_month_backwards dow_of_month dow_of_month_backwards
 * @param  ?integer $hour Hour (null: end hour of day in the timezone expressed as UTC, for whatever day the given midnight day/month/year shifts to after timezone conversion)
 * @param  ?integer $minute Minute (null: end minute of day in the timezone expressed as UTC, for whatever day the given midnight day/month/year shifts to after timezone conversion)
 * @param  boolean $show_in_users_timezone Whether the time will be converted to the $timezone instead of UTC *later*. If not then the "UTC time" returned is actually guaged for $timezone, as that's how it was opted to be displayed.
 * @return TIME Timestamp
 */
function cal_get_end_utctime_for_event($timezone, $year, $month, $day, $monthly_spec_type, $hour, $minute, $show_in_users_timezone)
{
    $day = find_concrete_day_of_month($year, $month, $day, $monthly_spec_type, $hour, $minute, $timezone, $show_in_users_timezone);

    $_hour = ($hour === null) ? find_timezone_end_hour_in_utc($timezone, $year, $month, $day, $monthly_spec_type) : $hour;
    $_minute = ($minute === null) ? find_timezone_end_minute_in_utc($timezone, $year, $month, $day, $monthly_spec_type) : $minute;

    $timestamp = mktime(
        $_hour,
        $_minute,
        0,
        $month,
        $day,
        $year
    );

    if ((!$show_in_users_timezone) || (get_option('allow_international') === '0')) { // Move into timezone, as if that is UTC, as it won't get converted later (in cal_utctime_to_usertime)
        $timestamp = tz_time($timestamp, $timezone);
    }

    return $timestamp;
}

/**
 * Put a timestamp into the correct timezone for being reported onto the calendar.
 *
 * @param  TIME $timestamp Timestamp (either UTC, if $show_in_users_timezone is true, or converted to the timezone of the event if $show_in_users_timezone is false)
 * @param  boolean $show_in_users_timezone Whether the time should be converted to the viewer's own timezone instead (if so $timestamp must be in UTC)
 * @return TIME Altered timestamp
 */
function cal_utctime_to_usertime($timestamp, $show_in_users_timezone)
{
    if ((!$show_in_users_timezone) || (get_option('allow_international') === '0')) {
        return $timestamp;
    }
    return tz_time($timestamp, get_users_timezone());
}

/**
 * Detect conflicts with an event in certain time periods.
 *
 * @param  MEMBER $member_id The member to detect conflicts for
 * @param  ?AUTO_LINK $skip_id The event ID that we are detecting conflicts with (we need this so we don't think we conflict with ourself) (null: not added yet)
 * @param  array $our_times List of pairs specifying our happening time (in time order)
 * @param  boolean $restrict Whether to restrict only to viewable events for the current member
 * @param  ?TIME $period_start The timestamp that found times must exceed. In user-time (null: use find_periods_recurrence default)
 * @param  ?TIME $period_end The timestamp that found times must not exceed. In user-time (null: use find_periods_recurrence default)
 * @return array A list of events happening, with time details
 */
function detect_happening_at($member_id, $skip_id, $our_times, $restrict = true, $period_start = null, $period_end = null)
{
    if (count($our_times) == 0) {
        return array();
    }

    $conflicts = array();
    $table = 'calendar_events e';
    $where = is_null($skip_id) ? '1=1' : ('id<>' . strval($skip_id));
    if ($restrict) {
        if (addon_installed('content_privacy')) {
            require_code('content_privacy');
            list($privacy_join, $privacy_where) = get_privacy_where_clause('event', 'e', $member_id, 'e.e_member_calendar=' . strval($member_id));
            $table .= $privacy_join;
            $where .= $privacy_where;
        }
    }
    if ($where != '') {
        $where .= ' AND ';
    }
    $where .= 'validated=1';
    $where .= ' AND (((e_start_month>=' . strval(intval(date('m', $our_times[0][0])) - 1) . ' AND e_start_year=' . date('Y', $our_times[0][0]) . ') AND (e_start_month<=' . strval(intval(date('m', $our_times[0][1])) + 1) . ' AND e_start_year=' . date('Y', $our_times[0][1]) . ' OR e_start_year<' . date('Y', $our_times[0][1]) . ')) OR ' . db_string_not_equal_to('e_recurrence', 'none') . ')';
    $where = ' WHERE ' . $where;
    $events = $GLOBALS['SITE_DB']->query('SELECT *,e.id AS e_id FROM ' . $GLOBALS['SITE_DB']->get_table_prefix() . $table . $where);
    foreach ($events as $event) {
        if (!has_category_access(get_member(), 'calendar', strval($event['e_type']))) {
            continue;
        }

        $_start_hour = ($event['e_start_hour'] === null) ? find_timezone_start_hour_in_utc($event['e_timezone'], $event['e_start_year'], $event['e_start_month'], $event['e_start_day'], $event['e_start_monthly_spec_type']) : $event['e_start_hour'];
        $_start_minute = ($event['e_start_minute'] === null) ? find_timezone_start_minute_in_utc($event['e_timezone'], $event['e_start_year'], $event['e_start_month'], $event['e_start_day'], $event['e_start_monthly_spec_type']) : $event['e_start_minute'];
        $_end_hour = ($event['e_end_hour'] === null) ? find_timezone_end_hour_in_utc($event['e_timezone'], $event['e_end_year'], $event['e_end_month'], $event['e_end_day'], $event['e_end_monthly_spec_type']) : $event['e_end_hour'];
        $_end_minute = ($event['e_end_minute'] === null) ? find_timezone_end_minute_in_utc($event['e_timezone'], $event['e_end_year'], $event['e_end_month'], $event['e_end_day'], $event['e_end_monthly_spec_type']) : $event['e_end_minute'];

        $their_times = find_periods_recurrence(
            $event['e_timezone'],
            1,
            $event['e_start_year'],
            $event['e_start_month'],
            $event['e_start_day'],
            $event['e_start_monthly_spec_type'],
            $_start_hour,
            $_start_minute,
            $event['e_end_year'],
            $event['e_end_month'],
            $event['e_end_day'],
            $event['e_end_monthly_spec_type'],
            $_end_hour,
            $_end_minute,
            $event['e_recurrence'],
            $event['e_recurrences'],
            $period_start,
            $period_end
        );

        // Now search every combination to see if we can get a hit
        foreach ($our_times as $our) {
            foreach ($their_times as $their) {
                $conflict = false;

                if ((is_null($our[3])) && (is_null($their[3]))) { // Has to be exactly the same
                    if ($our[2] == $their[2]) {
                        $conflict = true;
                    }
                } elseif ((is_null($our[3])) && (!is_null($their[3]))) { // Ours has to occur within their period
                    if (($our[2] >= $their[2]) && ($our[2] < $their[3])) {
                        $conflict = true;
                    }
                } elseif ((!is_null($our[3])) && (is_null($their[3]))) { // Theirs has to occur within our period
                    if (($their[2] >= $our[2]) && ($their[2] < $our[3])) {
                        $conflict = true;
                    }
                } elseif ((!is_null($our[3])) && (!is_null($their[3]))) { // The two periods need to overlap
                    if (($our[2] >= $their[2]) && ($our[2] < $their[3])) {
                        $conflict = true;
                    }
                    if (($their[2] >= $our[2]) && ($their[2] < $our[3])) {
                        $conflict = true;
                    }
                }

                if ($conflict) {
                    $conflicts[] = array($event['e_id'], $event, $their[2], $their[3]);
                    break 2;
                }
            }
        }
    }

    return $conflicts;
}

/**
 * Given a specially encoded day of month, work out the real day of the month.
 *
 * @param  integer $year The concrete year
 * @param  integer $month The concrete month
 * @param  integer $day The encoded day of month
 * @param  ID_TEXT $monthly_spec_type In-month specification type
 * @set day_of_month day_of_month_backwards dow_of_month dow_of_month_backwards
 * @param  integer $hour The concrete hour
 * @param  integer $minute The concrete minute
 * @param  ID_TEXT $timezone The timezone of the event
 * @param  boolean $show_in_users_timezone Whether to do a timezone conversion (NB: unused, as this is before conversion to what dates users see - we are only using timezones here to push the nth weekday appropriately to the correct timezone, due to alignment problems)
 * @return integer Concrete day
 */
function find_concrete_day_of_month($year, $month, $day, $monthly_spec_type, $hour, $minute, $timezone, $show_in_users_timezone)
{
    switch ($monthly_spec_type) {
        case 'day_of_month':
        default:
            $day_of_month = intval(date('d', mktime($hour, $minute, 0, $month, $day, $year)));
            break;
        case 'day_of_month_backwards':
            $day_of_month = intval(date('d', mktime($hour, $minute, 0, $month + 1, 0, $year))) - $day + 1;
            break;
        case 'dow_of_month':
            $days = array('Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'); // Used to set the repeating sequence $day is for (e.g. 0 means first Monday, 7 means second Monday, and so on)
            $nth = intval(1.0 + floatval($day) / 7.0);

            $month_start = mktime(0, 0, 0, $month, 1, $year);
            if (strtotime('+0 Tuesday', mktime(0, 0, 0, 1, 1, 2013)) != mktime(0, 0, 0, 1, 1, 2013)) {
                $month_start -= 1; // This "-1" is needed on SOME PHP versions, to set the window 1 second before where we're looking to make it find something right at the start of the actual window
            }
            date_default_timezone_set($timezone);
            $lookup = '+' . strval($nth) . ' ' . ($days[$day % 7]);
            $timestamp = strtotime($lookup, $month_start);
            $day_of_month = intval(date('d', $timestamp));
            $month = intval(date('m', $timestamp));
            $year = intval(date('Y', $timestamp));
            date_default_timezone_set('UTC');
            break;
        case 'dow_of_month_backwards':
            $days = array('Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday');
            $nth = intval(1.0 + floatval($day) / 7.0);

            $month_end = mktime(0, 0, 0, $month + 1, 0, $year);
            date_default_timezone_set($timezone);
            $lookup = '-' . strval($nth) . ' ' . ($days[$day % 7]);
            $timestamp = strtotime($lookup, $month_end + 1);
            $day_of_month = intval(date('d', $timestamp));
            $month = intval(date('m', $timestamp));
            $year = intval(date('Y', $timestamp));
            date_default_timezone_set('UTC');
            break;
    }

    return $day_of_month;
}

/**
 * Given a calendar day of month, work out the day of the month within the specified encoding.
 *
 * @param  integer $year The concrete year
 * @param  integer $month The concrete month
 * @param  integer $day_of_month The concrete day of month
 * @param  ID_TEXT $monthly_spec_type In-month specification type
 * @return integer Concrete day
 * @set day_of_month day_of_month_backwards dow_of_month dow_of_month_backwards
 */
function find_abstract_day($year, $month, $day_of_month, $monthly_spec_type)
{
    switch ($monthly_spec_type) {
        case 'day_of_month':
        default:
            $day = $day_of_month;
            break;
        case 'day_of_month_backwards':
            $day = intval(date('d', mktime(0, 0, 0, $month + 1, 0, $year))) - $day_of_month + 1;
            break;
        case 'dow_of_month':
            $day_code = intval(date('w', mktime(0, 0, 0, $month, $day_of_month, $year)));

            // Monday is 0 in my mind, not Sunday
            $day_code--;
            if ($day_code == -1) {
                $day_code = 6;
            }

            $day = $day_code + 7 * intval(floatval($day_of_month - 1) / 7.0); // -1 is because we are counting from 0 in our new scale, while $day_of_month was counting from 1
            break;
        case 'dow_of_month_backwards':
            $day_code = intval(date('w', mktime(0, 0, 0, $month, $day_of_month, $year)));

            // Monday is 0 in my mind, not Sunday
            $day_code--;
            if ($day_code == -1) {
                $day_code = 6;
            }

            $month_end = mktime(0, 0, 0, $month + 1, 0, $year);
            $days_in_month = intval(date('d', $month_end));

            $day = $day_code + 7 * intval(floatval($days_in_month - ($day_of_month - 1)) / 7.0);
            break;
    }
    return $day;
}

/**
 * Choose how a recurring monthly event should be encoded.
 * This function is timezone-agnostic.
 *
 * @param  integer $day_of_month The concrete day
 * @param  integer $month The concrete month
 * @param  integer $year The concrete year
 * @param  ID_TEXT $default_monthly_spec_type Current in-month specification type
 * @set day_of_month day_of_month_backwards dow_of_month dow_of_month_backwards
 * @return Tempcode Chooser
 */
function monthly_spec_type_chooser($day_of_month, $month, $year, $default_monthly_spec_type = 'day_of_month')
{
    require_code('form_templates');
    require_lang('calendar');

    $radios = new Tempcode();

    foreach (array('day_of_month', 'day_of_month_backwards', 'dow_of_month', 'dow_of_month_backwards') as $monthly_spec_type) {
        $day = find_abstract_day($year, $month, $day_of_month, $monthly_spec_type);
        $timestamp = mktime(0, 0, 0, $month, $day_of_month, $year);

        if (substr($monthly_spec_type, 0, 4) == 'dow_') {
            $nth = cms_strftime(do_lang('calendar_day_of_month'), mktime(0, 0, 0, 1, intval(floatval($day) / 7.0) + 1, $year)); // Bit of a hack. Uses the date locales nth stuff, even when it's not actually a day-of-month here.
        } else {
            $nth = cms_strftime(do_lang('calendar_day_of_month'), mktime(0, 0, 0, $month, $day, $year)); // Bit of a hack. Uses the date locales nth stuff, even when it's not actually a day-of-month here.
        }
        $dow = cms_strftime('%a', $timestamp);

        $month_name = cms_strftime('%b', $timestamp);

        $text = do_lang_tempcode('CALENDAR_MONTHLY_RECURRENCE_CONCRETE_' . $monthly_spec_type, escape_html($nth), escape_html($dow), escape_html($month_name));
        $description = do_lang_tempcode('CALENDAR_MONTHLY_RECURRENCE_' . $monthly_spec_type);

        $radios->attach(form_input_radio_entry('monthly_spec_type', $monthly_spec_type, $monthly_spec_type == $default_monthly_spec_type, $text, null, $description));
    }

    return form_input_radio(do_lang_tempcode('MONTHLY_SPEC_TYPE'), do_lang_tempcode('DESCRIPTION_MONTHLY_SPEC_TYPE'), 'monthly_spec_type', $radios, true);
}

/**
 * Adjust an event row to match a recurrence on a specific day.
 *
 * @param  string $day A day (Y-m-d)
 * @param  array $event The event row
 * @param  string $timezone Timezone of the viewer
 * @return array Adjusted event row
 */
function adjust_event_dates_for_a_recurrence($day, $event, $timezone)
{
    $explode = explode('-', $day);
    if (count($explode) == 3) {
        $recurrence_start_day = intval($explode[2]);
        $recurrence_start_month = intval($explode[1]);
        $recurrence_start_year = intval($explode[0]);

        $orig_start_day = $event['e_start_day'];
        $orig_start_month = $event['e_start_month'];
        $orig_start_year = $event['e_start_year'];
        $orig_concrete_start_day = start_find_concrete_day_of_month_wrap($event);

        // Adjust for the fact that this was given in the user's timezone, while event is in UTC
        $start_hour = ($event['e_start_hour'] === null) ? find_timezone_start_hour_in_utc($event['e_timezone'], $event['e_start_year'], $event['e_start_month'], $event['e_start_day'], $event['e_start_monthly_spec_type']) : $event['e_start_hour'];
        $start_minute = ($event['e_start_minute'] === null) ? find_timezone_start_minute_in_utc($event['e_timezone'], $event['e_start_year'], $event['e_start_month'], $event['e_start_day'], $event['e_start_monthly_spec_type']) : $event['e_start_minute'];
        $end_hour = ($event['e_end_hour'] === null) ? find_timezone_end_hour_in_utc($event['e_timezone'], $event['e_end_year'], $event['e_end_month'], $event['e_end_day'], $event['e_end_monthly_spec_type']) : $event['e_end_hour'];
        $end_minute = ($event['e_end_minute'] === null) ? find_timezone_end_minute_in_utc($event['e_timezone'], $event['e_end_year'], $event['e_end_month'], $event['e_end_day'], $event['e_end_monthly_spec_type']) : $event['e_end_minute'];
        //$first_timestamp = mktime($start_hour, $start_minute, 0, $orig_start_month, $orig_concrete_start_day, $orig_start_year);	Wrong, DST could be issue
        $incident_timestamp = mktime($start_hour, $start_minute, 0, $recurrence_start_month, $recurrence_start_day, $recurrence_start_year);
        $shifted_incident_timestamp = tz_time($incident_timestamp, $timezone);
        $day_dif_due_to_timezone = intval(date('z', $incident_timestamp)) - intval(date('z', $shifted_incident_timestamp));
        if ($day_dif_due_to_timezone > 182) {
            $days_in_year = intval(date('z', mktime(0, 0, 0, 12, 31, $recurrence_start_year))) + 1;
            $day_dif_due_to_timezone = ($days_in_year - intval(date('L', $incident_timestamp))) - $day_dif_due_to_timezone;
        }
        $recurrence_start_day += $day_dif_due_to_timezone;

        $has_end_date = (!is_null($event['e_end_year'])) && (!is_null($event['e_end_month'])) && (!is_null($event['e_end_day']));

        if ($has_end_date) {
            $orig_end_day = $event['e_end_day'];
            $orig_end_month = $event['e_end_month'];
            $orig_end_year = $event['e_end_year'];

            $event = resolve_complex_event_end_date($event); // Lock down the end date to be a regular calendar one, so we know our calculations on it can be simple. It must be defined relative to the start date of the first recurrence
        }

        // Set the start date to this recurrence
        $event['e_start_day'] = $recurrence_start_day;
        $event['e_start_month'] = $recurrence_start_month;
        $event['e_start_year'] = $recurrence_start_year;
        $event['e_start_monthly_spec_type'] = 'day_of_month';

        if (!is_null($event['e_start_hour'])) {
            list($dif_hours, $dif_minutes) = dst_boundary_difference_for_recurrence($orig_start_year, $orig_start_month, $orig_start_day, $event['e_start_year'], $event['e_start_month'], $event['e_start_day'], $event['e_timezone']);
            $event['e_start_hour'] += $dif_hours;
            $event['e_start_minute'] += $dif_minutes;
        }

        if ($has_end_date) {
            $event['e_end_day'] += $recurrence_start_day - $orig_concrete_start_day;
            $event['e_end_month'] += $recurrence_start_month - $orig_start_month;
            $event['e_end_year'] += $recurrence_start_year - $orig_start_year;

            // Fix to be a proper calendar date (removes out of range values by carrying over)
            $event_end_time = mktime(0, 0, 0, $event['e_end_month'], $event['e_end_day'], $event['e_end_year']);
            $event['e_end_day'] = intval(date('d', $event_end_time));
            $event['e_end_month'] = intval(date('m', $event_end_time));
            $event['e_end_year'] = intval(date('Y', $event_end_time));

            if (!is_null($event['e_end_hour'])) {
                list($dif_hours, $dif_minutes) = dst_boundary_difference_for_recurrence($orig_end_year, $orig_end_month, $orig_end_day, $event['e_end_year'], $event['e_end_month'], $event['e_end_day'], $event['e_timezone']);
                $event['e_end_hour'] += $dif_hours;
                $event['e_end_minute'] += $dif_minutes;
            }
        }
    }

    return $event;
}

/**
 * An event moved from 'a' to 'b' may have an hour/minute shift due to a DST.
 *
 * @param  integer $a_year 'A' year
 * @param  integer $a_month 'A' month
 * @param  integer $a_day 'A' day
 * @param  integer $b_year 'B' year
 * @param  integer $b_month 'B' month
 * @param  integer $b_day 'B' day
 * @param  ID_TEXT $timezone The timezone of the event
 * @return array A pair: shift in hours, shift in minutes
 */
function dst_boundary_difference_for_recurrence($a_year, $a_month, $a_day, $b_year, $b_month, $b_day, $timezone)
{
    $dif_hours = intval(date('H', tz_time(mktime(0, 0, 0, $a_month, $a_day, $a_year), $timezone))) - intval(date('H', tz_time(mktime(0, 0, 0, $b_month, $b_day, $b_year), $timezone)));
    $dif_minutes = intval(date('i', tz_time(mktime(0, 0, 0, $a_month, $a_day, $a_year), $timezone))) - intval(date('i', tz_time(mktime(0, 0, 0, $b_month, $b_day, $b_year), $timezone)));
    return array($dif_hours, $dif_minutes);
}

/**
 * An event may have a complex end date (e.g. 4th Friday).
 * We want to fix it to a calendar day for the recurrence (which we assume is already fixed into the event row).
 * We also want to define it (trick it) to be stated in the same month of the start date, even if that means the days will exceed the number of days in a month.
 * This will allow us to do shifts around in calendar-space.
 *
 * @param  array $event Event row
 * @return array Event row
 */
function resolve_complex_event_end_date($event)
{
    if ((!is_null($event['e_end_year'])) && (!is_null($event['e_end_month'])) && (!is_null($event['e_end_day']))) {
        if ($event['e_end_monthly_spec_type'] != 'day_of_month') {
            $concrete_start_day = start_find_concrete_day_of_month_wrap($event);
            $concrete_end_day = end_find_concrete_day_of_month_wrap($event);
            $dif_days = get_days_between($event['e_start_month'], $concrete_start_day, $event['e_start_year'], $event['e_end_month'], $concrete_end_day, $event['e_end_year']);

            $event['e_end_monthly_spec_type'] = 'day_of_month';
            $event['e_end_day'] = $concrete_start_day + $dif_days;
            $event['e_end_month'] = $event['e_start_month'];
            $event['e_end_year'] = $event['e_start_year'];
        }
    }

    return $event;
}

/**
 * Find the timestamp of an event's start.
 *
 * @param  array $event Event row
 * @return array A pair: timestamp, timestamp considering the viewing users timezone
 */
function find_event_start_timestamp($event)
{
    $time = cal_get_start_utctime_for_event($event['e_timezone'], $event['e_start_year'], $event['e_start_month'], $event['e_start_day'], $event['e_start_monthly_spec_type'], $event['e_start_hour'], $event['e_start_minute'], $event['e_do_timezone_conv'] == 1);

    $shifted = cal_utctime_to_usertime(
        $time,
        $event['e_do_timezone_conv'] == 1
    );

    return array($time, $shifted);
}

/**
 * Find the timestamp of an event's end.
 *
 * @param  array $event Event row
 * @return array A pair: timestamp, timestamp considering the viewing users timezone
 */
function find_event_end_timestamp($event)
{
    $time = cal_get_end_utctime_for_event($event['e_timezone'], $event['e_end_year'], $event['e_end_month'], $event['e_end_day'], $event['e_end_monthly_spec_type'], $event['e_end_hour'], $event['e_end_minute'], $event['e_do_timezone_conv'] == 1);

    $shifted = cal_utctime_to_usertime(
        $time,
        $event['e_do_timezone_conv'] == 1
    );

    return array($time, $shifted);
}

/**
 * Find the concrete start day of a month for an event row.
 *
 * @param  array $event Event row
 * @return integer Concrete day
 */
function start_find_concrete_day_of_month_wrap($event)
{
    $start_hour = ($event['e_start_hour'] === null) ? find_timezone_start_hour_in_utc($event['e_timezone'], $event['e_start_year'], $event['e_start_month'], $event['e_start_day'], $event['e_start_monthly_spec_type']) : $event['e_start_hour'];
    $start_minute = ($event['e_start_minute'] === null) ? find_timezone_start_minute_in_utc($event['e_timezone'], $event['e_start_year'], $event['e_start_month'], $event['e_start_day'], $event['e_start_monthly_spec_type']) : $event['e_start_minute'];
    return find_concrete_day_of_month($event['e_start_year'], $event['e_start_month'], $event['e_start_day'], $event['e_start_monthly_spec_type'], $start_hour, $start_minute, $event['e_timezone'], $event['e_do_timezone_conv'] == 1);
}

/**
 * Find the concrete end day of a month for an event row.
 *
 * @param  array $event Event row
 * @return integer Concrete day
 */
function end_find_concrete_day_of_month_wrap($event)
{
    $end_hour = ($event['e_end_hour'] === null) ? find_timezone_end_hour_in_utc($event['e_timezone'], $event['e_end_year'], $event['e_end_month'], $event['e_end_day'], $event['e_end_monthly_spec_type']) : $event['e_end_hour'];
    $end_minute = ($event['e_end_minute'] === null) ? find_timezone_end_minute_in_utc($event['e_timezone'], $event['e_end_year'], $event['e_end_month'], $event['e_end_day'], $event['e_end_monthly_spec_type']) : $event['e_end_minute'];
    return find_concrete_day_of_month($event['e_end_year'], $event['e_end_month'], $event['e_end_day'], $event['e_end_monthly_spec_type'], $end_hour, $end_minute, $event['e_timezone'], $event['e_do_timezone_conv'] == 1);
}

/**
 * Find details of when an event happens. Preferably the next recurrence, but if it is in the past, the first.
 *
 * @param  ?ID_TEXT $timezone The timezone of the event (null: current user's timezone)
 * @param  BINARY $do_timezone_conv Whether the time should be converted to the viewer's own timezone
 * @param  integer $start_year The year the event starts at. This and the below are in server time
 * @param  integer $start_month The month the event starts at
 * @param  integer $start_day The day the event starts at
 * @param  ID_TEXT $start_monthly_spec_type In-month specification type for start date
 * @set day_of_month day_of_month_backwards dow_of_month dow_of_month_backwards
 * @param  integer $start_hour The hour the event starts at
 * @param  integer $start_minute The minute the event starts at
 * @param  ?integer $end_year The year the event ends at (null: not a multi day event)
 * @param  ?integer $end_month The month the event ends at (null: not a multi day event)
 * @param  ?integer $end_day The day the event ends at (null: not a multi day event)
 * @param  ID_TEXT $end_monthly_spec_type In-month specification type for end date
 * @set day_of_month day_of_month_backwards dow_of_month dow_of_month_backwards
 * @param  ?integer $end_hour The hour the event ends at (null: not a multi day event / all day event)
 * @param  ?integer $end_minute The minute the event ends at (null: not a multi day event / all day event)
 * @param  string $recurrence The event recurrence
 * @param  ?integer $recurrences The number of recurrences (null: none/infinite)
 * @param  boolean $force_first Whether to forcibly get the first recurrence, not a future one
 * @return array A tuple: Written date [range], from timestamp, to timestamp
 */
function get_calendar_event_first_date($timezone, $do_timezone_conv, $start_year, $start_month, $start_day, $start_monthly_spec_type, $start_hour, $start_minute, $end_year, $end_month, $end_day, $end_monthly_spec_type, $end_hour, $end_minute, $recurrence, $recurrences, $force_first = false)
{
    if ($timezone === null) {
        $timezone = get_users_timezone();
    }

    if ($force_first) {
        $times = array();
    } else {
        $times = find_periods_recurrence($timezone, $do_timezone_conv, $start_year, $start_month, $start_day, $start_monthly_spec_type, $start_hour, $start_minute, $end_year, $end_month, $end_day, $end_monthly_spec_type, $end_hour, $end_minute, $recurrence, $recurrences);
    }
    if (array_key_exists(0, $times)) {
        $from = $times[0][2];
        $to = $times[0][3];
    } else {
        $_from = cal_get_start_utctime_for_event($timezone, $start_year, $start_month, $start_day, $start_monthly_spec_type, $start_hour, $start_minute, $do_timezone_conv == 1);
        $from = cal_utctime_to_usertime($_from, false);
        $to = mixed();
        if (!is_null($end_year) && !is_null($end_month) && !is_null($end_day)) {
            $_to = cal_get_end_utctime_for_event($timezone, $end_year, $end_month, $end_day, $end_monthly_spec_type, $end_hour, $end_minute, $do_timezone_conv == 1);
            $to = cal_utctime_to_usertime($_to, false);
        }
    }

    $do_time = !is_null($start_hour);
    if (is_null($to)) {
        if (!$do_time) {
            $written_date = cms_strftime(do_lang('calendar_date_verbose'), $from);
        } else {
            $written_date = cms_strftime(do_lang(($to - $from > 60 * 60 * 24 * 5) ? 'calendar_date_range_single_long' : 'calendar_date_range_single'), $from);
        }
    } else {
        $written_date = date_range($from, $to, $do_time, true);
    }

    return array($written_date, $from, $to);
}
