<?php
declare(strict_types=1);
namespace MRBS;

use DateInterval;
use DateTimeZone;
use Email\Parse;
use MRBS\Form\Field;
use MRBS\Form\FieldSelect;
use MRBS\ICalendar\RFC5545;
use PHPMailer\PHPMailer\PHPMailer;
use TypeError;

require_once "mrbs_sql.inc";


// Strips out extra whitespace from a string.  Useful for sanitizing
// user input before it is inserted into the database, eg when the
// user has accidentally typed "Joe  Smith" instead of "Joe Smith".
function remove_extra_whitespace(string $string) : string
{
  return preg_replace('/\s+/', ' ', $string);
}


// Constrain an integer to fit a certain type.  Useful for making sure
// that integers will fit within the range for a database type.
// Only 4 byte unsigned integers supported at the moment.
function constrain_int(int $n, int $bytes, bool $unsigned=false) : int
{
  if (!$unsigned && ($bytes == 4))
  {
    $max_limit = 0x7fffffff;
    $min_limit = -$max_limit;
  }
  else
  {
    throw new \Exception("Other combinations not yet supported");
  }

  return max(min($n, $max_limit), $min_limit);
}


// Case-insensitive in_array
// See https://www.php.net/manual/en/function.in-array.php#Hcom89256
function in_arrayi($needle, array $haystack, bool $strict=false) : bool
{
  return in_array(mb_strtolower($needle),
                  array_map('mb_strtolower', $haystack),
                  $strict);
}



// Checks if a string ends with one of an array of given substrings
function str_ends_with_array(string $haystack, array $needles) : bool
{
  foreach ($needles as $needle)
  {
    if (str_ends_with($haystack, $needle))
    {
      return true;
    }
  }

  return false;
}


function generate_token(int $length) : string
{
  if (function_exists('random_bytes'))
  {
    try
    {
      return bin2hex(random_bytes($length));  // PHP 7 and above
    }
    catch (\Exception $e)
    {
      // random_bytes() will throw an Exception if an appropriate source of randomness cannot be found.
      // If that happens, just fall through and use one of the other methods.
    }
  }

  if (function_exists('openssl_random_pseudo_bytes'))
  {
    return bin2hex(openssl_random_pseudo_bytes($length));
  }

  return md5(uniqid((string)rand(), true));
}


// A locale aware version of strcasecmp()
function strcasecmp_locale(string $string1, string $string2) : int
{
  // Trivial case
  if ($string1 === $string2)
  {
    return 0;
  }

  // Sort the array.  If the order is reversed then $string1 > $string2
  // (When this function is being used as a callback for usort, and if the original array
  // is sorted in ascending order, which it well might be if it's the result of
  // an SQL query with an ORDER BY, then it's fastest to test for $string1 > $string2
  // first, as below.)
  $original_array = array($string1, $string2);
  $array = $original_array;
  asort($array,SORT_LOCALE_STRING | SORT_FLAG_CASE);
  if ($array !== $original_array)
  {
    return 1;
  }
  // Otherwise, flip the array and try again.  If the order is reversed then $string2 > $string1
  $original_array = array($string2, $string1);
  $array = $original_array;
  asort($array,SORT_LOCALE_STRING | SORT_FLAG_CASE);
  if ($array !== $original_array)
  {
    return -1;
  }
  // Otherwise they must be equal
  return 0;
}


// Returns a name in the format last_name first_name for sorting
function get_sortable_name(?string $name) : ?string
{
  global $sort_users_by_last_name;

  if (!isset($name))
  {
    return null;
  }

  if (empty($sort_users_by_last_name))
  {
    return $name;
  }

  // Look for anything in brackets (ordinary or square) at the
  // end of the name and treat it as a suffix.  For example if
  // the display name is "Joe Bloggs (visitor)" then the last name
  // is really "Bloggs".
  $pattern = '/\s*\(.*\)$|[.*]$/';
  if (preg_match($pattern, $name, $matches))
  {
    $suffix = $matches[0];
    $name = preg_replace($pattern, '', $name);
  }


  $tokens = explode(' ', $name);

  // Get rid of other whitespace (eg tabs)
  $tokens = array_map('trim', $tokens);

  // Get the last name
  $result = array_pop($tokens);

  // Add back in the first names
  while (null !== ($token = array_shift($tokens)))
  {
    if ($token !== '') // weeds out multiple spaces in a name
    {
      $result .= ' ' . $token;
    }
  }

  // Add back in any suffix
  if (isset($suffix))
  {
    $result .= $suffix;
  }

  return $result;
}


function compare_display_names(string $name1, string $name2) : int
{
  return strcasecmp_locale(get_sortable_name($name1), get_sortable_name($name2));
}


// Tests whether the request has come via an Ajax call or not.
function is_ajax() : bool
{
  global $server;

  return (isset($server['HTTP_X_REQUESTED_WITH']) &&
          (mb_strtolower($server['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest'));
}


function get_type_options(bool $include_admin_types) : array
{
  global $booking_types, $auth;

  $result = array();

  if (isset($booking_types))
  {
    foreach ($booking_types as $key)
    {
      if (!$include_admin_types &&
        isset($auth['admin_only_types']) &&
        in_array($key, $auth['admin_only_types']))
      {
        continue;
      }
      $result[$key] = get_type_vocab($key);
    }
  }

  return $result;
}


// Converts an array of types into a string
function get_type_names(?array $types, string $glue=', ') : string
{
  if (empty($types))
  {
    return '';
  }

  $names = array();

  foreach($types as $type)
  {
    $names[] = get_type_vocab($type);
  }

  return implode($glue, $names);
}


// Returns the maximum length, in characters, of a column, or NULL if the maximum
// length is not known.   Should only be called after any database upgrades have been
// performed as the results are stored in a static variable and so the maximum lengths
// may change after an upgrade.  $column is of the form, <table_short_name.column_name>,
// for example, 'area.area_name'.
function maxlength(string $column) : ?int
{
  static $maxlengths = array();

  list($table_short_name, $column_name) = explode('.', $column);

  if (!isset($maxlengths[$table_short_name]))
  {
    $maxlengths[$table_short_name] = array();

    // Check to see if the table exists, as it won't yet if it's about
    // to be created as part of an upgrade
    if (db()->table_exists(_tbl($table_short_name)))
    {
      // Find the maximum length of the CHAR and VARCHAR fields (we won't
      // worry about TEXT fields)
      $field_info = db()->field_info(_tbl($table_short_name));

      foreach ($field_info as $field)
      {
        if (($field['nature'] == 'character') &&
            isset($field['length']) &&
            ($field['length'] < 256))
        {
          $maxlengths[$table_short_name][$field['name']] = (int) $field['length'];
        }
      }
    }
  }

  return (isset($maxlengths[$table_short_name][$column_name])) ? $maxlengths[$table_short_name][$column_name] : null;
}


// Truncate any fields that have a maximum length as a precaution.
// Although the MAXLENGTH attribute is used in the <input> tag, this can
// sometimes be ignored by the browser, for example by Firefox when
// autocompletion is used.  The user could also edit the HTML and remove
// the MAXLENGTH attribute.    Another problem is that the <datalist> tag
// does not accept a maxlength attribute.  Passing an oversize string to some
// databases (eg some versions of PostgreSQL) results in an SQL error,
// rather than silent truncation of the string.
//
// We truncate to a maximum number of UTF8 characters rather than bytes.
// This is OK in current versions of MySQL and PostgreSQL, though in earler
// versions of MySQL (I haven't checked PostgreSQL) this could cause problems
// as a VARCHAR(n) was n bytes long rather than n characters.
function truncate(string $value, string $column) : string
{
  if (null !== ($maxlength = maxlength($column)))
  {
    return mb_substr($value, 0, $maxlength);
  }

  return $value;
}


// Formats a number taking into account the current locale.  (Could use
// the NumberFormatter class, but the intl extension isn't always installed.)
function number_format_locale($number, int $decimals=0) : string
{
  $locale_info = localeconv();
  return number_format($number, $decimals, $locale_info['decimal_point'], $locale_info['thousands_sep']);
}


function get_microtime()
{
  if (function_exists('microtime'))
  {
    list($usec, $sec) = explode(" ", microtime());
    return ((float)$usec + (float)$sec);
  }
  else
  {
    return time();
  }
}


function function_disabled(string $name) : bool
{
  $disabled = preg_split('/\s*,\s*/', ini_get('disable_functions'));
  return in_array($name, $disabled);
}


function function_available(string $name) : bool
{
  return function_exists($name) && !function_disabled($name);
}


// Set the default timezone.   If $tz is not set, then the default MRBS
// timezone from the config file is used.
function mrbs_default_timezone_set(?string $tz=null) : void
{
  global $area_defaults;

  if (!isset($tz))
  {
    if (isset($area_defaults['timezone']))
    {
      $tz = $area_defaults['timezone'];
    }
    else
    {
      // This should have been picked up before now, but just in case ...

      // We don't just use a default default timezone such as UTC because then
      // people would start running into DST problems with their bookings.
      $message = 'MRBS configuration error: $timezone has not been set.';
      // Use die() rather than Errors::fatalError() because unless we have set the timezone
      // PHP starts complaining bitterly if we try and do anything remotely complicated.
      die($message);
    }
  }

  if (!date_default_timezone_set($tz))
  {
    $message = "MRBS configuration error: invalid timezone '$tz'";
    die($message);  // See comment above about use of die()
  }

}


// Get the default timezone.
// The MRBS wrapper round date_default_timezone_get() is redundant now.  It used to
// cater for PHP servers that don't have date_default_timezone_get(), but now that
// the minimum PHP version is >= 5.1.0, there are none.
function mrbs_default_timezone_get() : string
{
  return date_default_timezone_get();
}


function format_compound_name(string $username, string $display_name) : string
{
  return ($username === $display_name) ? $username : get_vocab('compound_name', $username, $display_name);
}


// Gets the username and display name, if different.  Useful in places such as search and
// report results, where you have to search by username - because that's what's stored in
// the entry - but you are interested in the display name.
function get_compound_name(?string $username) : string
{
  if (isset($username))
  {
    $display_name = auth()->getDisplayName($username);
    return (isset($display_name) && ($display_name !== '')) ? format_compound_name($username, $display_name) : $username;
  }

  return '';
}


function get_cookie_path()
{
  global $cookie_path_override, $server;

  if (isset($cookie_path_override))
  {
    $cookie_path = $cookie_path_override;
  }
  else
  {
    // REQUEST_URI isn't set on all PHP systems, so fall back to PHP_SELF
    $cookie_path = $server['REQUEST_URI'] ?? $server['PHP_SELF'];
    // Strip off everything after the last '/'
    $cookie_path = preg_replace('/[^\/]*$/', '', $cookie_path);
    // Some cases have been observed of the cookie path being set to, for example,
    // '/mrbs/ajax/' or '/mrbs/js/'.  It's not clear how this can happen (possibly
    // some kind of timing issue when an Ajax request arrives before the main page??)
    // but in case it does, strip off any trailing 'ajax/' or 'js/' string.
    $cookie_path = preg_replace('/(ajax|js)\/$/', '', $cookie_path);
  }

  return $cookie_path;
}


// Gets the url path from either $_SERVER['PHP_SELF'] or $_SERVER['REQUEST_URI'], whichever
// happens to be non-empty.  (Some systems will not have one or the other variable set).
function url_path()
{
  global $server;

  if (isset($server['PHP_SELF']) && ($server['PHP_SELF'] !== ''))
  {
    $result = $server['PHP_SELF'];
  }
  elseif (isset($server['REQUEST_URI']) && ($server['REQUEST_URI'] !== ''))
  {
    // The REQUEST_URI includes the query string, so we need to strip it off
    $result = parse_url($server['REQUEST_URI'], PHP_URL_PATH);
    if ($result === false)
    {
      trigger_error('Cannot parse $_SERVER["REQUEST_URI"] of "' . $server['REQUEST_URI'] . '"', E_USER_NOTICE);
      $result = '';
    }
  }
  else
  {
    if (!is_cli())
    {
      trigger_error('$_SERVER["PHP_SELF"] and $_SERVER["REQUEST_URI"] are both empty.  Check your server configuration.', E_USER_NOTICE);
    }
    $result = '';
  }

  return $result;
}


// Returns the base URL for MRBS.  If the config variable $url_base is set then that
// overrides the automatic calculation.  A '/' will be appended to the end of the string,
// unless it is empty or already ends in a '/'.
function url_base() : string
{
  global $url_base, $server;

  if (isset($url_base))
  {
    $result = $url_base;
  }
  elseif (isset($server['HTTP_HOST']))
  {
    $result = $server['HTTP_HOST'] . dirname(url_path());
  }
  else
  {
    $result = '';
  }

  // Add a scheme if we haven't got one
  if (null === parse_url($result, PHP_URL_SCHEME))
  {
    $result = ((is_https()) ? 'https' : 'http') . '://' . $result;
  }

  // Add a trailing '/' if there isn't one
  if (($result !== '') && (substr($result, -1) !== '/'))
  {
    $result .= '/';
  }

  return $result;
}



function is_https() : bool
{
  global $server;

  if (!empty($server['HTTP_X_FORWARDED_PROTO']))
  {
    return ($server['HTTP_X_FORWARDED_PROTO'] === 'https');
  }
  elseif (!empty($server['HTTP_X_FORWARDED_PROTOCOL']))
  {
    return ($server['HTTP_X_FORWARDED_PROTOCOL'] === 'https');
  }
  elseif (!empty($server['HTTP_X_FORWARDED_SSL']))
  {
    return (strcasecmp($server['HTTP_X_FORWARDED_SSL'], 'on') === 0);
  }

  return (isset($server['HTTPS']) &&
          !empty($server['HTTPS']) &&
          (strcasecmp($server['HTTPS'], 'off') !== 0));
}


// Returns the current page.   If the page ends in $suffix this will be cut off.
// The existing query string is optionally added.
function this_page(bool $with_query_string=false, string $suffix='') : string
{
  global $server;

  if ((!isset($server['SCRIPT_NAME']) || ($server['SCRIPT_NAME'] === '') && !is_cli()))
  {
    trigger_error('$_SERVER["SCRIPT_NAME"] is empty.  Check your server configuration.', E_USER_NOTICE);
  }

  // We use basename() because the full name causes problems when reverse proxies are being used.
  $result = basename($server['SCRIPT_NAME'], $suffix);

  // Get the query string if required
  if ($with_query_string &&
      isset($server['QUERY_STRING']) &&
     ($server['QUERY_STRING'] !== ''))
  {
    $query_string = $server['QUERY_STRING'];
  }
  else
  {
    $query_string = '';
  }

  parse_str($query_string, $query_string_parts);

  // Add the query string back in
  if (!empty($query_string_parts))
  {
    $result .= '?' . http_build_query($query_string_parts, '', '&');
  }

  return $result;
}


// Format a timestamp in RFC 1123 format, for HTTP headers
//
// e.g. Wed, 28 Jul 2010 12:43:58 GMT
function rfc1123_date(int $timestamp) : string
{
  return gmdate("D, d M Y G:i:s \\G\\M\\T", $timestamp);
}


// Rebuilds a url that has been decompomosed into parts by parse_url().
// See https://www.php.net/manual/en/function.parse-url.php#106731
function build_url(array $parts) : string
{
    return (isset($parts['scheme']) ? "{$parts['scheme']}:" : '') .
          ((isset($parts['user']) || isset($parts['host'])) ? '//' : '') .
          (isset($parts['user']) ? "{$parts['user']}" : '') .
          (isset($parts['pass']) ? ":{$parts['pass']}" : '') .
          (isset($parts['user']) ? '@' : '') .
          (isset($parts['host']) ? "{$parts['host']}" : '') .
          (isset($parts['port']) ? ":{$parts['port']}" : '') .
          (isset($parts['path']) ? "{$parts['path']}" : '') .
          (isset($parts['query']) ? "?{$parts['query']}" : '') .
          (isset($parts['fragment']) ? "#{$parts['fragment']}" : '');
}


// A wrapper for sending a Location header.  This function will urlencode and add
// the site parameter as necessary
function location_header(string $location) : void
{
  global $debug;

  if ($debug && (version_compare(MRBS_MIN_PHP_VERSION, '8.1') >= 0))
  {
    trigger_error("The return type can be changed to 'never'.", E_USER_NOTICE);
  }

  // Add the site parameter if necessary
  $location = multisite($location);

  // Encode the query string if there is one
  $parts = parse_url($location);

  if (isset($parts['query']))
  {
    parse_str($parts['query'], $result);
    $parts['query'] = http_build_query($result, '', '&');
  }

  header('Location: ' . build_url($parts));
  exit;
}


function add_query_parameter(string $location, string $name, $value) : string
{
  $parts = parse_url($location);

  if ($parts === false)
  {
    throw new \Exception("Could not parse the URL '$location'");
  }

  if (!isset($parts['query']))
  {
    $parts['query'] = '';
  }

  parse_str($parts['query'], $result);
  $result[$name] = $value;
  $parts['query'] = http_build_query($result, '', '&');
  return build_url($parts);
}


function remove_query_parameter(string $location, string $name) : string
{
  $parts = parse_url($location);

  if ($parts === false)
  {
    throw new \Exception("Could not parse the URL '$location'");
  }

  if (!isset($parts['query']))
  {
    $parts['query'] = '';
  }

  parse_str($parts['query'], $result);
  unset($result[$name]);
  $parts['query'] = http_build_query($result, '', '&');
  return build_url($parts);
}


// Adds the site parameter in the query string if necessary
function multisite(string $location) : string
{
  global $multisite, $site;

  // Add the site, if there is one, to the query string
  if ($multisite && isset($site) && ($site !== ''))
  {
    return add_query_parameter($location, 'site', $site);
  }

  return $location;
}


// Add a version parameter to a filename in order to force the use of the new
// file instead of the cached version
function add_version(string $filename) : string
{
  $url_path = parse_url($filename, PHP_URL_PATH);

  if (is_readable($url_path))
  {
    $mtime = filemtime($url_path);
    if ($mtime !== false)
    {
      return add_query_parameter($filename, 'v', $mtime);
    }
  }

  return $filename;
}


// A little helper function to send an "Expires" header. Just one
// parameter, the number of seconds in the future to set the expiry.
// If $seconds is <= 0, then caching is disabled.
function expires_header(int $seconds) : void
{
  global $debug;

  if (!$debug && ($seconds > 0))
  {
    // We also send a couple of extra headers as the "Expires" header alone
    // does not always result in caching.
    header("Expires: " . rfc1123_date(time() + $seconds));
    header("Pragma: cache");
    header("Cache-Control: max-age=$seconds");
  }
  else
  {
    // Make sure that caching is disabled.   Setting the "Expires" header
    // alone doesn't always turn off caching.
    header("Pragma: no-cache");                          // HTTP 1.0
    header("Expires: Mon, 26 Jul 1997 05:00:00 GMT");    // Date in the past
  }
}


// Outputs the HTTP headers, passed in the array $headers, followed by
// a set of headers to set the cache expiry date.  If $expiry_seconds <= 0
// then caching is disabled.
function http_headers(array $headers, int $expiry_seconds=0) : void
{
  foreach ($headers as $header)
  {
    header($header);
  }

  expires_header($expiry_seconds);
}


// Prints a very simple header.  This may be necessary on occasions, such as
// during a database upgrade, when some of the features that the normal
// header uses are not yet available.
function print_simple_header() : void
{
  print_header(null, true);
}


// Print the page header
// $context is an associative array indexed by 'view', 'view_all', 'year', 'month', 'day', 'area' and 'room',
// any of which can be NULL.
// When $omit_login is true the Login link is omitted.
function print_header(?array $context=null, bool $simple=false, bool $omit_login=false) : void
{
  global $theme;

  static $done_header = false;

  if ($done_header)
  {
    return;
  }

  // Load the print_theme_header function appropriate to the theme.    If there
  // isn't one then fall back to the default header.
  if (is_readable("Themes/$theme/header.inc"))
  {
    include_once "Themes/$theme/header.inc";
  }
  if (!function_exists(__NAMESPACE__ . "\\print_theme_header"))
  {
    require_once "Themes/default/header.inc";
  }

  // Now go and do it
  print_theme_header($context, $simple, $omit_login);

  $done_header = true;
}


// Print the standard footer, currently very simple.  Pass $and_exit as
// TRUE to exit afterwards
function print_footer(bool $and_exit=false) : void
{
  global $theme;

  // Load the print_theme_footer function appropriate to the theme.    If there
  // isn't one then fall back to the default footer.
  if (is_readable("Themes/$theme/footer.inc"))
  {
    include_once "Themes/$theme/footer.inc";
  }
  if (!function_exists(__NAMESPACE__ . "\\print_theme_footer"))
  {
    require_once "Themes/default/footer.inc";
  }

  print_theme_footer();

  if ($and_exit)
  {
    exit(0);
  }
}


// Converts a duration of $dur seconds into a duration of
// $dur $units
// $max_unit can be set to 'seconds', 'minutes', 'hours', etc. and
// is used to specify a maximum size of unit to use.  For example if
// it is set to 'minutes', 3600 seconds will give 60 minutes rather
// than 1 hour.
function toTimeString(int &$dur, &$units, bool $translate=true, ?string $max_unit=null)
{
  if (!isset($max_unit))
  {
    $max_unit = 'years';
  }

  if (($max_unit != 'seconds') && (abs($dur) >= 60) && ($dur % 60 == 0))
  {
    $dur /= 60;

    if (($max_unit != 'minutes') && (abs($dur) >= 60) && ($dur % 60 == 0))
    {
      $dur /= 60;

      if(($max_unit != 'hours') && ((abs($dur) >= 24) && ($dur % 24 == 0)))
      {
        $dur /= 24;

        if(($max_unit != 'days') && ((abs($dur) >= DAYS_PER_WEEK) && ($dur % DAYS_PER_WEEK == 0)))
        {
          $dur /= DAYS_PER_WEEK;

          if (($max_unit != 'weeks') && ((abs($dur) >= 52) && ($dur % 52 == 0)))
          {
            $dur  /= 52;
            $units = 'years';
          }
          else
          {
            $units = 'weeks';
          }
        }
        else
        {
          $units = 'days';
        }
      }
      else
      {
        $units = 'hours';
      }
    }
    else
    {
      $units = 'minutes';
    }
  }
  else
  {
    $units = 'seconds';
  }

  // Limit any floating point values to three decimal places
  if (is_float($dur))
  {
    $dur = sprintf('%.3f', $dur);
    $dur = rtrim($dur, '0');  // removes trailing zeros
  }

  // Translate into local language if required
  if ($translate)
  {
    $units = get_vocab($units);
  }
}


// Converts a time period of $units into seconds, when it is originally
// expressed in $dur_units.   (Almost the inverse of toTimeString(),
// but note that toTimeString() can do language translation)
function fromTimeString(&$units, $dur_units)
{
  if (!isset($units) || !isset($dur_units))
  {
    return;
  }

  switch($dur_units)
  {
    case "years":
      $units *= 52;
    case "weeks":
      $units *= DAYS_PER_WEEK;
    case "days":
      $units *= 24;
    case "hours":
      $units *= 60;
    case "periods":
    case "minutes":
      $units *= 60;
    case "seconds":
      break;
  }
  $units = (int) $units;
}


// Gets the interval in periods for a booking with $start_time and $end_time
// Takes account of DST
function get_period_interval(int $start_time, int $end_time, int $area_id) : int
{
  static $area_details = [];

  if (!isset($area_details[$area_id]))
  {
    $area_details[$area_id] = get_area_details($area_id);
  }

  $periods_per_day = count(json_decode($area_details[$area_id]['periods']));
  $date_time_zone = new DateTimeZone($area_details[$area_id]['timezone']);

  // Create start and end dates and set both dates to noon so that we can compare them
  // and get an integral number of days difference.  Noon also happens to be when
  // periods start, so will be useful in a moment.
  $startDate = new DateTime();
  $startDate
    ->setTimezone($date_time_zone)
    ->setTimestamp($start_time)
    ->modify('12:00');

  $endDate = new DateTime();
  $endDate
    ->setTimezone($date_time_zone)
    ->setTimestamp($end_time)
    ->modify('12:00');

  // Calculate the difference in days
  $interval = $startDate->diff($endDate);
  $interval_days = intval($interval->format('%a'));

  if ($interval_days === 0)
  {
    // If the interval starts and ends on the same day, then we just calculate the number
    // of periods by calculating the number of minutes between the start and end times.
    $result = ($end_time - $start_time)/60;
  }
  else
  {
    // Otherwise we calculate the number of periods on the first day
    $startDate->add(new DateInterval('PT' . $periods_per_day . 'M'));
    $result = get_period_interval($start_time, $startDate->getTimestamp(), $area_id);
    // Add in the number of whole days worth of periods in between
    $result += ($interval_days - 1) * $periods_per_day;
    // And add in the number of periods on the last day
    $result += get_period_interval($endDate->getTimestamp(), $end_time, $area_id);
  }

  return (int) $result;
}


function toPeriodString($start_period, &$dur, &$units, $translate=true)
{
  global $periods;

  $max_periods = count($periods);
  $dur /= 60;  // duration now in minutes
  $days = $dur / MINUTES_PER_DAY;
  $remainder = $dur % MINUTES_PER_DAY;
  // strip out any gap between the end of the last period on one day
  // and the beginning of the first on the next
  if ($remainder > $max_periods)
  {
    $remainder += $max_periods - MINUTES_PER_DAY;
  }

  // We'll express the duration as an integer, in days if possible, otherwise periods
  if (($remainder == 0) || (($start_period == 0) && ($remainder == $max_periods)))
  {
    $dur = (int) $days;
    if ($remainder == $max_periods)
    {
      $dur++;
    }
    $units = $translate ? get_vocab("days") : "days";
  }
  else
  {
    $dur = (intval($days) * $max_periods) + $remainder;
    $units = $translate ? get_vocab("periods") : "periods";
  }
}

// Converts a period of $units starting at $start_period into seconds, when it is
// originally expressed in $dur_units (periods or days).   (Almost the inverse of
// toPeriodString(), but note that toPeriodString() can do language translation)
function fromPeriodString($start_period, &$units, $dur_units)
{
  global $periods;

  if (!isset($units) || !isset($dur_units))
  {
    return;
  }

  // First get the duration in minutes
  $max_periods = count($periods);
  if ($dur_units == "periods")
  {
    $end_period = $start_period + $units;
    if ($end_period > $max_periods)
    {
      $units = (MINUTES_PER_DAY * floor($end_period/$max_periods)) + ($end_period%$max_periods) - $start_period;
    }
  }
  if ($dur_units == "days")
  {
    if ($start_period == 0)
    {
      $units = $max_periods + ($units-1)*MINUTES_PER_DAY;
    }
    else
    {
      $units = $units * MINUTES_PER_DAY;
    }
  }

  // Then convert into seconds
  $units = (int) $units;
  $units = 60 * $units;
}


// Returns the BYDAY value for a given timestamp, eg 4SU for fourth Sunday in
// the month, or -1MO for the last Monday.
function date_byday(int $timestamp) : string
{
  $dow = RFC5545::DAYS[date('w', $timestamp)];
  $dom = date('j', $timestamp);
  $ord = intval(($dom - 1)/DAYS_PER_WEEK) + 1;
  if ($ord == 5)
  {
    $ord = -1;
  }
  return $ord . $dow;
}


// Returns TRUE if the time $hm1 is before $hm2
// $hm1 and $hm2 are associative arrays indexed by 'hours' and 'minutes'.
// The indices are chosen to allow the result of the PHP getdate() function
// to be passed as parameters
function hm_before(array $hm1, array $hm2) : bool
{
  return ($hm1['hours'] < $hm2['hours']) ||
         (($hm1['hours'] == $hm2['hours']) && ($hm1['minutes'] < $hm2['minutes']));
}


// Returns TRUE if the end of the last slot is on the day after the beginning
// of the first slot
function day_past_midnight() : bool
{
  global $morningstarts, $morningstarts_minutes, $eveningends, $eveningends_minutes, $resolution;

  $start_first_slot = (($morningstarts * 60) + $morningstarts_minutes) * 60;
  $end_last_slot = ((($eveningends * 60) + $eveningends_minutes) * 60) + $resolution;
  $end_last_slot = $end_last_slot % SECONDS_PER_DAY;

  return ($end_last_slot <= $start_first_slot);
}


// Gets the UNIX timestamp for the start of the first slot on the given day
function get_start_first_slot(int $month, int $day, int $year) : int
{
  global $morningstarts, $morningstarts_minutes, $enable_periods;

  if ($enable_periods)
  {
    $result = mktime(12, 0, 0, $month, $day, $year);
  }
  else
  {
    $result = mktime($morningstarts, $morningstarts_minutes, 0,
                     $month, $day, $year);
  }

  if ($result === false)
  {
    throw new \Exception("Invalid arguments to mktime()");
  }

  return $result;
}


// Gets the UNIX timestamp for the start of the last slot on the given day
function get_start_last_slot(int $month, int $day, int $year) : int
{
  global $morningstarts, $morningstarts_minutes, $eveningends, $eveningends_minutes, $enable_periods, $periods;

  if ($enable_periods)
  {
    $result = mktime(12, count($periods) -1, 0, $month, $day, $year);
  }
  else
  {
    // Work out if $evening_ends is really on the next day
    if (hm_before(array('hours' => $eveningends, 'minutes' => $eveningends_minutes),
        array('hours' => $morningstarts, 'minutes' => $morningstarts_minutes)))
    {
      $day++;
    }

    $result = mktime($eveningends, $eveningends_minutes, 0,
                     $month, $day, $year);
  }

  if ($result === false)
  {
    throw new \Exception("Invalid arguments to mktime()");
  }

  return $result;
}


function get_end_last_slot(int $month, int $day, int $year) : int
{
  global $resolution;

  return intval(get_start_last_slot($month, $day, $year) + $resolution);
}


// Determines with a given timestamp is within a booking day, ie between the start of
// the first slot and end of the last slot.   Returns a boolean.
function is_in_booking_day(int $t) : bool
{
  global $morningstarts, $morningstarts_minutes,
         $eveningends, $eveningends_minutes,
         $resolution, $enable_periods;

  if ($enable_periods)
  {
    return true;
  }

  $start_day_secs = (($morningstarts * 60) + $morningstarts_minutes) * 60;
  $end_day_secs = (((($eveningends * 60) + $eveningends_minutes) * 60) + $resolution) % SECONDS_PER_DAY;

  $date = getdate($t);
  $t_secs = (($date['hours'] * 60) + $date['minutes']) * 60;

  if ($start_day_secs == $end_day_secs)
  {
    return true;
  }
  elseif (day_past_midnight())
  {
    return (($t_secs >= $start_day_secs) || ($t_secs <= $end_day_secs));
  }
  else
  {
    return (($t_secs >= $start_day_secs) && ($t_secs <= $end_day_secs));
  }
}


// Takes a Unix timestamp and sets the time, while keeping the day the same.
// $time is a string in hh:mm format (24 hour clock)
function timestamp_set_time(int $timestamp, string $time) : int
{
  list($hour, $minute) = explode(':', $time);
  $hour = intval($hour);
  $minute = intval($minute);
  $date = new DateTime();
  $date->setTimestamp($timestamp);
  $date->setTime($hour, $minute);
  return $date->getTimestamp();
}


// Force a timestamp $t to be on a booking day, either by moving it back to the end
// of the previous booking day, or forward to the start of the next.
function fit_to_booking_day(int $t, bool $back=true) : int
{
  if (is_in_booking_day($t))
  {
    return $t;
  }

  $date = getdate($t);
  // Remember that we need to cater for days that stretch beyond midnight.
  if ($back)
  {
    $new_t = get_end_last_slot($date['mon'], $date['mday'], $date['year']);
    if ($new_t > $t)
    {
      $new_t = get_end_last_slot($date['mon'], $date['mday'] - 1, $date['year']);
    }
  }
  else
  {
    $new_t = get_start_first_slot($date['mon'], $date['mday'], $date['year']);
    if ($new_t < $t)
    {
      $new_t = get_start_first_slot($date['mon'], $date['mday'] + 1, $date['year']);
    }
  }

  return $new_t;
}


/**
 * Get the duration of an interval given a start time and end time.
 *
 * Corrects for DST changes so that the duration is what the user would expect to see.
 * For example, 12 noon to 12 noon crossing a DST boundary is 24 hours.
 *
 * @param int $start_time Unix timestamp
 * @param int $end_time Unix timestamp
 * @param bool $enable_periods Whether periods are being used
 * @param bool $translate Whether to translate into the browser language
 * @return array{duration: int, dur_units: string}
 */
function get_duration(int $start_time, int $end_time, bool $enable_periods, int $area_id, bool $translate=true) : array
{
  $result = array();

  $period_names = get_period_names();

  if ($enable_periods)
  {
    $periods_per_day = count($period_names[$area_id]);
    $n_periods = get_period_interval($start_time, $end_time, $area_id);  // this handles DST
    if (($n_periods % $periods_per_day) == 0)
    {
      $result['duration'] =  intval($n_periods/$periods_per_day);
      $result['dur_units'] = ($translate) ? get_vocab('days') : 'days';
    }
    else
    {
      $result['duration'] = $n_periods;
      $result['dur_units'] = ($translate) ? get_vocab('periods') : 'periods';
    }
  }
  else
  {
    $result['duration'] = $end_time - $start_time;
    // Need to make DST correct in opposite direction to entry creation
    // so that user see what he expects to see
    $result['duration'] -= cross_dst($start_time, $end_time);
    toTimeString($result['duration'], $result['dur_units'], $translate);
  }
  return $result;
}


// Escape a PHP variable for use in HTML
function escape_html($value)
{
  switch ($type = gettype($value))
  {
    case 'double':
    case 'integer':
      return $value;
      break;
    case 'string':
      return htmlspecialchars($value);
      break;
    default:
      throw new TypeError("Type '$type' not supported as an argument.");
      break;
  }
}


// Escape a PHP string for use in JavaScript
//
// Based on a function contributed by kongaspar at gmail dot com at
// http://www.php.net/manual/function.addcslashes.php
function escape_js(string $str) : string
{
  return addcslashes($str, "\\\'\"&\n\r<>/");
}


// Return a default area; used if no area is already known. This returns the
// area that contains the default room (if it is set, valid and enabled) otherwise the
// first area in alphabetical order in the database (no guarantee there is an area 1).
// The area must be enabled for it to be considered.
// This could be changed to implement something like per-user defaults.
function get_default_area() : int
{
  global $default_room;

  // It's possible that this function is being called during an upgrade
  // process before the disabled column existed.
  $disabled_field_exists = db()->field_exists(_tbl('room'), 'disabled');

  // If the $default_room is set and exists and is enabled and is visible to
  // the current user, then return the corresponding area
  if (isset($default_room) && is_visible($default_room))
  {
    $sql = "SELECT area_id
              FROM " . _tbl('room') . " R, " . _tbl('area') . " A
             WHERE R.id=:id
               AND R.area_id = A.id";

    if ($disabled_field_exists)
    {
      $sql .= " AND R.disabled = 0 AND A.disabled = 0";
    }

    $sql .= " LIMIT 1";
    $area = db()->query1($sql, array(':id' => $default_room));

    if ($area >= 0)
    {
      return (int) $area;
    }
  }

  // Otherwise return the first enabled area in the database that has an
  // enabled and visible room
  $sql = "SELECT R.id, R.area_id
            FROM " . _tbl('room') . " R, " . _tbl('area') . " A
           WHERE R.area_id = A.id";

  if ($disabled_field_exists)
  {
    $sql .= " AND R.disabled = 0 AND A.disabled = 0";
  }

  $order_by_column = (db()->field_exists(_tbl('area'), 'sort_key')) ? 'sort_key' : 'area_name';
  $sql .= " ORDER BY A.$order_by_column";

  $res = db()->query($sql);

  while (false !== ($row = $res->next_row_keyed()))
  {
    if (is_visible($row['id']))
    {
      return (int) $row['area_id'];
    }
  }

  return 0;
}

// Return a default room given a valid area; used if no room is already known.
// If the area contains $default_room and $default_room is visible to the current
// user, then it returns $default_room. Otherwise it returns the first visible room
// in sort_key order in the database.
// This could be changed to implement something like per-user defaults.
function get_default_room(int $area) : int
{
  global $default_room;

  // It's possible that this function is being called during
  // an upgrade process before the disabled columns existed.
  $disabled_field_exists = db()->field_exists(_tbl('room'), 'disabled');

  // Check to see whether this area contains $default_room
  if (isset($default_room))
  {
    $sql = "SELECT id
              FROM " . _tbl('room') . "
             WHERE id=:id AND area_id=:area_id";

    if ($disabled_field_exists)
    {
      $sql .= " AND disabled=0";
    }

    $sql .= " LIMIT 1";
    $sql_params = array(':id' => $default_room, ':area_id' => $area);
    $room = db()->query1($sql, $sql_params);

    if (($room >= 0) && is_visible($room))
    {
      return (int) $room;
    }
  }

  // Otherwise just return the first visible room in the area
  $rooms = get_rooms($area, !$disabled_field_exists);

  return (count($rooms) > 0) ? (int) $rooms[0]['id'] : 0;
}


// Return an area id for a given room
function get_area(int $room) : int
{
  $sql = "SELECT area_id
            FROM " . _tbl('room') . "
           WHERE id=?
           LIMIT 1";

  $area = db()->query1($sql, array($room));

  return ($area < 0 ? 0 : (int) $area);
}


// Clean up a row from the area table, making sure there are no nulls, casting
// boolean fields into bools and doing some sanity checking
function clean_area_row(array $row) : array
{
  global $force_resolution, $area_defaults, $private_override_options;

  // This code can get called during the upgrade process and so must
  // not make any assumptions about the existence of extra columns in
  // the area table.

  // Cast the columns to their intended types (the pseudo-booleans will
  // be picked up later)
  row_cast_columns($row, 'area');

  // Get the default settings if necessary
  $columns = Columns::getInstance(_tbl('area'));

  foreach ($columns as $column)
  {
    // Cast the pseudo-booleans to bools
    if (isset($row[$column->name]) && ($column->getNature() == Column::NATURE_INTEGER) && ($column->getLength() <=2))
    {
      $row[$column->name] = (bool)$row[$column->name];
    }

    if (array_key_exists($column->name, $area_defaults))
    {
      // If the "per area" setting is in the database, then use that.  Otherwise
      // just stick with the default setting from the config file.
      // (don't use the database setting if $force_resolution is TRUE
      // and we're looking at the resolution field)
      if (!isset($row[$column->name]) ||
          (($column->name == 'resolution') && !empty($force_resolution) && empty($row['enable_periods'])) )
      {
        $row[$column->name] = $area_defaults[$column->name];
      }
    }
  }

  // Do some sanity checking in case the area table is somehow messed up
  // (1) 'private_override' must be a valid value
  if (array_key_exists('private_override', $row) &&
      !in_array($row['private_override'], $private_override_options))
  {
    $row['private_override'] = 'private';  // the safest default
    $message = "Invalid value for 'private_override' in the area table.  Using '{$row['private_override']}'.";
    trigger_error($message, E_USER_WARNING);
  }
  // (2) 'resolution' must be positive
  if (array_key_exists('resolution', $row) &&
      (empty($row['resolution']) || ($row['resolution'] < 0)))
  {
    $row['resolution'] = 30*60;  // 30 minutes, a reasonable fallback
    $message = "Invalid value for 'resolution' in the area table.   Using {$row['resolution']} seconds.";
    trigger_error($message, E_USER_WARNING);
  }

  return $row;
}


// Update the default area settings with the ones specific to this area.
// If no value is set in the database, use the value from the config file.
// If $area is empty, use the default area
function get_area_settings(int $area) : void
{
  global $auth;
  global $resolution, $default_duration, $default_duration_all_day;
  global $morningstarts, $morningstarts_minutes, $eveningends, $eveningends_minutes;
  global $private_enabled, $private_default, $private_mandatory, $private_override;
  global $min_create_ahead_enabled, $max_create_ahead_enabled, $min_create_ahead_secs, $max_create_ahead_secs;
  global $min_delete_ahead_enabled, $max_delete_ahead_enabled, $min_delete_ahead_secs, $max_delete_ahead_secs;
  global $max_duration_enabled, $max_duration_secs, $max_duration_periods;
  global $approval_enabled, $reminders_enabled, $enable_periods, $periods;
  global $confirmation_enabled, $confirmed_default, $timezone;
  global $max_per_interval_area_enabled, $max_per_interval_area;
  global $max_secs_per_interval_area_enabled, $max_secs_per_interval_area;
  global $interval_types;
  global $times_along_top, $default_type;

  // This code can get called during the upgrade process and so must
  // not make any assumptions about the existence of extra columns in
  // the area table.
  if (empty($area))
  {
    $area = get_default_area();
  }

  // Get all the "per area" config settings
  $columns = array('timezone', 'resolution', 'default_duration', 'default_duration_all_day',
                   'morningstarts', 'morningstarts_minutes',
                   'eveningends', 'eveningends_minutes',
                   'private_enabled', 'private_default', 'private_mandatory', 'private_override',
                   'min_create_ahead_enabled', 'max_create_ahead_enabled',
                   'min_create_ahead_secs', 'max_create_ahead_secs',
                   'min_delete_ahead_enabled', 'max_delete_ahead_enabled',
                   'min_delete_ahead_secs', 'max_delete_ahead_secs',
                   'max_duration_enabled', 'max_duration_secs', 'max_duration_periods',
                   'max_per_day_enabled', 'max_per_day',
                   'max_per_week_enabled', 'max_per_week',
                   'max_per_month_enabled', 'max_per_month',
                   'max_per_year_enabled', 'max_per_year',
                   'max_per_future_enabled', 'max_per_future',
                   'max_secs_per_day_enabled', 'max_secs_per_day',
                   'max_secs_per_week_enabled', 'max_secs_per_week',
                   'max_secs_per_month_enabled', 'max_secs_per_month',
                   'max_secs_per_year_enabled', 'max_secs_per_year',
                   'max_secs_per_future_enabled', 'max_secs_per_future',
                   'approval_enabled', 'reminders_enabled', 'enable_periods', 'periods',
                   'confirmation_enabled', 'confirmed_default',
                   'times_along_top', 'default_type');

  $sql = "SELECT *
            FROM " . _tbl('area') . "
           WHERE id=?
           LIMIT 1";

  $res = db()->query($sql, array($area));
  if ($res->count() == 0)
  {
    // We still need to set the timezone even if the query didn't
    // return any results
    mrbs_default_timezone_set($timezone);
    return;
  }
  else
  {
    $row = $res->next_row_keyed();

    // Periods are stored as a JSON encoded string in the database
    if (isset($row['periods']))
    {
      $row['periods'] = json_decode($row['periods']);
    }

    $row = clean_area_row($row);
    foreach ($columns as $column)
    {
      if (array_key_exists($column, $row))
      {
        $$column = $row[$column];
      }
    }
  }
  // Set the timezone
  mrbs_default_timezone_set($timezone);

  // Set the $max_per_interval_area_enabled, $max_per_interval_area,
  // $max_secs_per_interval_area_enabled and $max_secs_per_interval_area
  // arrays, which are handled slightly differently
  foreach ($interval_types as $interval_type)
  {
    $var = "max_per_{$interval_type}_enabled";
    if (isset($$var))
    {
      $max_per_interval_area_enabled[$interval_type] = $$var;
    }

    $var = "max_per_$interval_type";
    if (isset($$var))
    {
      $max_per_interval_area[$interval_type] = $$var;
    }

    $var = "max_secs_per_{$interval_type}_enabled";
    if (isset($$var))
    {
      $max_secs_per_interval_area_enabled[$interval_type] = $$var;
    }

    $var = "max_secs_per_$interval_type";
    if (isset($$var))
    {
      $max_secs_per_interval_area[$interval_type] = $$var;
    }
  }

  // Sanitise the settings
  if ($enable_periods)
  {
    $resolution = 60;
    $morningstarts = 12;
    $morningstarts_minutes = 0;
    $eveningends = 12;
    $eveningends_minutes = count($periods) - 1;
  }
  elseif (!isset($morningstarts_minutes))
  {
    // ensure that $morningstarts_minutes defaults to zero if not set
    $morningstarts_minutes = 0;
  }

  // If it's been set in the config settings then force all bookings to be shown
  // as private for users who haven't logged in.
  if ($auth['force_private_for_guests'] && (null === session()->getCurrentUser()))
  {
    $private_override = 'private';
  }
}


// Generate the predicate for use in an SQL query to test whether
// an area has $field set
function some_area_predicate(string $field) : string
{
  global $area_defaults;

  $predicate = "(($field IS NOT NULL) AND ($field > 0))";
  if ($area_defaults[$field])
  {
    $predicate = "(" . $predicate . " OR ($field IS NULL))";
  }
  return $predicate;
}


// Generate the predicate for use in an SQL query to test whether
// an area does not have $field set
function some_area_predicate_not(string $field) : string
{
  global $area_defaults;

  $predicate = "(($field IS NOT NULL) AND ($field = 0))";
  if (empty($area_defaults[$field]))
  {
    $predicate = "(" . $predicate . " OR ($field IS NULL))";
  }
  return $predicate;
}


// Determines whether there is at least one area with the relevant $field
// set (eg 'approval_enabled' or 'confirmation_enabled').   If $only_enabled
// is TRUE then the search is limited to enabled areas
//
// Returns: boolean
function some_area(string $field, bool $only_enabled=false, bool $not=false) : bool
{
  $predicate = ($not) ? some_area_predicate_not($field) : some_area_predicate($field);
  $sql = "SELECT COUNT(*) FROM " . _tbl('area') . " WHERE $predicate";
  $sql .= ($only_enabled) ? " AND disabled=0" : "";
  $sql .= " LIMIT 1";

  return (db()->query1($sql) > 0);
}


// Determines whether there is at least one area with periods enabled.
// If $only_enabled is TRUE then the search is limited to enabled areas
function periods_somewhere(bool $only_enabled=false) : bool
{
  return some_area('enable_periods', $only_enabled);
}


// Determines whether there is at least one area with times enabled.
// If $only_enabled is TRUE then the search is limited to enabled areas
function times_somewhere(bool $only_enabled=false) : bool
{
  return some_area('enable_periods', $only_enabled, true);
}


// Returns a date in ISO 8601 format ('yyyy-mm-dd'), having adjusted the date if necessary
// to make sure it's a valid date
function format_iso_date(int $year, int $month, int $day) : string
{
  $date = new DateTime();
  return $date->setDate($year, $month, $day)->format(DateTime::ISO8601_DATE);
}


// Splits a date in ISO 8601 format ('yyyy-mm-dd') and returns an
// array of year, month, day with leading zeros removed, or FALSE
// on failure.
function split_iso_date(string $iso_date)
{
  if (false === ($date = DateTime::createFromFormat(DateTime::ISO8601_DATE, $iso_date)))
  {
    return false;
  }

  return array(
    (int) $date->format('Y'),
    (int) $date->format('n'),
    (int) $date->format('j')
  );
}


// Validates that a string is a valid ISO date in yyyy-mm-dd format
function validate_iso_date(string $date) : bool
{
  // Try creating a DateTime object.  If that is successful and
  // the inverse operation gives the original string then it is
  // valid.  (This is to check that, for example, 2021-09-31 hasn't
  // been converted into 2021-10-01).
  $format = DateTime::ISO8601_DATE;
  $datetime = DateTime::createFromFormat($format, $date);

  return ($datetime && ($datetime->format($format) === $date));
}


// Get the local day name based on language.
function day_name(int $day_number, ?array $format=null, ?string $locale=null) : string
{
  global $datetime_formats;

  if (!isset($format))
  {
    $format = $datetime_formats['day_name'];
  }

  // Note 2000-01-02 is a Sunday.
  return datetime_format($format, mktime(0,0,0,1,2+$day_number,2000), $locale);
}


// Gets the monthly repeat day for an entry, for example "20" or "second Wednesday"
function get_monthly_repeat_day(array $data) : string
{
  // Fix up the keys while we're still transitioning to the RepeatRule
  if (isset($data['repeat_rule']))
  {
    $repeat_rule = $data['repeat_rule'];
    if ($repeat_rule->getMonthlyType() == RepeatRule::MONTHLY_ABSOLUTE)
    {
      $data['month_absolute'] = $repeat_rule->getMonthlyAbsolute();
    }
    else
    {
      $data['month_relative'] = $repeat_rule->getMonthlyRelative();
    }
  }

  if (isset($data['month_absolute']))
  {
    $result = (string) $data['month_absolute'];
  }
  elseif (isset($data['month_relative']))
  {
    // Note: this does not internationalise very well and could do with revisiting.
    // It follows the select box order in edit_entry, which is the more difficult one
    // to sort out.  It assumes all languages have the same order as English
    // eg "the second Wednesday" which is probably not true.
    list('ordinal' => $ord, 'day' => $dow) = RFC5545::parseByday($data['month_relative']);
    $result = get_vocab("ord_" . $ord) . " " . day_name(RFC5545::convertDayToOrd($dow));
  }
  else
  {
    throw new Exception("Unknown monthly repeat type");
  }

  return $result;
}


function hour_min_format() : array
{
  global $datetime_formats;

  return $datetime_formats['time'];
}


// Returns a string representing the hour and minute for the nominal
// seconds since the start of the day, $s
function hour_min(int $s) : string
{
  $following_day = ($s >= SECONDS_PER_DAY);
  $s = $s % SECONDS_PER_DAY;  // in case $s is on the next day
  // Choose a day that doesn't have any DST transitions in any timezone
  $t = mktime(0, 0, $s, 1, 1, 2000);
  $result = datetime_format(hour_min_format(), $t);
  if ($following_day)
  {
    $result = "* " . $result;
  }
  return $result;
}


/**
 * Convert a timestamp into a date string, when using periods.
 *
 * @param bool $previous_period Whether the date string for the previous period should be used (useful for the end of a period)
 */
function period_date_string(int $t, int $area_id, bool $previous_period=false) : string
{
  global $datetime_formats;

  // The separator is a ',' as a '-' leads to an ambiguous
  // display in report.php when showing end times.
  return period_time_string($t, $area_id, $previous_period) . ', ' . datetime_format($datetime_formats['date'], $t);
}


/**
 * Returns the name of the period in a given area for a given timestamp.
 *
 * @param bool $previous_period Whether the name of the previous period should be used (useful for the end of a period)
 */
function period_time_string(int $t, int $area_id, bool $previous_period=false) : string
{
  $periods = get_period_names();

  $n_periods = count($periods[$area_id]);
  $time = getdate($t);
  $p = $time['minutes'];
  if ($previous_period)
  {
    $p--;
  }

  // Make sure the period is valid
  $p = max($p, 0);
  $p = min($p, $n_periods - 1);

  return $periods[$area_id][$p];
}


function time_date_string(int $t) : string
{
  global $datetime_formats;

  return datetime_format($datetime_formats['date_and_time'], $t);
}


/**
 * Convert a timestamp into a date string
 *
 * @param bool $previous_period Whether the date string for the previous period should be used (useful for the end of a period)
 */
function date_string(bool $use_periods, int $t, int $area_id, bool $previous_period=false) : string
{
  return ($use_periods) ? period_date_string($t, $area_id, $previous_period) : time_date_string($t);
}


// Wrapper for the standard PHP function nl2br() that sets $use_xhtml to false
function mrbs_nl2br(string $string) : string
{
  return nl2br($string, false);
}


// Take a string of email addresses separated by commas
// and return a comma separated list with duplicates removed.
function clean_address_list(string $string) : string
{
  $array = explode(',', $string);
  $array = array_map('trim', $array);
  return implode(', ', array_unique($array));
}


function validate_email(string $email) : bool
{
  return PHPMailer::validateAddress($email);
}


// Validates a comma separated list of email addresses.  (The individual email
// addresses are 'trimmed' before validation, so spaces are allowed after the commas).
// Returns FALSE if any one of them is invalid, otherwise TRUE
function validate_email_list(string $list) : bool
{
  if ($list !== '')
  {
    $emails = explode(',', $list);
    foreach ($emails as $email)
    {
      if (!validate_email(trim($email)))
      {
        return false;
      }
    }
  }

  return true;
}


// Parses an email address and returns an array indexed by 'local' and 'domain',
// or FALSE if the email address is not valid.
function parse_email(string $email)
{
  $parser = new Parse(new Logger());
  $parsed_address = $parser->parse($email, false);

  if ($parsed_address['invalid'])
  {
    return false;
  }

  return [
    'local' => $parsed_address['local_part_parsed'],
    'domain' => $parsed_address['domain_part']
  ];
}


function parse_addresses(string $address_string) : array
{
  $result = [];

  $parser = new Parse(new Logger());
  $parsed_addresses = $parser->parse($address_string);

  foreach ($parsed_addresses['email_addresses'] as $parsed_address)
  {
    if (!$parsed_address['invalid'])
    {
      $result[] = [
        'name' => $parsed_address['name_parsed'],
        'address' => $parsed_address['local_part_parsed'] . '@' . $parsed_address['domain_part']
      ];
    }
  }

  return $result;
}


// Round time down to the nearest resolution
function round_t_down(int $t, int $resolution, int $start_first_slot) : int
{
  // The simple case when no DST transition is involved
  if (cross_dst($start_first_slot, $t) == 0)
  {
    return $t - (int)abs(($t - $start_first_slot) % $resolution);
  }
  // Otherwise we have to pay attention to DST transitions
  $date = new DateTime();
  $date->setTimestamp($start_first_slot);
  $nominal_seconds = nominal_seconds($start_first_slot);
  // Keep advancing the slot until we find the one that is closest to $t
  do {
    $candidate_slot = $date->getTimestamp();
    $nominal_seconds += $resolution;
    $date->setNominalSeconds($nominal_seconds);
  } while ($date->getTimestamp() <= $t);

  return $candidate_slot;
}


// Round time up to the nearest resolution
function round_t_up(int $t, int $resolution, int $start_first_slot) : int
{
  // The simple case when no DST transition is involved
  if (cross_dst($start_first_slot, $t) == 0)
  {
    if (($t - $start_first_slot) % $resolution != 0) {
      return $t + $resolution - abs(($t - $start_first_slot) % $resolution);
    }
    else {
      return $t;
    }
  }
  // Otherwise we have to pay attention to DST transitions
  $date = new DateTime();
  $date->setTimestamp($start_first_slot);
  $nominal_seconds = nominal_seconds($start_first_slot);
  // Keep advancing the slot until we find the one that is >= $t
  do {
    $candidate_slot = $date->getTimestamp();
    $nominal_seconds += $resolution;
    $date->setNominalSeconds($nominal_seconds);
  } while ($candidate_slot < $t);

  return $candidate_slot;
}


// Returns the nominal (ie ignoring DST transitions) seconds since the start of
// the calendar day on the start of the booking day
function nominal_seconds(int $t) : int
{
  global $morningstarts, $morningstarts_minutes;

  $date = getdate($t);
  // check to see if the time is really on the next day
  if (hm_before($date,
                array('hours' => $morningstarts, 'minutes' => $morningstarts_minutes)))
  {
    $date['hours'] += 24;
  }
  return (($date['hours'] * 60) + $date['minutes']) * 60;
}


// Returns the index of the period represented by the timestamp $timestamp
function period_index_timestamp(int $timestamp) : int
{
  // Periods are counted as minutes from noon, ie 1200 is $period[0],
  // 1201 $period[1], etc.
  $date = getdate($timestamp);

  return intval((($date['hours'] - 12) * 60) + $date['minutes']);
}


// Returns the index of the period represented by $s nominal seconds
function period_index_nominal(int $s) : int
{
  // Periods are counted as minutes from noon, ie 1200 is $period[0],
  // 1201 $period[1], etc.
  return intval($s/60) - (12*60);
}


// Returns the name of the period represented by nominal seconds $s
function period_name_nominal(int $s) : string
{
  global $periods;

  return $periods[period_index_nominal($s)];
}


// Returns the name of the period represented by the timestamp $timestamp
function period_name_timestamp(int $timestamp) : string
{
  global $periods;

  $index = period_index_timestamp($timestamp);

  return $periods[$index] ?? unknown_period_name($index);
}


// Returns the name of a period that doesn't exist
function unknown_period_name(int $index) : string
{
  return get_vocab('unknown_period', $index);
}


// returns the numeric day of the week (0-6) in terms of the MRBS week as defined by
// $weekstarts.   For example if $weekstarts is set to 2 (Tuesday) and a $time for
// a Wednesday is given, then 1 is returned.
function day_of_MRBS_week(int $time) : int
{
  global $weekstarts;

  return (date('w', $time) - $weekstarts + DAYS_PER_WEEK) % DAYS_PER_WEEK;
}


// Compares two nominal dates which are indexed by 'hours', 'minutes', 'seconds',
// 'mon', 'mday' and 'year', ie as in the output of getdate().  Returns -1 if the
// first date is before the second, 0 if they are equal and +1 if the first date
// is after the second.   NULL if a comparison can't be done.
//
// (Note that internally the function uses gmmktime() so the parameters do not
// have to represent valid values.   For example you could pass '32' for day and
// that would be interpreted as 4 days after '28'.)
function nominal_date_compare(array $d1, array $d2) : int
{
  // We compare the dates using gmmktime() because we are trying to compare nominal
  // dates and so do not want DST transitions
  $t1 = gmmktime($d1['hours'], $d1['minutes'], $d1['seconds'],
                 $d1['mon'], $d1['mday'], $d1['year']);
  $t2 = gmmktime($d2['hours'], $d2['minutes'], $d2['seconds'],
                 $d2['mon'], $d2['mday'], $d2['year']);
  if ($t1 < $t2)
  {
    return -1;
  }
  elseif ($t1 == $t2)
  {
    return 0;
  }
  else
  {
    return 1;
  }
}


// Determines whether there's a possibility that the interval between the two Unix
// timestamps could contain nominal times that don't exist, for example from 0100 to
// 0159 in Europe/London when entering DST.
function is_possibly_invalid(int $start, int $end) : bool
{
  // We err on the side of caution by widening the interval by a day at each end.   This
  // allows for the possibility that the start or end times have been calculated by using
  // mktime() on an invalid time!
  return (cross_dst($start - 86400, $end + 86400) < 0);
}


// Checks whether the nominal time given is an invalid date and time with respect to
// DST transitions.   When entering DST there is a set of times that don't exist, for
// example from 0100 to 0159 in Europe/London.
// Returns NULL if MRBS is unable to determine an answer, otherwise TRUE or FALSE (so
// a simple equality test will default to a valid time if MRBS can't determine an answer)
function is_invalid_datetime(int $hour, int $minute, int $second, int $month, int $day, int $year, ?string $tz=null) : ?bool
{
  global $timezone;

  // Do a quick check to see if there's a possibility of an invalid time by checking
  // whether there's a transition into DST from the day before to the day after
  if (empty($tz))
  {
    $tz = $timezone;  // default to the current timezone
  }

  $old_tz = date_default_timezone_get();  // save the current timezone
  date_default_timezone_set($tz);

  // If the day before is in DST then the datetime must be valid, because
  // you only get the gap when entering DST.
  $dayBefore = mktime($hour, $minute, $second, $month, $day-1, $year);
  if (date('I', $dayBefore))
  {
    $result = false;
  }
  else
  {
    // The day before is not in DST.   If the day after is also not in DST,
    // then there can have been no transition, so again the datetime must be valid.
    $dayAfter = mktime($hour, $minute, $second, $month, $day+1, $year);
    if (!date('I', $dayAfter))
    {
      $result = false;
    }
    else
    {
      $thisDateTimeZone = new DateTimeZone($tz);
      // Get the transition data (we assume there is one and only one transition),
      // in particular the time at which the transition happens and the new offset
      $transitions = $thisDateTimeZone->getTransitions($dayBefore, $dayAfter);
      // According to my reading of the PHP manual, getTransitions() should return
      // all transitions between the start and end date.   However what it seems to do
      // is return an array consisting of the time data for the start date followed by
      // the transition data.   So as a precaution we take the last element of the array
      // (we were only expecting one element, but seem to get two).
      $transition = array_pop($transitions);
      // If we failed for some reason to get any transition data, return NULL
      if (!isset($transition))
      {
        $result = null;
      }
      else
      {
        // Get the old offset and work out how many seconds the clocks change by
        $beforeDateTime = new DateTime(date('c', $dayBefore), $thisDateTimeZone);
        $change = $transition['offset'] - $beforeDateTime->getOffset();

        // See if the nominal date falls outside the gap
        $lastValidSecond = getdate($transition['ts'] - 1);
        $lastInvalidSecond = $lastValidSecond;
        $lastInvalidSecond['seconds'] += $change;
        $thisDate = array('hours' => $hour, 'minutes' => $minute, 'seconds' => $second,
                          'mon' => $month, 'mday' => $day, 'year' => $year);

        $result = ((nominal_date_compare($thisDate, $lastValidSecond) > 0) &&
                   (nominal_date_compare($thisDate, $lastInvalidSecond) <= 0));
      }
    }
  }

  date_default_timezone_set($old_tz);  // restore the old timezone

  return $result;
}


// Returns the modification (in seconds) necessary on account of any DST
// transitions when going from $start to $end
function cross_dst(int $start, int $end) : int
{
  global $timezone;

  // Calculate the modification using the DateTimeZone information rather than date('I', $time),
  // because not all DST transitions are 1 hour.  For example, Lord Howe Island in Australia
  // has a 30 minute transition.
  $thisDateTimeZone = new DateTimeZone($timezone);
  $startDateTime = new DateTime(date('c', $start), $thisDateTimeZone);
  $endDateTime = new DateTime(date('c', $end), $thisDateTimeZone);
  $modification = $startDateTime->getOffset() - $endDateTime->getOffset();

  return $modification;
}


// If $time falls on a non-working day, shift it back to the end of the last
// working day before that
function shift_to_workday(int $time) : int
{
  global $working_days;

  $dow = date('w', $time);  // get the day of the week
  $skip_back = 0;           // number of days to skip back
  // work out how many days to skip back to get to a working day
  while (!in_array($dow, $working_days))
  {
    if ($skip_back == DAYS_PER_WEEK)
    {
      break;
    }
    $skip_back++;
    $dow = ($dow + 6) % DAYS_PER_WEEK;  // equivalent to skipping back a day
  }
  if ($skip_back != 0)
  {
    // set the time to the end of the working day
    $d = intval(date('j', $time)) - $skip_back;
    $m = intval(date('n', $time));
    $y = intval(date('Y', $time));
    $time = mktime(23, 59, 59, $m, $d, $y);
  }
  return $time;
}

// Returns the difference in seconds between two timestamps, $now and $then
// It gives $now - $then, less any seconds that were part of a non-working day
function working_time_diff(int $now, int $then) : int
{
  global $working_days;

  // Deal with the easy case
  if ($now == $then)
  {
    return 0;
  }
  // Sanitise the $working_days array in case it was malformed
  $working_week = array_unique(array_intersect(array(0,1,2,3,4,5,6), $working_days));
  $n_working_days = count($working_week);
  // Deal with the special case where there are no working days
  if ($n_working_days == 0)
  {
    return 0;
  }
  // and the special case where there are no holidays
  if ($n_working_days == DAYS_PER_WEEK)
  {
    return ($now - $then);
  }

  // For the rest we're going to assume that $last comes after $first
  $last = max($now, $then);
  $first = min($now, $then);

  // first of all, if $last or $first fall on a non-working day, shift
  // them back to the end of the last working day
  $last = shift_to_workday($last);
  $first = shift_to_workday($first);
  // So calculate the difference
  $diff = $last - $first;
  // Then we have to deduct all the non-working days in between.   This will be
  // (a) the number of non-working days in the whole weeks between them +
  // (b) the number of non-working days in the part week

  // First let's calculate (a)
  $last = mktime(12, 0, 0, (int)date('n', $last), (int)date('j', $last), (int)date('Y', $last));
  $first = mktime(12, 0, 0, (int)date('n', $first), (int)date('j', $first), (int)date('Y', $first));
  $days_diff = (int) round(($last - $first)/SECONDS_PER_DAY);  // the difference in days
  $whole_weeks = (int) floor($days_diff/DAYS_PER_WEEK);  // the number of whole weeks between the two
  $non_working_days = $whole_weeks * (DAYS_PER_WEEK - $n_working_days);
  // Now (b), ie we just have to calculate how many non-working days there are between the two
  // days of the week that are left
  $last_dow = date('w', $last);
  $first_dow = date('w', $first);

  while ($first_dow != $last_dow)
  {
    $first_dow = ($first_dow + 1) % DAYS_PER_WEEK;
    if (!in_array($first_dow, $working_week))
    {
      $non_working_days++;
    }
  }

  // So now subtract the number of weekend seconds
  $diff = $diff - ($non_working_days * SECONDS_PER_DAY);

  // Finally reverse the difference if $now was in fact before $then
  if ($now < $then)
  {
    $diff = -$diff;
  }

  return (int) $diff;
}


// checks whether a given day of the week is supposed to be hidden in the display
function is_hidden_day(int $dow) : bool
{
  global $hidden_days;

  return (isset($hidden_days) && in_array($dow, $hidden_days));
}


// Checks whether a given day of the week is a weekend day.
// $dow is in the range [0..6].
function is_weekend(int $dow) : bool
{
  global $weekdays;

  return !in_array($dow, $weekdays);
}


// returns true if event should be considered private based on
// config settings and event's privacy status (passed to function)
function is_private_event(bool $privacy_status) : bool
{
  global $private_override;
  if ($private_override == "private" )
  {
    $privacy_status = true;
  }
  elseif ($private_override == "public" )
  {
    $privacy_status = false;
  }

  return $privacy_status;
}

// Generate a globally unique id
//
// We will generate a uid of the form "MRBS-uniqid-MD5hash@domain_name"
// where uniqid is time based and is generated by uniqid() and the
// MD5hash is the first 8 characters of the MD5 hash of $str concatenated
// with a random number.
function generate_global_uid(string $str) : string
{
  global $server;

  $uid = uniqid('MRBS-');
  $uid .= "-" . substr(md5($str . rand(0,10000)), 0, 8);
  $uid .= "@";
  // Add on the domain name if possible, if not the server name,
  // otherwise 'MRBS'
  if (empty($server['SERVER_NAME']))
  {
    $uid .= 'MRBS';
  }
  elseif (mb_strpos($server['SERVER_NAME'], 'www.') === 0)
  {
    $uid .= mb_substr($server['SERVER_NAME'], 4);
  }
  else
  {
    $uid .= $server['SERVER_NAME'];
  }

  return $uid;
}

// Tests whether an array is associative
//
// Thanks to magentix at gmail dot com at http://php.net/manual/function.is-array.php
function is_assoc(array $arr) : bool
{
  return (is_array($arr) && count(array_filter(array_keys($arr),'is_string')) == count($arr));
}


// Checks whether we are running as a CLI module
//
// Based on code from mniewerth at ultimediaos dot com at
// http://php.net/manual/features.commandline.php
function is_cgi() : bool
{
  return (substr(PHP_SAPI, 0, 3) == 'cgi');
}


// Checks whether we are running from the CLI
//
// Based on code from mniewerth at ultimediaos dot com at
// http://php.net/manual/features.commandline.php
function is_cli() : bool
{
  global $allow_cli;

  if (!$allow_cli)
  {
    return false;
  }

  if (defined('STDIN'))
  {
    return true;
  }
  elseif (is_cgi() && getenv('TERM'))
  {
    return true;
  }
  else
  {
    return false;
  }
}


// Determine whether this is a GET request
function is_get_request() : bool
{
  global $server;

  return ($server['REQUEST_METHOD'] === 'GET');
}


// Determines whether we are in kiosk mode
function is_kiosk_mode() : bool
{
  return session()->isset('kiosk_password_hash');
}


// Set and restore ignore_user_abort.   The function is designed to be used to
// ensure a critical piece of code can't be aborted, and used in pairs of set and
// restore calls.  The function keeps track of outstanding set requests so that
// the original state isn't restored if there are other requests still outstanding.
//
// $set   TRUE    set ignore_user_abort
//        FALSE   restore to the original state, if no other requests outstanding
function mrbs_ignore_user_abort(bool $set) : void
{
  static $original_state;
  static $outstanding_requests = 0;

  if (!isset($original_state))
  {
    $original_state = boolval(ignore_user_abort());
  }

  // Set ignore_user_abort
  if ($set)
  {
    if ($outstanding_requests == 0)
    {
      ignore_user_abort(true);
    }
    $outstanding_requests++;
  }
  else
  // Restore the original state, provided no other requests are outstanding
  {
    $outstanding_requests--;
    if ($outstanding_requests == 0)
    {
      ignore_user_abort($original_state);
    }
  }
}


// Gets the web server software type and version, if it can.
function get_server_software() : string
{
  global $server;

  if (function_exists('apache_get_version'))
  {
    $result = apache_get_version();
    if ($result !== false)
    {
      return $result;
    }
  }

  return $server['SERVER_SOFTWARE'] ?? '';
}



function get_field_entry_input(array $params) : Field
{
  global $select_options, $datalist_options, $text_input_max;

  if (isset($params['field']))
  {
    if (!empty($select_options[$params['field']]))
    {
      $class = 'FieldSelect';
    }
    elseif (!empty($datalist_options[$params['field']]))
    {
      $class = 'FieldInputDatalist';
    }
    else
    {
      $columns = Columns::getInstance(_tbl('entry'));
      $column_name = preg_replace('/^entry./', '', $params['field']);
      $column = $columns->getColumnByName($column_name);
      if (!in_array($column_name, ['name', 'create_by']) &&  // special cases
          ($column->getNature() === Column::NATURE_CHARACTER) &&
          ($column->getLength() > $text_input_max))
      {
        $class = 'FieldTextarea';
      }
      else
      {
        $class = 'FieldInputText';
      }
    }
  }
  else
  {
    $class = 'FieldInputText';
  }

  $full_class = __NAMESPACE__ . "\\Form\\$class";
  $field = new $full_class();
  $field->setLabel($params['label'])
    ->setControlAttribute('name', $params['name']);

  if (!empty($params['required']))
  {
    $field->setControlAttribute('required', true);
  }
  if (!empty($params['disabled']))
  {
    $field->setControlAttribute('disabled', true);
    $field->addHiddenInput($params['name'], $params['value']);
  }

  switch ($class)
  {
    case 'FieldSelect':
      $options = $select_options[$params['field']];
      $field->addSelectOptions($options, $params['value']);
      break;

    case 'FieldInputDatalist':
      $options = $datalist_options[$params['field']];
      $field->addDatalistOptions($options);
    // Drop through

    case 'FieldInputText':
      if (!empty($params['required']))
      {
        // Set a pattern as well as required to prevent a string of whitespace
        $field->setControlAttribute('pattern', REGEX_TEXT_POS);
      }
    // Drop through

    case 'FieldTextarea':
      if ($class == 'FieldTextarea')
      {
        $field->setControlText($params['value'] ?? '');
      }
      else
      {
        $field->setControlAttribute('value', $params['value']);
      }
      if (isset($params['field']) &&
        (null !== ($maxlength = maxlength($params['field']))))
      {
        $field->setControlAttribute('maxlength', $maxlength);
      }
      // Add a placeholder if necessary
      $tag = $params['field'] . '.placeholder';
      $placeholder = get_vocab($tag);
      if (isset($placeholder) && ($placeholder !== $tag))
      {
        $field->setControlAttribute('placeholder', $placeholder);
      }
      break;

    default:
      throw new \Exception("Unknown class '$class'");
      break;
  }

  return $field;
}


// Returns a Field object for selecting as user from the list of available users.
// If $datalist is true a datalist is returned, otherwise a select element.
// $params is an associative array with the following keys
//    value       the value of the current selection
//    disabled    boolean, whether the field should be disabled
//    required    boolean
//    field       the field name, eg 'entry.create_by'
//    label       the field label
//    name        the field name
function get_user_field(array $params, bool $datalist=false) : Field
{
  if (method_exists(auth(), 'getUsernames'))
  {
    // We can get a list of all users, so present a <select> or <datalist> element.
    // The options will actually be provided later via Ajax, so all we
    // do here is present one option, ie the create_by user.
    $options = array();
    $selected_user = auth()->getUser($params['value']);
    // It's possible that $selected_user no longer exists - may have left the
    // organisation and been deleted from the user list - so in that case
    // use their username for the displayname.
    if (isset($selected_user))
    {
      $options[$selected_user->username] = $selected_user->display_name;
    }
    else
    {
      $options[$params['value']] = $params['value'];
    }

    $field = new FieldSelect();
    $class = 'ajax_usernames';
    if ($datalist)
    {
      $class .= ' datalist';
    }
    $field->setLabel($params['label'])
      ->setControlAttributes(array('name'     => $params['name'],
                                   'class'    => $class,
                                   'disabled' => $params['disabled']))
      ->addSelectOptions($options, $params['value'], true);
  }
  else
  {
    // We don't know all the available users, so we'll just present
    // a text input field and rely on the user to enter a valid username.
    $field = get_field_entry_input($params);
  }

  return $field;
}
