<?php
/**
 * @package     VikBooking
 * @subpackage  com_vikbooking
 * @author      Alessio Gaggii - e4j - Extensionsforjoomla.com
 * @copyright   Copyright (C) 2022 e4j - Extensionsforjoomla.com. All rights reserved.
 * @license     GNU General Public License version 2 or later; see LICENSE
 * @link        https://vikwp.com
 */

defined('ABSPATH') or die('No script kiddies please!');

/**
 * Availability handler class for Vik Booking.
 * Also used to handle website inquiry reservations.
 *
 * @since 	1.15.0 (J) - 1.5.0 (WP)
 */
class VikBookingAvailability
{
	/**
	 * The singleton instance of the class.
	 *
	 * @var VikBookingAvailability
	 */
	protected static $instance = null;

	/**
	 * An array containing the stay dates.
	 *
	 * @var array
	 */
	protected $stay_dates = [];

	/**
	 * An array containing the stay date timestamps.
	 *
	 * @var array
	 */
	protected $stay_ts = [];

	/**
	 * An array containing the room parties.
	 *
	 * @var array
	 */
	protected $room_parties = [];

	/**
	 * The total number of days to go "back and forth".
	 * 
	 * @var int
	 */
	protected $back_and_forth = 14;

	/**
	 * A list of the room ids to be checked.
	 *
	 * @var array
	 */
	protected $room_ids = [];

	/**
	 * Whether to ignore restrictions.
	 *
	 * @var bool
	 */
	protected $ignore_restrictions = false;

	/**
	 * Whether to ignore rooms availability.
	 *
	 * @var bool
	 */
	protected $ignore_availability = false;

	/**
	 * Whether check-ins on check-outs are allowed.
	 * 
	 * @var bool
	 */
	protected $inonout_allowed = true;

	/**
	 * The percent ratio for nights/transfers in split stays.
	 * 
	 * @var int
	 */
	protected $nights_transfers_ratio = 100;

	/**
	 * Whether we need to behave for the front-end booking process.
	 * 
	 * @var bool
	 */
	protected $is_front_booking = false;

	/**
	 * The warning string occurred.
	 *
	 * @var string
	 */
	protected $warning = '';

	/**
	 * The error string occurred.
	 *
	 * @var string
	 */
	protected $error = '';

	/**
	 * The last error code occurred in TACVBO.
	 *
	 * @var int
	 */
	protected $errorCode = 0;

	/**
	 * A list of fully booked room ids.
	 *
	 * @var array
	 */
	protected $fully_booked = [];

	/**
	 * Associative list of all rooms.
	 *
	 * @var array
	 */
	protected $all_rooms = [];

	/**
	 * Associative list of all rate plans.
	 *
	 * @var array
	 */
	protected $all_rplans = [];

	/**
	 * Map of min/max LOS tariffs defined per room.
	 *
	 * @var array
	 */
	protected $min_max_los_tariffs_map = [];

	/**
	 * Class constructor is protected.
	 *
	 * @see 	getInstance()
	 */
	protected function __construct()
	{
		// load dependencies
		if (!class_exists('TACVBO')) {
			require_once VBO_SITE_PATH . DIRECTORY_SEPARATOR . 'helpers' . DIRECTORY_SEPARATOR . 'tac.vikbooking.php';
		}

		/**
		 * Set default nights transfer ratio.
		 * 
		 * @since 	1.18.5 (J) - 1.8.5 (WP)
		 */
		$this->setNightsTransfersRatio();
	}

	/**
	 * Returns the global object, either a new instance or the existing instance
	 * if the class was already instantiated, unless a new instance is requested.
	 * 
	 * @param 	bool 	$anew 	True for forcing a new instance.
	 *
	 * @return 	VikBookingAvailability
	 */
	public static function getInstance($anew = false)
	{
		if (is_null(static::$instance) || $anew) {
			static::$instance = new static();
		}

		return static::$instance;
	}

	/**
	 * Counts the total number of nights of stay according to the stay dates.
	 * 
	 * @param 	int 	$from_ts 	optional check-in timestamp.
	 * @param 	int 	$to_ts 		optional check-out timestamp.
	 * 
	 * @return 	int 	the total number of nights of stay.
	 * 
	 * @since 	1.16.0 (J) - 1.6.0 (WP) added args to make this an helper method.
	 */
	public function countNightsOfStay($from_ts = null, $to_ts = null)
	{
		if (!count($this->stay_ts) && (empty($from_ts) || empty($to_ts))) {
			return 1;
		}

		if (!empty($from_ts) && !empty($to_ts)) {
			$use_from = $from_ts;
			$use_to   = $to_ts;
		} else {
			$use_from = $this->stay_ts[0];
			$use_to   = $this->stay_ts[1];
		}

		$secdiff = $use_to - $use_from;
		$daysdiff = $secdiff / 86400;
		if (is_int($daysdiff)) {
			$daysdiff = $daysdiff < 1 ? 1 : $daysdiff;
		} else {
			if ($daysdiff < 1) {
				$daysdiff = 1;
			} else {
				$sum = floor($daysdiff) * 86400;
				$newdiff = $secdiff - $sum;
				$maxhmore = VikBooking::getHoursMoreRb() * 3600;
				if ($maxhmore >= $newdiff) {
					$daysdiff = floor($daysdiff);
				} else {
					$daysdiff = ceil($daysdiff);
				}
			}
		}

		return $daysdiff;
	}

	/**
	 * Explains the error code occurred or passed by using translation strings.
	 * 
	 * @param 	int 	$force_code 	optional error code to explain.
	 * 
	 * @return 	string 					the explanation of the error, or an empty string.
	 */
	public function explainErrorCode($force_code = 0)
	{
		// the error code to parse
		$use_ecode = $force_code ? $force_code : $this->errorCode;

		if (empty($use_ecode)) {
			return '';
		}

		/**
		 * Error code identifier:
		 * 
		 * 1 = missing/invalid request options.
		 * 2 = invalid authentication.
		 * 3 = no rooms found for the given party.
		 * 4 = not compliant with booking restrictions.
		 * 5 = not compliant with global closing dates.
		 * 6 = no rates defined for the given length of stay.
		 * 7 = no availability for the dates requested.
		 * 8 = no rooms available due to restrictions at room or rate plan level.
		 */

		switch ($use_ecode) {
			case 1:
				return 'Missing or invalid request options.';
			case 2:
				return 'Invalid request authentication.';
			case 3:
				$expl = JText::translate('VBO_AV_ECODE_3');
				return $expl != 'VBO_AV_ECODE_3' ? $expl : 'No rooms found for the given party.';
			case 4:
				return 'Not compliant with the booking restrictions.';
			case 5:
				return 'Not compliant with the global closing dates.';
			case 6:
				return 'No rates defined for the given length of stay.';
			case 7:
				$expl = JText::translate('VBO_AV_ECODE_7');
				return $expl != 'VBO_AV_ECODE_7' ? $expl : 'No availability for the dates requested.';
			case 8:
				return 'No rooms available due to room or rate plan restrictions.';
			default:
				return 'Unknown error code.';
		}
	}

	/**
	 * Returns a list of room IDs from the given category IDs.
	 * 
	 * @param 	array 	$category_ids 	List of category IDs to filter.
	 * 
	 * @return 	array 	List of involved and unique room IDs.
	 * 
	 * @since 	1.17.6 (J) - 1.7.6 (WP)
	 */
	public function filterRoomCategories(array $category_ids)
	{
		$category_ids = array_filter(array_map('abs', array_map('intval', $category_ids)));

		if (!$category_ids) {
			return [];
		}

		$dbo = JFactory::getDbo();

		$q = $dbo->getQuery(true)
			->select($dbo->qn('id'))
			->from($dbo->qn('#__vikbooking_rooms'));

		foreach ($category_ids as $cat_id) {
			$q->where([
				$dbo->qn('idcat') . ' = ' . $dbo->q($cat_id . ';'),
				$dbo->qn('idcat') . ' LIKE ' . $dbo->q($cat_id . ';%'),
				$dbo->qn('idcat') . ' LIKE ' . $dbo->q('%;' . $cat_id . ';%'),
				$dbo->qn('idcat') . ' LIKE ' . $dbo->q('%;' . $cat_id . ';'),
			], 'OR');
		}

		$dbo->setQuery($q);

		return array_values(array_unique(array_map('intval', $dbo->loadColumn())));
	}

	/**
	 * Loads a list of room categories.
	 * 
	 * @param 	bool 	$public 	True for excluding the "private" categories.
	 * 
	 * @return 	array 				The associative list of categories.
	 * 
	 * @since 	1.17.6 (J) - 1.7.6 (WP)
	 * @since 	1.18.3 (J) - 1.8.3 (WP) added argument $public.
	 */
	public function loadRoomCategories($public = false)
	{
		$dbo = JFactory::getDbo();

		$dbo->setQuery(
			$dbo->getQuery(true)
				->select([
					$dbo->qn('id'),
					$dbo->qn('name'),
				])
				->from($dbo->qn('#__vikbooking_categories'))
				->order($dbo->qn('name') . ' ASC')
		);

		$categories = $dbo->loadAssocList();

		if ($public) {
			$categories = array_values(array_filter($categories, function($category) {
				return substr((string) $category['name'], 0, 1) !== '_';
			}));
		}

		return $categories;
	}

	/**
	 * Loads all rooms in VBO and maps them into an associative array.
	 * 
	 * @param 	array 	$ids 	optional list of IDs to load.
	 * @param 	int 	$max 	optional rooms limit to fetch.
	 * @param 	bool 	$anew 	true to avoid object caching.
	 * 
	 * @return 	array 			the associative list of rooms.
	 * 
	 * @since 	1.16.10 (J) - 1.6.10 (WP) added arguments $ids, $max.
	 * @since 	1.17.5 (J) - 1.7.5 (WP) added argument $anew.
	 * @since 	1.18.6 (J) - 1.8.6 (WP) added support to negative IDs for category IDs.
	 */
	public function loadRooms(array $ids = [], $max = 0, $anew = false)
	{
		if ($this->all_rooms && !$anew) {
			// return previously cached array if available
			return $this->all_rooms;
		}

		$dbo = JFactory::getDbo();

		$q = $dbo->getQuery(true)
			->select([
				$dbo->qn('id'),
				$dbo->qn('name'),
				$dbo->qn('img'),
				$dbo->qn('idcat'),
				$dbo->qn('avail'),
				$dbo->qn('units'),
				$dbo->qn('fromadult'),
				$dbo->qn('toadult'),
				$dbo->qn('fromchild'),
				$dbo->qn('tochild'),
				$dbo->qn('totpeople'),
				$dbo->qn('mintotpeople'),
			])
			->from($dbo->qn('#__vikbooking_rooms'));

		if ($ids) {
			// cast all IDs to signed integers
			$ids = array_map('intval', $ids);

			// normalize listing IDs to support (negative) category IDs
	        $categoryIds = array_filter($ids, function($id) {
	            return $id < 0;
	        });

	        if ($categoryIds) {
	        	// filter out negative IDs
	        	$ids = array_values(array_filter($ids, function($id) {
		            return $id > 0;
		        }));

		        // include the listing IDs from category IDs, if any
		        $ids = array_values(array_unique(array_merge($ids, $this->filterRoomCategories($categoryIds))));
	        }

			// set query clause
			$q->where($dbo->qn('id') . ' IN (' . implode(', ', $ids) . ')');
		}

		$q->order($dbo->qn('avail') . ' DESC');
		$q->order($dbo->qn('name') . ' ASC');

		$dbo->setQuery($q, 0, $max);
		$room_rows = $dbo->loadAssocList();

		if (!$room_rows) {
			return $anew ? [] : $this->all_rooms;
		}

		if ($this->isFrontBooking()) {
			// apply translations on rooms
			$vbo_tn = VikBooking::getTranslator();
			$vbo_tn->translateContents($room_rows, '#__vikbooking_rooms');
		}

		$assoc_rooms = [];
		foreach ($room_rows as $room) {
			$assoc_rooms[$room['id']] = $room;
		}

		if (!$anew) {
			// cache room records
			$this->all_rooms = $assoc_rooms;
		}

		return $anew ? $assoc_rooms : $this->all_rooms;
	}

	/**
	 * Sets the current rooms as an associative array of information. The
	 * array keys represent the room IDs as an associative array of details.
	 * 
	 * @param 	array 	$rooms 	the associatve list of rooms.
	 * 
	 * @return 	self
	 */
	public function setRooms(array $rooms = [])
	{
		$this->all_rooms = $rooms;

		return $this;
	}

	/**
	 * Filters all rooms by keeping just the ones published/available.
	 * 
	 * @return 	array 	associative array of published (available) rooms.
	 */
	public function filterPublishedRooms()
	{
		$rooms = $this->loadRooms();

		foreach ($rooms as $k => $room) {
			if (!($room['avail'] ?? 1)) {
				unset($rooms[$k]);
			}
		}

		return $rooms;
	}

	/**
	 * Finds a room (or multiple rooms) by name.
	 * 
	 * @param 	string 	$name 	The room name to look for.
	 * @param 	bool 	$all 	True to return a list of matches.
	 * 
	 * @return 	array
	 * 
	 * @since 	1.16.10 (J) - 1.6.10 (WP)
	 * @since 	1.18.17 (J) - 1.8.17 (WP) added argument $all.
	 */
	public function getRoomByName(string $name, bool $all = false)
	{
		$dbo = JFactory::getDbo();

		$q = $dbo->getQuery(true)
			->select('*')
			->from($dbo->qn('#__vikbooking_rooms'))
			->order($dbo->qn('avail') . ' DESC')
			->order($dbo->qn('name') . ' ASC');

		foreach (array_filter(preg_split("/[\s\-_.,]+/", $name)) as $nm_part) {
			$q->where($dbo->qn('name') . ' LIKE ' . $dbo->q("%{$nm_part}%"));
		}

		if ($all) {
			// match all records
			$dbo->setQuery($q);
			return $dbo->loadAssocList();
		}

		$dbo->setQuery($q, 0, 1);
		$record = $dbo->loadAssoc();

		return $record ?? [];
	}

	/**
	 * Loads all rate plans in VBO and maps them into an associative array.
	 * 
	 * @param 	bool 	$no_cache 	True to avoid internal caching.
	 * 
	 * @return 	array 	the associative list of rate plans.
	 * 
	 * @since 	1.17.6 (J) - 1.7.6 (WP) added $no_cache argument.
	 */
	public function loadRatePlans($no_cache = false)
	{
		if (!$no_cache && $this->all_rplans) {
			// return previously cached array if available
			return $this->all_rplans;
		}

		$dbo = JFactory::getDbo();

		$rate_plans = [];
		$derived_rplans = [];

		$q = "SELECT * FROM `#__vikbooking_prices` ORDER BY `name` ASC;";
		$dbo->setQuery($q);
		$rplan_rows = $dbo->loadAssocList();

		foreach ($rplan_rows as $rplan) {
			if (!empty($rplan['derived_id']) && !empty($rplan['derived_data'])) {
				// decode the derived data information
				$rplan['derived_data'] = json_decode($rplan['derived_data'], true);
				// add rate plan ID
				$derived_rplans[] = $rplan['id'];
			}
			$rate_plans[$rplan['id']] = $rplan;
		}

		// add the information about the parent rate plans, if any
		foreach ($rate_plans as $rplan_id => $rplan_data) {
			if (in_array($rplan_id, $derived_rplans) && isset($rate_plans[$rplan_data['derived_id']])) {
				// set parent rate details
				$rate_plans[$rplan_id]['parent_rate_id'] = $rate_plans[$rplan_data['derived_id']]['id'];
				$rate_plans[$rplan_id]['parent_rate_name'] = $rate_plans[$rplan_data['derived_id']]['name'];
			}
		}

		// sort rate plans
		$rate_plans = VikBooking::sortRatePlans($rate_plans, true);

		if (!$no_cache) {
			$this->all_rplans = $rate_plans;
		}

		return $rate_plans;
	}

	/**
	 * Returns a list of rate plans derived from the given parent rate plan ID.
	 * 
	 * @param 	int 	$parent_rate_id 	The parent rate plan ID.
	 * @param 	?array 	$all_rate_plans 	Optional cached rate plans list.
	 * 
	 * @return 	array
	 * 
	 * @since 	1.16.10 (J) - 1.6.10 (WP)
	 * @since 	1.18.5 (J) - 1.8.5 (WP) added argument $all_rate_plans.
	 */
	public function getDerivedRatePlans(int $parent_rate_id, ?array $all_rate_plans = null)
	{
		$derived_rplans = [];

		foreach (($all_rate_plans ?: $this->loadRatePlans()) as $rp_id => $rplan) {
			if ($rplan['derived_id'] && $rplan['derived_id'] == $parent_rate_id && $rplan['derived_data']) {
				// this rate plan is derived from the given parent rate plan
				$derived_rplans[] = $rplan;
			}
		}

		return $derived_rplans;
	}

	/**
	 * Returns the orphan dates for the given or set rooms and dates.
	 * 
	 * @param 	array 	$options 	List of fetching options.
	 * 
	 * @return 	array 	Associative list of rooms orphan dates.
	 * 
	 * @since 	1.17.7 (J) - 1.7.7 (WP)
	 */
	public function getOrphanDates(array $options = [])
	{
		// flag to indicate whether room-level restrictions were loaded
		$use_room_level_restr = false;

		// load and set rooms and restrictions
		if ($options['room_ids'] ?? []) {
			// force the requested rooms to be loaded
			$this->loadRooms((array) $options['room_ids']);

			// load restrictions with room filters
			$all_restrictions = VikBooking::loadRestrictions(true, (array) $options['room_ids']);

			// turn flag on for room-level restrictions being used
			$use_room_level_restr = true;
		} else {
			// load restrictions with no filters
			$all_restrictions = VikBooking::loadRestrictions(false);
		}

		// default minimum stay
		$def_min_stay = VikBooking::getDefaultNightsCalendar();

		// figure out dates range
		if (($current_stay_dates = $this->getStayDates()) && !($options['from_date'] ?? null)) {
			// populate current stay dates
			$options['from_date'] = $current_stay_dates[0];
			$options['to_date'] = $current_stay_dates[1];
		} else {
			// check given fetching options
			if (!($options['from_date'] ?? null)) {
				// default to today's date
				$options['from_date'] = date('Y-m-d');
			}

			if (!($options['to_date'] ?? null)) {
				// default to next week's date
				$options['to_date'] = date('Y-m-d', strtotime('+1 week'));
			}
		}

		// calculate the highest minimum stay for a better accuracy
		$all_min_los = [($def_min_stay > 1 ? $def_min_stay : 0)];
		foreach ($all_restrictions as $index => $restrs) {
			$all_min_los = array_merge($all_min_los, array_column($restrs, 'minlos'));
		}
		$max_min_los = max(array_map('intval', $all_min_los));

		if (!$max_min_los) {
			// no minimum stay restrictions to apply, hence no orphan dates
			return [];
		}

		// calculate past limit timestamp for today at midnight
		$lim_past_ts = strtotime(date('Y-m-d'));

		// always fetch a wider availability window for better accuracy
		$fetch_from_date = date('Y-m-d', strtotime("-{$max_min_los} days", strtotime($options['from_date'])));
		$fetch_to_date = date('Y-m-d', strtotime("+{$max_min_los} days", strtotime($options['to_date'])));
		if (strtotime($fetch_from_date) < $lim_past_ts) {
			// start fetching the inventory from today's date (past dates not accepted)
			$fetch_from_date = date('Y-m-d');
		}

		// always set stay dates
		$this->setStayDates($fetch_from_date, $fetch_to_date);

		// obtain the availability inventory by ignoring restrictions
		$ari = $this->getInventory(false);

		if (!$ari) {
			return [];
		}

		// build orphan dates pool
		$orphans_pool = [];

		// UTC timezone
		$utc_tz = new DateTimezone('UTC');

		// get date bounds
		$from_bound = new DateTime($options['from_date'], $utc_tz);
		$to_bound = new DateTime($options['to_date'], $utc_tz);

		// build iterable dates interval (period)
		$date_range = new DatePeriod(
			// start date included by default in the result set
			$from_bound,
			// interval between recurrences within the period
			new DateInterval('P1D'),
			// end date excluded by default from the result set
			$to_bound->modify('+1 day')
		);

		// scan rooms availability inventory
		foreach ($ari as $idroom => $room_ari) {
			// load proper room-level restrictions if not done already
			$all_restrictions = $use_room_level_restr ? $all_restrictions : VikBooking::loadRestrictions(true, [$idroom]);

			// iterate the dates interval
			foreach ($date_range as $dt) {
				// calculate date values
				$day_key = $dt->format('Y-m-d');
				$day_now_ts = strtotime($day_key);
				$day_after_ts = strtotime('+1 day', $day_now_ts);

				// parse room restrictions for the current inventory day and room to get the minimum stay
				$restr = VikBooking::parseSeasonRestrictions($day_now_ts, $day_after_ts, 1, $all_restrictions);
				$minimum_stay = (int) ($restr['minlos'] ?? $def_min_stay);

				if ($minimum_stay < 2) {
					// no real minimum stay restriction detected for this day
					continue;
				}

				// scan rooms availability
				foreach ($room_ari['inventory'] as $keypoint => $inventory) {
					if ($inventory['day'] != $day_key) {
						// not the date-key point we are looking for
						continue;
					}

					// tell whether the listing is available on the current date-key point
					$is_available = (bool) ($inventory['units_to_sell'] ?? $inventory['available'] ?? 0);
					if (!$is_available) {
						// this day is fully booked, hence no orphan dates
						continue 2;
					}

					// scan the availability for the next minimum stay days from the current day-key point
					for ($d = 0; $d < $minimum_stay; $d++) {
						// build next date-key point value (start from current day-key point as it counts as one available night of stay)
						$next_keypoint = $keypoint + $d;

						if (!isset($room_ari['inventory'][$next_keypoint])) {
							// no more data available
							break;
						}

						// tell whether the listing will be available on this future date-key point (0th day will always be available)
						$will_be_available = (bool) ($room_ari['inventory'][$next_keypoint]['units_to_sell'] ?? $room_ari['inventory'][$next_keypoint]['available'] ?? 0);

						if (!$will_be_available) {
							/**
							 * Probable orphan date found for bookings on days ahead. Ensure this
							 * is truly an orphan date by checking the days before. If staying
							 * on this (available) night is allowed, then this should not be
							 * considered as an orphan date, even though arriving is not allowed.
							 */
							if (!($options['orphans_checkin'] ?? false)) {
								// check previous dates before saying it's an orphan date
								for ($backd = ($keypoint - 1), $distance = 1; $backd >= 0; $backd--, $distance++) {
									if (!isset($room_ari['inventory'][$backd])) {
										// nothing to do, let it be an orphan date
										break;
									}

									// tell whether the listing was available on this previous date-key point
									$was_available = (bool) ($room_ari['inventory'][$backd]['units_to_sell'] ?? $room_ari['inventory'][$backd]['available'] ?? 0);

									if (!$was_available) {
										// nothing to do, let it be an orphan date
										break;
									}

									// calculate past date values
									$past_day_now_ts = strtotime($room_ari['inventory'][$backd]['day']);
									$past_day_after_ts = strtotime('+1 day', $past_day_now_ts);

									// parse room restrictions for the current past day and room to get the minimum stay
									$restr = VikBooking::parseSeasonRestrictions($past_day_now_ts, $past_day_after_ts, 1, $all_restrictions);
									$minimum_stay = (int) ($restr['minlos'] ?? $def_min_stay);

									if ($minimum_stay <= $distance) {
										// free day with a minimum stay that allows to stay on the presumed orphan date
										// break the cycles for the presumed orphan date-key point, because it's not truly an orphan
										break 3;
									}
								}
							}

							// orphan date found
							if (!isset($orphans_pool[$idroom])) {
								// start container
								$orphans_pool[$idroom] = [
									'room_name' => $room_ari['room_name'],
									'orphans'   => [],
								];
							}

							// push orphan date information
							$orphans_pool[$idroom]['orphans'][] = [
								'day'                         => $day_key,
								'current_min_stay_nights'     => $minimum_stay,
								'max_min_stay_nights_allowed' => $d,
							];

							// break the cycles for this date-key point
							break 2;
						}
					}
				}
			}
		}

		// return the associative list of room orphan dates found, if any
		return $orphans_pool;
	}

	/**
	 * Returns the inventory for the rooms and dates set.
	 * 
	 * @param 	bool 	$restrictions 	Whether to include booking restrictions data.
	 * 
	 * @return 	array 					Associative list of rooms availability inventory.
	 * 
	 * @since 	1.16.10 (J) - 1.6.10 (WP)
	 */
	public function getInventory($restrictions = false)
	{
		$stay_ts = $this->getStayDates(true);

		if (!$stay_ts) {
			$this->setError('No dates provided');
			return [];
		}

		if ($stay_ts[0] > $stay_ts[1]) {
			$this->setError('Invalid dates provided');
			return [];
		}

		$info_from = getdate($stay_ts[0]);

		$room_ids   = array_column($this->loadRooms(), 'id');
		$room_names = array_column($this->loadRooms(), 'name');
		$room_units = array_column($this->loadRooms(), 'units');

		if (!$room_ids) {
			$this->setError('No rooms provided');
			return [];
		}

		// load busy records
		$busy_records = VikBooking::loadBusyRecords($room_ids, $stay_ts[0], $stay_ts[1]);

		// room restrictions associative list
		$room_restrictions = [];

		// global minimum stay restriction
		$glob_minlos = VikBooking::getDefaultNightsCalendar();
		$glob_minlos = $glob_minlos < 1 ? 1 : (int) $glob_minlos;

		// inventory pool
		$ari = [];
		foreach ($room_ids as $room_index => $room_id) {
			$ari[$room_id] = [
				'room_name' => $room_names[$room_index],
				'inventory' => [],
			];
		}

		// loop through all dates in the range
		while ($info_from[0] <= $stay_ts[1]) {
			// build day after timestamp
			$day_after_ts = mktime(0, 0, 0, $info_from['mon'], $info_from['mday'] + 1, $info_from['year']);

			// scan the units booked for each requested room
			foreach ($room_ids as $room_index => $room_id) {
				$units_booked = 0;
				// scan all the occupied records of the current room
				foreach (($busy_records[$room_id] ?? []) as $busy) {
					$info_in  = getdate($busy['checkin']);
					$info_out = getdate($busy['checkout']);
					$in_ts    = mktime(0, 0, 0, $info_in['mon'], $info_in['mday'], $info_in['year']);
					$out_ts   = mktime(0, 0, 0, $info_out['mon'], $info_out['mday'], $info_out['year']);
					if ($info_from[0] >= $in_ts && $info_from[0] < $out_ts) {
						// increase room units booked
						$units_booked++;
					}
				}

				// count units left
				$units_left = $units_booked >= $room_units[$room_index] ? 0 : ($room_units[$room_index] - $units_booked);

				// set room inventory date
				$inventory_day = [
					'day' => date('Y-m-d', $info_from[0]),
				];

				if ($room_units[$room_index] == 1) {
					// single-unit listing inventory structure
					$inventory_day['available'] = (bool) $units_left;
				} else {
					// multi-unit room-type inventory structure
					$inventory_day['units_booked']  = $units_booked;
					$inventory_day['units_to_sell'] = $units_left;
				}

				// check for room-level restrictions
				if ($restrictions) {
					if (!isset($room_restrictions[$room_id])) {
						// load room restrictions only once
						$room_restrictions[$room_id] = VikBooking::loadRestrictions(true, [$room_id]);
					}

					// parse room restrictions for the current inventory day
					$restr = VikBooking::parseSeasonRestrictions($info_from[0], $day_after_ts, 1, $room_restrictions[$room_id]);
					if ($restr) {
						$inventory_day['restrictions'] = [
							'min_los' => (int) $restr['minlos'],
						];
						if (($restr['maxlos'] ?? 0)) {
							$inventory_day['restrictions']['max_los'] = (int) $restr['maxlos'];
						}
						if (!empty($restr['cta'])) {
							$inventory_day['restrictions']['closed_to_arrival'] = array_map(function($wday) {
								switch ($wday) {
									case '1':
										return 'Monday';
									case '2':
										return 'Tuesday';
									case '3':
										return 'Wednesday';
									case '4':
										return 'Thursday';
									case '5':
										return 'Friday';
									case '6':
										return 'Saturday';
									default:
										return 'Sunday';
								}
							}, $restr['cta']);
						}
						if (!empty($restr['ctd'])) {
							$inventory_day['restrictions']['closed_to_departure'] = array_map(function($wday) {
								switch ($wday) {
									case '1':
										return 'Monday';
									case '2':
										return 'Tuesday';
									case '3':
										return 'Wednesday';
									case '4':
										return 'Thursday';
									case '5':
										return 'Friday';
									case '6':
										return 'Saturday';
									default:
										return 'Sunday';
								}
							}, $restr['ctd']);
						}
					} else {
						// set the global minimum stay restriction
						$inventory_day['restrictions'] = [
							'min_los' => $glob_minlos,
						];
					}
				}

				// push room-day inventory
				$ari[$room_id]['inventory'][] = $inventory_day;
			}

			// go to next date
			$info_from = getdate($day_after_ts);
		}

		return $ari;
	}

	/**
	 * Gets the available room rates for the specified dates, party and rooms.
	 * 
	 * @param 	array 	$params 	optional list of options to be forced for TACVBO.
	 * 
	 * @return 	mixed 	boolean false in case of errors or array result of TACVBO class.
	 */
	public function getRates($params = [])
	{
		// reset errors to their initial values
		$this->error = '';
		$this->errorCode = 0;

		if (!$this->stay_dates) {
			$this->setError('No dates provided');
			return false;
		}

		if (!$this->room_parties) {
			$this->setError('No room party provided');
			return false;
		}

		// count injected rooms, if any
		$tot_rooms = count($this->room_ids);

		// build options array for TACVBO
		$options = [
			'hash' 		 => md5('vbo.e4j.vbo'),
			'req_type' 	 => 'hotel_availability',
			'start_date' => $this->stay_dates[0],
			'end_date' 	 => $this->stay_dates[1],
			'nights' 	 => $this->countNightsOfStay(),
			'num_rooms'  => ($tot_rooms > 0 ? $tot_rooms : 1),
			'adults' 	 => [$this->getPartyGuests('adults', 0)],
			'children' 	 => [$this->getPartyGuests('children', 0)],
			'only_rates' => 1,
			'wtax'       => $params['wtax'] ?? null,
		];

		if ($tot_rooms > 1 && count($this->room_parties) == $tot_rooms) {
			// re-build list of adults and children
			$options['adults'] = [];
			$options['children'] = [];
			for ($i = 0; $i < $tot_rooms; $i++) {
				$options['adults'][] = $this->getPartyGuests('adults', $i);
				$options['children'][] = $this->getPartyGuests('children', $i);
			}
		}

		// check for implicit settings
		if (!empty($params['max_rooms_limit'])) {
			// set rooms maximum limit
			TACVBO::$maxRoomsLimit = (int) $params['max_rooms_limit'];
			// unset implicit setting
			unset($params['max_rooms_limit']);
		} else {
			// reset to initial value for subsequent calls
			TACVBO::$maxRoomsLimit = 0;
		}

		if (is_array(($params['forced_room_ids'] ?? null))) {
			// set allowed room IDs
			TACVBO::$forcedRoomIds = $params['forced_room_ids'];
			// unset implicit setting
			unset($params['forced_room_ids']);
		} else {
			// reset to initial value for subsequent calls
			TACVBO::$forcedRoomIds = [];
		}

		// merge default options with params, if any
		$options = array_merge($options, $params);

		// invoke TACVBO class by injecting the options
		TACVBO::$getArray = true;
		TACVBO::$ignoreRestrictions = $this->ignore_restrictions;
		TACVBO::$ignoreAvailability = $this->ignore_availability;
		$website_rates = TACVBO::tac_av_l($options);

		// store the error code occurred (if any)
		$this->errorCode = TACVBO::getErrorCode();

		if (!is_array($website_rates)) {
			// critical error
			$this->setError(str_replace('e4j.error.', '', $website_rates));
			return false;
		}

		if (isset($website_rates['e4j.error'])) {
			// calculation/availability error
			$this->setError($website_rates['e4j.error']);
			// check if reserved keys like "fullybooked" are present
			if (isset($website_rates['fullybooked']) && is_array($website_rates['fullybooked'])) {
				// store fully booked rooms array
				$this->fully_booked = $website_rates['fullybooked'];
			}
			// always return false
			return false;
		}

		// optional filter by room IDs will be applied on this flow
		$found_rids = array_keys($website_rates);
		$unwanted_rids = $tot_rooms ? array_diff($found_rids, $this->room_ids) : [];
		foreach ($unwanted_rids as $rid) {
			unset($website_rates[$rid]);
		}

		return $website_rates;
	}

	/**
	 * Finds the available suggestions in case of no availability previously occurred
	 * while getting the rates. This method should be called after getRates() so that
	 * a valid errorCode to be analized will be available, unless code is forced.
	 * Suggestions can include closest booking dates and/or different room-guest parties.
	 * 
	 * @param 	int 	$force_code 	the error code to force (no availability or party unsatisfied).
	 * @param 	array 	$force_rooms 	an optional list of room IDs to consider for the suggestions.
	 * 
	 * @return 	array 	array of alternative dates, alternative room-parties and split stays.
	 * 
	 * @since 	1.16.0 (J) - 1.6.0 (WP) list of split stays available is also returned.
	 */
	public function findSuggestions($force_code = 0, $force_rooms = [])
	{
		// reset error and warning strings to start a new calculation
		$this->error = '';
		$this->warning = '';

		// build containers for the two types of suggestions
		$alternative_dates 	 = [];
		$alternative_parties = [];
		$split_stay_sols 	 = [];

		// the error code to parse
		$use_ecode = $force_code ? $force_code : $this->errorCode;

		if (empty($use_ecode)) {
			// do not continue if no valid errors previously occurred or forced
			$this->setError('Empty error code');
			return [$alternative_dates, $alternative_parties, $split_stay_sols];
		}

		if ($use_ecode == 7 && (count($this->fully_booked) || count($force_rooms))) {
			// get the closest booking dates for the compatible, yet unavailable, rooms
			$use_rooms = count($force_rooms) ? $force_rooms : $this->fully_booked;
			$alternative_dates = $this->findClosestRoomDateSolutions($use_rooms);
			// calculate the split stay solutions available for the compatible rooms
			$split_stay_sols = $this->findSplitStays($use_rooms);
		}

		if ($use_ecode == 3) {
			// no rooms found for the given party, suggest alternative parties
			$active_rooms = count($force_rooms) ? $force_rooms : array_keys($this->filterPublishedRooms());
			// match all the available rooms in the requested or near dates
			$all_solutions = $this->findClosestRoomDateSolutions($active_rooms);
			// sort solutions by bigger rooms
			$all_solutions = $this->sortBiggerRoomSolutions($all_solutions);
			// find matching solutions for the requested party
			$alternative_parties = $this->matchSolutionsParty($all_solutions);
		}

		return [$alternative_dates, $alternative_parties, $split_stay_sols];
	}

	/**
	 * Given a list of unavailable room IDs, yet compatible with the party and LOS requested,
	 * we build a list of available solutions for booking split stays on the same dates. The
	 * visibility should be public so that other Views could use just this method.
	 * 
	 * @param 	array 	$room_ids 	list of unavailable, yet compatible, room IDs.
	 * @param 	?array 	$busy_list 	optional list of busy records for the involved dates.
	 * 
	 * @return 	array 				associative list of available split stays, or empty array.
	 * 
	 * @since 	1.16.0 (J) - 1.6.0 (WP)
	 */
	public function findSplitStays(array $room_ids = [], ?array $busy_list = null)
	{
		if (!$room_ids) {
			return [];
		}

		// get all website rooms
		$all_rooms = $this->loadRooms();

		// validate max occupancy for the given rooms
		if (count($this->room_parties) === 1) {
			// we only use the first party for the occupancy validation
			$party_adults 	= $this->getPartyGuests('adults', $party = 0);
			$party_children = $this->getPartyGuests('children', $party = 0);
			foreach ($room_ids as $rindex => $rid) {
				if (!isset($all_rooms[$rid])) {
					unset($room_ids[$rindex]);
					continue;
				}
				if ($party_adults > $all_rooms[$rid]['toadult'] || $party_children > $all_rooms[$rid]['tochild'] || ($party_adults + $party_children) > $all_rooms[$rid]['totpeople']) {
					// max occupancy not met
					unset($room_ids[$rindex]);
					continue;
				}
			}

			if (!$room_ids) {
				return [];
			}

			// reset array keys
			$room_ids = array_values($room_ids);
		}

		// get original check-in and check-out timestamps
		list($orig_checkin_ts, $orig_checkout_ts) = $this->getStayDates(true);
		$info_from = getdate($orig_checkin_ts);
		$info_to   = getdate($orig_checkout_ts);

		// the final check-out date
		$final_checkout_ymd = date('Y-m-d', $orig_checkout_ts);

		// count original length of stay and nights involved
		$tot_nights = $this->countNightsOfStay();
		$groupdays  = VikBooking::getGroupDays($orig_checkin_ts, $orig_checkout_ts, $tot_nights);

		if ($tot_nights < 2) {
			// useless to waste time on finding a split stay if not at least 2 nights
			return [];
		}

		// load the occupied records for these dates and rooms
		$busy_records = !is_null($busy_list) ? $busy_list : VikBooking::loadBusyRecords($room_ids, $orig_checkin_ts, strtotime('+1 day', $orig_checkout_ts));

		// calculate available rooms for each night
		$avroom_nights = [];
		foreach ($room_ids as $rid) {
			if (!isset($all_rooms[$rid])) {
				continue;
			}
			$room = $all_rooms[$rid];
			foreach ($groupdays as $gday) {
				$day_key = date('Y-m-d', $gday);
				$bfound = 0;
				if (!isset($busy_records[$rid])) {
					$busy_records[$rid] = [];
				}
				foreach ($busy_records[$rid] as $bu) {
					$busy_info_in = getdate($bu['checkin']);
					$busy_info_out = getdate($bu['checkout']);
					$busy_in_ts = mktime(0, 0, 0, $busy_info_in['mon'], $busy_info_in['mday'], $busy_info_in['year']);
					$busy_out_ts = mktime(0, 0, 0, $busy_info_out['mon'], $busy_info_out['mday'], $busy_info_out['year']);
					if ($gday >= $busy_in_ts && $gday == $busy_out_ts && !$this->inonout_allowed && $room['units'] < 2) {
						// check-ins on check-outs not allowed
						$bfound++;
						if ($bfound >= $room['units']) {
							break;
						}
					}
					if ($gday >= $busy_in_ts && $gday < $busy_out_ts) {
						$bfound++;
						if ($bfound >= $room['units']) {
							break;
						}
					}
				}
				if ($bfound < $room['units']) {
					// push this night as available for this room
					if (!isset($avroom_nights[$rid])) {
						$avroom_nights[$rid] = [];
					}
					$avroom_nights[$rid][] = $day_key;
				} else {
					// room not available on this night, make sure to unset any previous value
					if (isset($avroom_nights[$rid]) && in_array($day_key, $avroom_nights[$rid])) {
						$unav_key = array_search($day_key, $avroom_nights[$rid]);
						unset($avroom_nights[$rid][$unav_key]);
						$avroom_nights[$rid] = array_values($avroom_nights[$rid]);
					}
				}
			}
		}

		if (!count($avroom_nights)) {
			// no rooms available at all, there's no way to do anything
			return [];
		}

		// make sure all nights requested can be satisfied by at least one room
		foreach ($groupdays as $gday_index => $gday) {
			$day_key = date('Y-m-d', $gday);
			$day_av  = false;
			foreach ($avroom_nights as $rid => $av_nights) {
				if (in_array($day_key, $av_nights)) {
					// night was found
					$day_av = true;
					break;
				}
			}
			// ensure there's availability for this day, or permit if it's the check-out day
			if (!$day_av && !($gday_index === (count($groupdays) - 1) && $this->inonout_allowed)) {
				// this night of stay is not available in any room, unable to proceed
				return [];
			}
		}

		// count the number of consecutive nights per room
		$cons_room_nights = [];
		$tot_gdays = count($groupdays);
		foreach ($groupdays as $k => $gday) {
			$day_key = date('Y-m-d', $gday);
			if (!isset($cons_room_nights[$day_key])) {
				$cons_room_nights[$day_key] = [];
			}
			foreach ($avroom_nights as $rid => $av_nights) {
				if (in_array($day_key, $av_nights)) {
					if (!isset($cons_room_nights[$day_key][$rid])) {
						$cons_room_nights[$day_key][$rid] = [];
					}
					// count the next consecutive nights of stay
					$cons_room_nights[$day_key][$rid][] = $day_key;
					for ($j = ($k + 1); $j < $tot_gdays; $j++) {
						$next_day_key = date('Y-m-d', $groupdays[$j]);
						if (in_array($next_day_key, $av_nights)) {
							$cons_room_nights[$day_key][$rid][] = $next_day_key;
						} else {
							break;
						}
					}
				}
			}
		}

		// sort the solutions with the highest number of consecutive nights to reduce the splits
		$cons_room_nights_sorted = [];
		foreach ($cons_room_nights as $day_key => $cons_nights) {
			$cons_room_nights_cnt = [];
			foreach ($cons_nights as $rid => $cons_dates) {
				$cons_room_nights_cnt[$rid] = count($cons_dates);
			}
			// sort the array in a descending order
			arsort($cons_room_nights_cnt);
			// restore sorted values in cloned array
			$cons_room_nights_sorted[$day_key] = [];
			foreach ($cons_room_nights_cnt as $rid => $tot_cons_nights) {
				$cons_room_nights_sorted[$day_key][$rid] = $cons_room_nights[$day_key][$rid];
			}
		}
		$cons_room_nights = $cons_room_nights_sorted;

		// validate the data just built
		$first_day_key = date('Y-m-d', $groupdays[0]);
		if (!isset($cons_room_nights[$first_day_key]) || !count($cons_room_nights[$first_day_key]) || count($cons_room_nights) != count($groupdays)) {
			// unable to proceed
			return [];
		}

		// remove the consecutive nights from the check-out date as this won't be a night of stay
		unset($cons_room_nights[$final_checkout_ymd]);

		// build the split stay solutions
		$split_stay_sols = [];

		// the number of rooms available on the first night should determine the number of split stay solutions
		foreach ($cons_room_nights[$first_day_key] as $start_rid => $cons_nights) {
			// start container of the various splits for this stay
			$split_stay_sol = [];

			// calculate last consecutive night available
			$leave_date = end($cons_nights);
			$leave_date_info = getdate(strtotime($leave_date));

			// set the check-out date to the day after the last night
			$checkout_ymd = date('Y-m-d', mktime(0, 0, 0, $leave_date_info['mon'], ($leave_date_info['mday'] + 1), $leave_date_info['year']));

			// define the first stay
			$split_stay = [
				'idroom' 	=> $start_rid,
				'room_name' => $all_rooms[$start_rid]['name'],
				'checkin'  	=> $cons_nights[0],
				'checkout' 	=> $checkout_ymd,
				'nights' 	=> $this->countNightsOfStay(strtotime($cons_nights[0]), strtotime($checkout_ymd)),
			];

			// make sure this room has got a tariff defined for this number of nights of stay
			if (!$this->roomNightsAllowed($start_rid, $split_stay['nights'])) {
				// no tariffs found for this los
				continue;
			}

			// push first stay
			$split_stay_sol[] = $split_stay;

			// loop through the next stays
			while (isset($cons_room_nights[$checkout_ymd])) {
				/**
				 * For the next splits, we use just the first available rooms, which is
				 * the one with the highest number of consecutive nights available.
				 */
				foreach ($cons_room_nights[$checkout_ymd] as $rid => $split_cons_nights) {
					// calculate last consecutive night available
					$leave_date = end($split_cons_nights);
					$leave_date_info = getdate(strtotime($leave_date));

					// set the check-out date to the day after the last night
					$checkout_ymd = date('Y-m-d', mktime(0, 0, 0, $leave_date_info['mon'], ($leave_date_info['mday'] + 1), $leave_date_info['year']));
					if ($leave_date == $final_checkout_ymd) {
						// check-out date reached
						$checkout_ymd = $final_checkout_ymd;
					}

					// count nights of stay
					$split_nights = $this->countNightsOfStay(strtotime($split_cons_nights[0]), strtotime($checkout_ymd));

					// make sure this room has got a tariff defined for this number of nights of stay
					if (!$this->roomNightsAllowed($rid, $split_nights)) {
						// no tariffs found for this los, abort solution
						$split_stay_sol = [];
						break 2;
					}

					// push split stay
					$split_stay_sol[] = [
						'idroom' 	=> $rid,
						'room_name' => $all_rooms[$rid]['name'],
						'checkin'  	=> $split_cons_nights[0],
						'checkout' 	=> $checkout_ymd,
						'nights' 	=> $split_nights,
					];

					// we try to reduce the number of splits by considering just the first room
					break;
				}
			}

			if (count($split_stay_sol) < 2) {
				// not a split stay, but rather a fully available room
				continue;
			}

			// push split stay solution
			$split_stay_sols[] = $split_stay_sol;
		}

		/**
		 * Load rooms involved in all split stays in order to validate
		 * global/room-level restrictions and closing dates on the stay.
		 */
		$rooms_involved = [];
		foreach ($split_stay_sols as $split_stay_sol) {
			foreach ($split_stay_sol as $split_stay) {
				if (!in_array($split_stay['idroom'], $rooms_involved)) {
					$rooms_involved[] = $split_stay['idroom'];
				}
			}
		}

		// load restrictions for all rooms involved
		$all_restrictions   = VikBooking::loadRestrictions(true, $rooms_involved);
		$glob_restrictions  = VikBooking::globalRestrictions($all_restrictions);
		$invalid_room_restr = [];

		// validate global restrictions
		if (VikBooking::validateRoomRestriction($glob_restrictions, $info_from, $info_to, $tot_nights)) {
			// global restrictions apply over this stay
			return [];
		}

		// validate closing dates
		if (VikBooking::validateClosingDates($orig_checkin_ts, $orig_checkout_ts)) {
			// global closing dates apply over this stay
			return [];
		}

		// validate restrictions at room level
		foreach ($rooms_involved as $rid) {
			// load restrictions at room level
			$room_level_restr = VikBooking::roomRestrictions($rid, $all_restrictions);
			if (VikBooking::validateRoomRestriction($room_level_restr, $info_from, $info_to, $tot_nights)) {
				// room-level restrictions apply over this stay
				$invalid_room_restr[] = $rid;
			}
		}

		// unset the split stays with the restricted rooms (if any)
		$altered_sols = false;
		foreach ($invalid_room_restr as $rid) {
			foreach ($split_stay_sols as $k => $split_stay_sol) {
				foreach ($split_stay_sol as $split_stay) {
					if ($rid == $split_stay['idroom']) {
						// this booking split stay cannot be suggested because of this room
						unset($split_stay_sols[$k]);
						$altered_sols = true;
						continue 2;
					}
				}
			}
		}

		// apply nights/transfers ratio limit (unless disabled)
		$nights_transfers_ratio = $this->getNightsTransfersRatio();
		if ($nights_transfers_ratio > 0 && $nights_transfers_ratio < 100) {
			// count and apply limits
			foreach ($split_stay_sols as $k => $split_stay_sol) {
				// count nights and transfers
				$split_stay_transfers = count($split_stay_sol) - 1;
				$split_stay_nights 	  = 0;
				foreach ($split_stay_sol as $split_stay_room) {
					$split_stay_nights += $split_stay_room['nights'];
				}
				// max allowed transfers
				$max_transfers = round($split_stay_nights * $nights_transfers_ratio / 100, 0);
				if (!$split_stay_transfers || $split_stay_transfers > $max_transfers) {
					// unset solution
					unset($split_stay_sols[$k]);
					$altered_sols = true;
				}
			}
		} else {
			// split stays have been disabled
			$split_stay_sols = [];
		}

		if ($altered_sols && count($split_stay_sols)) {
			// restore the array keys
			$split_stay_sols = array_values($split_stay_sols);
		}

		// return the available booking split stay solutions (if any)
		return $split_stay_sols;
	}

	/**
	 * Returns the number of guests requested from the given room-party index.
	 * 
	 * @param 	string 	$guest 	either "adults", "children" or "guests".
	 * @param 	int 	$party 	the party index number, 0 by default.
	 * 
	 * @return 	int 			the total number of guests requested in the party.
	 */
	protected function getPartyGuests($guest = 'adults', $party = 0)
	{
		if (!isset($this->room_parties[$party])) {
			return 0;
		}

		if (!strcasecmp($guest, 'adults')) {
			// adults
			return $this->room_parties[$party]['adults'];
		}

		if (!strcasecmp($guest, 'children')) {
			// children
			return $this->room_parties[$party]['children'];
		}

		// total guests
		$tot_guests = 0;
		foreach ($this->room_parties as $rparty) {
			$tot_guests += $rparty['adults'];
			$tot_guests += $rparty['children'];
		}

		return $tot_guests;
	}

	/**
	 * Given a list of unavailable room IDs, yet compatible with the party and LOS requested,
	 * we build a list of available dates when such rooms could be booked for the same LOS.
	 * 
	 * @param 	array 	$room_ids 	list of unavailable, yet compatible, room IDs.
	 * 
	 * @return 	array 				associative list of available room-dates, or empty array.
	 */
	protected function findClosestRoomDateSolutions($room_ids = [])
	{
		if (!$room_ids) {
			return [];
		}

		// get all website rooms
		$all_rooms = $this->loadRooms();

		// get original check-in and check-out timestamps
		list($orig_checkin_ts, $orig_checkout_ts) = $this->getStayDates(true);
		$info_from = getdate($orig_checkin_ts);
		$info_to   = getdate($orig_checkout_ts);

		// count original length of stay
		$tot_nights = $this->countNightsOfStay();

		// earliest checkin timestamp allowed
		$lim_past_ts = mktime(0, 0, 0, date('n'), ((int)date('j') + VikBooking::getMinDaysAdvance()), date('Y'));

		// suggested range of dates (+/- "back and forth" days from original dates)
		$sug_from_ts = mktime($info_from['hours'], $info_from['minutes'], $info_from['seconds'], $info_from['mon'], ($info_from['mday'] - $this->back_and_forth), $info_from['year']);
		if ($sug_from_ts < $lim_past_ts) {
			$sug_from_ts = $lim_past_ts;
			// since we are close to the requested check-in, double up the "back and forth" for the max date
			$this->setBackForthDays($this->getBackForthDays() * 2);
		}
		$sug_to_ts = mktime($info_to['hours'], $info_to['minutes'], $info_to['seconds'], $info_to['mon'], ($info_to['mday'] + $this->back_and_forth), $info_to['year']);
		$sug_to_ts = $sug_to_ts < $sug_from_ts ? $sug_from_ts : $sug_to_ts;

		// get days timestamps for suggestions
		$groupdays = [];
		$sug_start_info = getdate($sug_from_ts);
		$sug_from_midnight = mktime(0, 0, 0, $sug_start_info['mon'], $sug_start_info['mday'], $sug_start_info['year']);
		$sug_start_info = getdate($sug_from_midnight);
		while ($sug_start_info[0] <= $sug_to_ts) {
			array_push($groupdays, $sug_start_info[0]);
			$sug_start_info = getdate(mktime(0, 0, 0, $sug_start_info['mon'], ($sug_start_info['mday'] + 1), $sug_start_info['year']));
		}

		// build suggestions array of dates with some availability for the given rooms
		$suggestions = [];
		$busy_records = VikBooking::loadBusyRecords($room_ids, $sug_from_ts, strtotime('+1 day', $sug_to_ts));
		foreach ($room_ids as $rid) {
			if (!isset($all_rooms[$rid])) {
				continue;
			}
			$room = $all_rooms[$rid];
			foreach ($groupdays as $gday) {
				$day_key = date('Y-m-d', $gday);
				$bfound = 0;
				if (!isset($busy_records[$rid])) {
					$busy_records[$rid] = [];
				}
				foreach ($busy_records[$rid] as $bu) {
					$busy_info_in = getdate($bu['checkin']);
					$busy_info_out = getdate($bu['checkout']);
					$busy_in_ts = mktime(0, 0, 0, $busy_info_in['mon'], $busy_info_in['mday'], $busy_info_in['year']);
					$busy_out_ts = mktime(0, 0, 0, $busy_info_out['mon'], $busy_info_out['mday'], $busy_info_out['year']);
					if ($gday >= $busy_in_ts && $gday == $busy_out_ts && !$this->inonout_allowed && $room['units'] < 2) {
						// check-ins on check-outs not allowed
						$bfound++;
						if ($bfound >= $room['units']) {
							break;
						}
					}
					if ($gday >= $busy_in_ts && $gday < $busy_out_ts) {
						$bfound++;
						if ($bfound >= $room['units']) {
							break;
						}
					}
				}
				if ($bfound < $room['units']) {
					if (!isset($suggestions[$day_key])) {
						$suggestions[$day_key] = [];
					}
					$room_day = $room;
					$room_day['units_left'] = $room['units'] - $bfound;
					$suggestions[$day_key] = $suggestions[$day_key] + [$rid => $room_day];
				}
			}
		}

		if (!$suggestions) {
			// no available nights found for the prior and next "back and forth" days for the given rooms
			return [];
		}

		// build the solutions array with keys=checkin, values=all rooms suited for the requested number of nights
		$solutions = [];
		// get all rooms available for the number of nights requested in the suggestions array of dates
		foreach ($suggestions as $kday => $rooms) {
			$day_ts_info = getdate(strtotime($kday));
			foreach ($rooms as $rid => $room) {
				$suitable = true;
				$room_days_av_left = [$kday => $room['units_left']];
				for ($i = 1; $i < $tot_nights; $i++) {
					$next_night = mktime(0, 0, 0, $day_ts_info['mon'], ($day_ts_info['mday'] + $i), $day_ts_info['year']);
					$next_night_dt = date('Y-m-d', $next_night);
					if (!isset($suggestions[$next_night_dt]) || !isset($suggestions[$next_night_dt][$rid])) {
						$suitable = false;
						break;
					}
					$room_days_av_left[$next_night_dt] = $suggestions[$next_night_dt][$rid]['units_left'];
				}
				if ($suitable === true) {
					if (!isset($solutions[$kday])) {
						$solutions[$kday] = [];
					}
					unset($room['units_left']);
					$room['days_av_left'] = $room_days_av_left;
					$solutions[$kday] = $solutions[$kday] + [$rid => $room];
				}
			}
		}

		if (!count($solutions)) {
			// the requested length of stay could not be satisfied for any available night
			return [];
		}

		// sort the solutions by the closest checkin date to the one requested
		$sortmap = [];
		$orig_checkin_ymd = date('Y-m-d', $orig_checkin_ts);
		foreach ($solutions as $kday => $solution) {
			$kdayts = strtotime($kday);
			$sortmap[$kdayts] = $kdayts > $orig_checkin_ts ? ($kdayts - $orig_checkin_ts) : ($orig_checkin_ts - $kdayts);
			if ($orig_checkin_ymd == $kday) {
				// the original check-in day is available, so we want it to be first, regardless of the check-in time
				$sortmap[$kdayts] = 1;
			}
		}
		asort($sortmap);
		$sorted = [];
		foreach ($sortmap as $kdayts => $v) {
			$kday = date('Y-m-d', $kdayts);
			$sorted[$kday] = $solutions[$kday];
		}
		$solutions = $sorted;
		unset($sorted);

		/**
		 * Load rooms involved in the final alternative solutions in order
		 * to validate global/room-level restrictions and closing dates.
		 * 
		 * @since 	1.15.4 (J) - 1.5.10 (WP)
		 */
		$rooms_involved = [];
		foreach ($solutions as $arrive_ymd => $roomsol) {
			foreach (array_keys($roomsol) as $rid) {
				if (!in_array($rid, $rooms_involved)) {
					$rooms_involved[] = $rid;
				}
			}
		}

		// load restrictions for all rooms involved
		$all_restrictions  = VikBooking::loadRestrictions(true, $rooms_involved);
		$glob_restrictions = VikBooking::globalRestrictions($all_restrictions);
		$room_level_restr  = [];

		foreach ($solutions as $arrive_ymd => $roomsol) {
			// build suggested stay dates
			$sug_in  = getdate(strtotime($arrive_ymd));
			$sug_out = getdate(mktime(0, 0, 0, $sug_in['mon'], ($sug_in['mday'] + $tot_nights), $sug_in['year']));
			// validate global restrictions
			if (VikBooking::validateRoomRestriction($glob_restrictions, $sug_in, $sug_out, $tot_nights)) {
				// global restrictions apply over this stay
				unset($solutions[$arrive_ymd]);
				continue;
			}
			// validate closing dates
			if (VikBooking::validateClosingDates($sug_in[0], $sug_out[0])) {
				// global closing dates apply over this stay
				unset($solutions[$arrive_ymd]);
				continue;
			}
			// validate restrictions at room level
			foreach ($roomsol as $rid => $rdata) {
				if (!isset($room_level_restr[$rid])) {
					// load restrictions at room level
					$room_level_restr[$rid] = VikBooking::roomRestrictions($rid, $all_restrictions);
				}
				if (VikBooking::validateRoomRestriction($room_level_restr[$rid], $sug_in, $sug_out, $tot_nights)) {
					// room-level restrictions apply over this stay
					unset($solutions[$arrive_ymd][$rid]);
					if (!count($solutions[$arrive_ymd])) {
						// unset the entire suggested date
						unset($solutions[$arrive_ymd]);
						break;
					}
					continue;
				}
			}
		}

		if (!count($solutions)) {
			// the calculated suggestions do not meet the restrictions or the closing dates
			return [];
		}

		// return the solution alternative dates for all rooms available
		return $solutions;
	}

	/**
	 * Sorts an associative array of room-solutions by the bigger rooms.
	 * 
	 * @param 	array 	$solutions 	the date solutions obtained for some rooms.
	 * 
	 * @return 	array 				the same array sorted by bigger rooms on top.
	 */
	protected function sortBiggerRoomSolutions($solutions)
	{
		if (!is_array($solutions) || !$solutions) {
			return $solutions;
		}

		// sort rooms-solutions by max-adults, 'max-guests', 'max-children', in a descending order
		foreach ($solutions as $kday => $solution) {
			// with this sorting, we will have the bigger rooms on top to quickly fit the party requested
			uasort($solutions[$kday], function($a, $b) {
				if ($a['toadult'] == $b['toadult']) {
					if ($a['totpeople'] == $b['totpeople']) {
						return $a['tochild'] > $b['tochild'] ? -1 : 1;
					}
					return $a['totpeople'] > $b['totpeople'] ? -1 : 1;
				}
				return $a['toadult'] > $b['toadult'] ? -1 : 1;
			});
		}

		return $solutions;
	}

	/**
	 * Given a list of available dates and rooms (solutions), attempts
	 * to match a party of rooms that fits the party requested.
	 * 
	 * @param 	array 	$solutions 	the list of available dates and related rooms.
	 * 
	 * @return 	array 				list of alternative party solutions, if any.
	 */
	protected function matchSolutionsParty($solutions)
	{
		if (!is_array($solutions) || !$solutions) {
			return [];
		}

		// build the list of alternative parties
		$alternative_parties = [];

		// build list of party guests
		$party_guests = [
			'adults'   => 0,
			'children' => 0,
		];
		foreach ($this->room_parties as $rparty) {
			$party_guests['adults']   += $rparty['adults'];
			$party_guests['children'] += $rparty['children'];
		}

		// check if the rooms of each solution can fit the number of guests requested, unset the solution otherwise
		foreach ($solutions as $kday => $solution) {
			$solution_guests = [
				'adults'   => 0,
				'children' => 0,
			];
			foreach ($solution as $rid => $roomsol) {
				// count minimum units left for this room
				$room_min_uleft = min($roomsol['days_av_left']);
				// check if this solution of rooms can allocate all the guests requested
				if ($roomsol['totpeople'] < ($roomsol['toadult'] + $roomsol['tochild']) && !$party_guests['children']) {
					// in case of no children requested, we ignore them to avoid adjusting the room capacity
					$roomsol['tochild'] = 0;
				}
				if ($roomsol['totpeople'] < ($roomsol['toadult'] + $roomsol['tochild'])) {
					/**
					 * The sum of the max_adults and max_children exceeds the max_guests: lower the adults 
					 * we can take first (if party children > 0), then the children, until sum=max_guests
					 */
					while (($roomsol['toadult'] > 0 || $roomsol['tochild'] > 0)) {
						if (!$party_guests['children'] && $roomsol['totpeople'] == $roomsol['toadult']) {
							/**
							 * When no children requested in the party, we cannot under-utilize rooms.
							 * Break the loop without lowering the 'toadult'.
							 */
							$roomsol['tochild'] = 0;
							break;
						}
						if ($party_guests['children'] && $solution_guests['children'] >= $party_guests['children']) {
							/**
							 * If all the children requested were allocated in other solutions,
							 * we should not under-utilize rooms by reducing the number of adults.
							 */
							break;
						}
						if ($roomsol['toadult'] > 0 && $party_guests['children'] > 0 && !($roomsol['tochild'] > $party_guests['children'])) {
							/**
							 * We lower first the adults that we put in this room, only if there are children in the party 
							 * and if the children in the party are more than the 'max_children' of this room.
							 */
							$roomsol['toadult']--;
							if ($roomsol['totpeople'] >= ($roomsol['toadult'] + $roomsol['tochild'])) {
								break;
							}
						}
						if ($roomsol['tochild'] > 0) {
							// if the max_guests is still greater than the sum of adults+children we take, take out one child
							$roomsol['tochild']--;
							if ($roomsol['totpeople'] >= ($roomsol['toadult'] + $roomsol['tochild'])) {
								break;
							}
						}
						if ($roomsol['toadult'] > 0) {
							// if even at this point we still have a high sum of guests to take compared to the max_guests, take out again one adult
							$roomsol['toadult']--;
							if ($roomsol['totpeople'] >= ($roomsol['toadult'] + $roomsol['tochild'])) {
								break;
							}
						}
					}
				}
				$solution_guests['adults']   += $roomsol['toadult'] * $room_min_uleft;
				$solution_guests['children'] += $roomsol['tochild'] * $room_min_uleft;
				// update 'max_adults' and 'max_children' for this solution (for later guests allocation)
				$solution[$rid]['toadult'] = $roomsol['toadult'];
				$solution[$rid]['tochild'] = $roomsol['tochild'];
			}

			$solutions[$kday] = $solution;
			if ($solution_guests['adults'] < $party_guests['adults'] || $solution_guests['children'] < $party_guests['children']) {
				// the guests we can allocate with the solution of this day are not enough: unset the solution
				unset($solutions[$kday]);
				continue;
			}

			// if we get to this point we can suggest a booking solution for the party requested, but in different rooms
			if (!isset($alternative_parties[$kday])) {
				$alternative_parties[$kday] = [];
			}

			// re-loop over the rooms in this solution to build the booking solution for this day
			$guests_allocated = [
				'adults' => 0,
				'children' => 0
			];

			/**
			 * The rooms available for an alternative booking solutions have been sorted by capacity
			 * in a descending order to quickly fit the guest party requested. However, if a smaller
			 * and cheaper room was capable of fitting all guests, we should opt for this solution.
			 * 
			 * @since 	1.15.4 (J) - 1.5.9 (WP)
			 */
			$smaller_fit_found = false;
			$smaller_solutions = array_reverse($solution, true);
			foreach ($smaller_solutions as $rid => $roomsol) {
				if ($party_guests['adults'] > 0 && $party_guests['adults'] > $roomsol['toadult']) {
					// too many adults requested for this small room
					continue;
				}
				if ($party_guests['children'] > 0 && $party_guests['children'] > $roomsol['tochild']) {
					// too many children requested for this small room
					continue;
				}
				if (($party_guests['adults'] + $party_guests['children']) > $roomsol['totpeople']) {
					// too many guests requested for this small room
					continue;
				}
				// we've got a fitting room which could be smaller
				$roomsol['guests_allocation'] = [
					'adults'   => $party_guests['adults'],
					'children' => $party_guests['children'],
				];
				array_push($alternative_parties[$kday], $roomsol);
				// turn flag on and break the loop
				$smaller_fit_found = true;
				break;
			}
			if ($smaller_fit_found) {
				// no need to parse the rooms from the largest to the smallest
				continue;
			}

			foreach ($solution as $rid => $roomsol) {
				// count minimum units left for this room
				$room_min_uleft = min($roomsol['days_av_left']);
				// fullfil all the units of this room
				for ($units_taken = 0; $units_taken < $room_min_uleft; $units_taken++) { 
					$current_allocation = [
						'adults' => 0,
						'children' => 0
					];
					if ($guests_allocated['adults'] < $party_guests['adults']) {
						$humans_taken 	= $roomsol['toadult'];
						$missing_humans = $party_guests['adults'] - $guests_allocated['adults'];
						$humans_taken 	= $humans_taken > $missing_humans ? $missing_humans : $humans_taken;

						$current_allocation['adults'] = $humans_taken;
						$guests_allocated['adults']   += $humans_taken;
					}
					if ($guests_allocated['children'] < $party_guests['children']) {
						$humans_taken 	= $roomsol['tochild'];
						$missing_humans = $party_guests['children'] - $guests_allocated['children'];
						$humans_taken 	= $humans_taken > $missing_humans ? $missing_humans : $humans_taken;

						$current_allocation['children'] = $humans_taken;
						$guests_allocated['children'] 	+= $humans_taken;
					}
					$roomsol['guests_allocation'] = $current_allocation;
					array_push($alternative_parties[$kday], $roomsol);
					if ($guests_allocated['adults'] >= $party_guests['adults'] && $guests_allocated['children'] >= $party_guests['children']) {
						// we have allocated all guests, exit the for-loop
						break;
					}
				}
				if ($guests_allocated['adults'] >= $party_guests['adults'] && $guests_allocated['children'] >= $party_guests['children']) {
					//we have allocated all guests with this solution, no need to loop over other rooms available in this day.
					break;
				}
			}
		}

		// return the alternative parties found, if any
		return $alternative_parties;
	}

	/**
	 * Given a list of alternative dates obtained from an inquiry/request information,
	 * composes a valid room-rate array to store the inquiry reservation. By calling this
	 * method, the original stay dates will be overwritten.
	 * 
	 * @param 	array 	$alt_dates 	the list of alternative dates found for the stay.
	 * @param 	object 	$customer 	a stdClass object with the basic customer details.
	 * 
	 * @return 	int 				the ID of the newly created inquiry reservation.
	 */
	public function allocateAltDatesInquiry($alt_dates, $customer)
	{
		if (!is_array($alt_dates) || !$alt_dates) {
			return 0;
		}

		foreach ($alt_dates as $ymd => $rooms) {
			// we expect just one room-type for the party, and we use the first suggestion
			foreach ($rooms as $rid => $alt_stay) {
				if (empty($alt_stay['days_av_left']) || !is_array($alt_stay['days_av_left'])) {
					// invalid structure
					continue;
				}
				// compose the new stay dates
				$sugg_checkin_dt  = null;
				$sugg_checkout_dt = null;
				foreach ($alt_stay['days_av_left'] as $dayk => $uleft) {
					if (empty($sugg_checkin_dt)) {
						// grab the first date
						$sugg_checkin_dt = $dayk;
					}
					// always overwrite until last date
					$sugg_checkout_dt = $dayk;
				}
				// increase check-out date by one day (day after last night of stay)
				$sugg_out_info = getdate(strtotime($sugg_checkout_dt));
				$sugg_checkout_dt = date('Y-m-d', mktime(0, 0, 0, $sugg_out_info['mon'], ($sugg_out_info['mday'] + 1), $sugg_out_info['year']));

				// set the new stay dates
				$this->setStayDates($sugg_checkin_dt, $sugg_checkout_dt);

				// build the room rate plan array without any rate plan information
				$room_rplan = [
					'idroom' => $alt_stay['id'],
				];

				// create the inquiry reservation for the closest alternative dates
				return $this->createInquiryReservation($room_rplan, $customer);
			}
		}

		return 0;
	}

	/**
	 * Given a list of alternative parties obtained from an inquiry/request information,
	 * composes valid room-rate arrays to store the inquiry reservation. By calling this
	 * method, the original stay dates and room party will be overwritten.
	 * 
	 * @param 	array 	$alt_parties 	the list of alternative parties found for the stay.
	 * @param 	object 	$customer 		a stdClass object with the basic customer details.
	 * 
	 * @return 	int 					the ID of the newly created inquiry reservation.
	 */
	public function allocateAltPartyInquiry($alt_parties, $customer)
	{
		if (!is_array($alt_parties) || !$alt_parties) {
			return 0;
		}

		// build list of rooms to assign to the inquiry reservation
		$room_rates = [];

		// start party counter
		$party_counter = 0;

		foreach ($alt_parties as $ymd => $alt_rooms) {
			// we expect to have more than one room-type for the large party suggestion
			foreach ($alt_rooms as $alt_room) {
				if (empty($alt_room['guests_allocation']) || !is_array($alt_room['guests_allocation'])) {
					// invalid structure
					continue;
				}
				if (empty($alt_room['days_av_left']) || !is_array($alt_room['days_av_left'])) {
					// invalid structure
					continue;
				}
				// compose the new stay dates
				$sugg_checkin_dt  = null;
				$sugg_checkout_dt = null;
				foreach ($alt_room['days_av_left'] as $dayk => $uleft) {
					if (empty($sugg_checkin_dt)) {
						// grab the first date
						$sugg_checkin_dt = $dayk;
					}
					// always overwrite until last date
					$sugg_checkout_dt = $dayk;
				}
				// increase check-out date by one day (day after last night of stay)
				$sugg_out_info = getdate(strtotime($sugg_checkout_dt));
				$sugg_checkout_dt = date('Y-m-d', mktime(0, 0, 0, $sugg_out_info['mon'], ($sugg_out_info['mday'] + 1), $sugg_out_info['year']));

				// set the new stay dates
				$this->setStayDates($sugg_checkin_dt, $sugg_checkout_dt);

				// set the current guests party (the first will replace the previous party, others will be pushed)
				$this->setRoomParty($alt_room['guests_allocation']['adults'], $alt_room['guests_allocation']['children'], ($party_counter === 0));

				// increase party counter
				$party_counter++;

				// push current room with no rate plan information
				array_push($room_rates, [
					'idroom' => $alt_room['id'],
				]);
			}

			if (count($room_rates)) {
				// we use the closest dates in the first suggestion party array
				break;
			}
		}

		// count total rooms in the party
		$tot_room_party = count($room_rates);

		if (!$tot_room_party) {
			// something went wrong
			return 0;
		}

		// grab the main/first room reservation
		$room_rplan = $room_rates[0];

		// build extra rooms
		$extra_rooms = [];
		if ($tot_room_party > 1) {
			// grab the remaining rooms
			unset($room_rates[0]);
			$extra_rooms = array_values($room_rates);
		}

		// create the inquiry reservation for the closest alternative dates and rooms party
		return $this->createInquiryReservation($room_rplan, $customer, $extra_rooms);
	}

	/**
	 * Creates a new pending reservation from the inquiry/request information.
	 * Requires a valid room-rate array to be available, or in case suggestions should
	 * be applied, the room-rate array should be adjusted to comply with this method.
	 * 
	 * @param 	array 	$room_rplan 	a room-rate array to allocate the booking.
	 * @param 	object 	$customer 		a stdClass object with the basic customer details.
	 * @param 	array 	$extra_rooms 	optional list of additional room-rate arrays to store
	 * 									in case of alternative parties suggested.
	 * 
	 * @return 	int 					the ID of the newly created inquiry reservation.
	 */
	public function createInquiryReservation($room_rplan, $customer, $extra_rooms = array())
	{
		if (!is_array($room_rplan) || empty($room_rplan['idroom'])) {
			return 0;
		}

		if (empty($this->stay_ts) || empty($this->room_parties)) {
			// no stay dates or room party set
			return 0;
		}

		$dbo = JFactory::getDbo();

		// build reservation object
		$res_obj = new stdClass;
		$res_obj->custdata = $customer->custdata;
		$res_obj->ts = time();
		$res_obj->status = 'standby';
		$res_obj->days = $this->countNightsOfStay();
		$res_obj->checkin = $this->stay_ts[0];
		$res_obj->checkout = $this->stay_ts[1];
		$res_obj->custmail = $customer->email;
		$res_obj->sid = VikBooking::getSecretLink();
		$res_obj->idpayment = $this->getDefaultPaymentId();
		$res_obj->roomsnum = count($this->room_parties);
		if (!empty($room_rplan['cost'])) {
			$res_obj->total = (float)$room_rplan['cost'];
		}
		$res_obj->adminnotes = $customer->adminnotes;
		$res_obj->lang = $customer->lang;
		$res_obj->country = $customer->country;
		if (!empty($room_rplan['taxes'])) {
			$res_obj->tot_taxes = (float)$room_rplan['taxes'];
		}
		if (!empty($room_rplan['city_taxes'])) {
			$res_obj->tot_city_taxes = (float)$room_rplan['city_taxes'];
		}
		$res_obj->phone = $customer->phone;
		$res_obj->type = 'inquiry';

		// store record
		if (!$dbo->insertObject('#__vikbooking_orders', $res_obj, 'id')) {
			// could not store the booking record
			return 0;
		}

		// get the ID of the newly created reservation
		$res_id = $res_obj->id;

		// check if mandatory options should be assigned (not in case of suggestions)
		$room_options = null;
		if (!empty($room_rplan['idprice']) && isset($res_obj->tot_city_taxes)) {
			$mand_taxes = VikBooking::getMandatoryTaxesFees([$room_rplan['idroom']], $this->getPartyGuests('adults', 0), $res_obj->days);
			if (is_array($mand_taxes) && !empty($mand_taxes['options'])) {
				$room_options = implode(';', $mand_taxes['options']);
			}
		}

		// build room-reservation object
		$room_res_obj = new stdClass;
		$room_res_obj->idorder = $res_id;
		$room_res_obj->idroom = $room_rplan['idroom'];
		$room_res_obj->adults = $this->getPartyGuests('adults', 0);
		$room_res_obj->children = $this->getPartyGuests('children', 0);
		if (!empty($room_rplan['idprice'])) {
			$room_res_obj->idtar = $this->getTariffId($room_rplan['idroom'], $room_rplan['idprice'], $res_obj->days);
		}
		$room_res_obj->optionals = $room_options;
		$room_res_obj->t_first_name = $customer->name;
		$room_res_obj->t_last_name = $customer->lname;

		// store record
		if (!$dbo->insertObject('#__vikbooking_ordersrooms', $room_res_obj, 'id')) {
			// could not store the room-reservation record
			return $res_id;
		}

		// in case of suggestions for alternative room parties, parse the extra rooms
		$party_index = 1;
		foreach ($extra_rooms as $extra_room) {
			if (!is_array($extra_room) || empty($extra_room['idroom'])) {
				continue;
			}
			// build additional room-reservation object
			$room_res_obj = new stdClass;
			$room_res_obj->idorder = $res_id;
			$room_res_obj->idroom = $extra_room['idroom'];
			$room_res_obj->adults = $this->getPartyGuests('adults', $party_index);
			$room_res_obj->children = $this->getPartyGuests('children', $party_index);

			// store record
			$dbo->insertObject('#__vikbooking_ordersrooms', $room_res_obj, 'id');

			// increase room party index
			$party_index++;
		}

		// return the newly created reservation ID
		return $res_id;
	}

	/**
	 * Attempts to get the tariff ID for the given room, rate plan and nights.
	 * 
	 * @param 	int 	$room_id 	the ID of the VBO room.
	 * @param 	int 	$rplan_id 	the rate plan ID in VBO.
	 * @param 	int 	$nights 	the number of nights of stay.
	 * 
	 * @return 	int|null 			the tariff ID or null.
	 */
	public function getTariffId($room_id, $rplan_id, $nights)
	{
		if (empty($room_id) || empty($rplan_id) || $nights < 1) {
			return null;
		}

		$dbo = JFactory::getDbo();

		$q = "SELECT `id` FROM `#__vikbooking_dispcost` WHERE `idroom`={$room_id} AND `days`={$nights} AND `idprice`={$rplan_id}";
		$dbo->setQuery($q, 0, 1);
		$res = $dbo->loadResult();

		if ($res) {
			return (int)$res;
		}

		return null;
	}

	/**
	 * Grabs the details of a given booking id.
	 * 
	 * @param 	int 	$bid 	the booking ID to look for.
	 * 
	 * @return 	array 	empty array or booking record details.
	 */
	public function getBookingDetails($bid)
	{
		$bid = (int)$bid;

		$dbo = JFactory::getDbo();

		$q = "SELECT `o`.*, `co`.`idcustomer`, CONCAT_WS(' ', `c`.`first_name`, `c`.`last_name`) AS `customer_fullname`, `c`.`country` AS `customer_country`, `c`.`pic` 
			FROM `#__vikbooking_orders` AS `o` 
			LEFT JOIN `#__vikbooking_customers_orders` AS `co` ON `co`.`idorder`=`o`.`id` 
			LEFT JOIN `#__vikbooking_customers` AS `c` ON `c`.`id`=`co`.`idcustomer` 
			WHERE `o`.`id`={$bid}";
		$dbo->setQuery($q, 0, 1);
		$row = $dbo->loadAssoc();
		if ($row) {
			return $row;
		}

		return [];
	}

	/**
	 * Validates if the room allows the given number of nights of stay by checking if a
	 * tariff is defined for the given length of stay. Useful for particular rate tables.
	 * 
	 * @param 	int 	$room_id 	the ID of the VBO room.
	 * @param 	int 	$nights 	the number of nights of stay.
	 * 
	 * @return 	bool 				true if a tariff is found between min and max nights.
	 * 
	 * @since 	1.16.3 (J) - 1.6.3 (WP)
	 */
	public function roomNightsAllowed($room_id, $nights)
	{
		if (!isset($this->min_max_los_tariffs_map[$room_id])) {
			$dbo = JFactory::getDbo();

			$q = $dbo->getQuery(true)
				->select('MIN(' . $dbo->qn('t.days') . ') AS ' . $dbo->qn('min_nights'))
				->select('MAX(' . $dbo->qn('t.days') . ') AS ' . $dbo->qn('max_nights'))
				->from($dbo->qn('#__vikbooking_dispcost', 't'))
				->where($dbo->qn('t.idroom') . ' = ' . (int)$room_id);

			$dbo->setQuery($q, 0, 1);
			$tariffs = $dbo->loadObject();

			if (!$tariffs || !$tariffs->min_nights || !$tariffs->max_nights) {
				return false;
			}

			// set values
			$this->min_max_los_tariffs_map[$room_id] = [$tariffs->min_nights, $tariffs->max_nights];
		}

		// check if the number of nights of stay is within the range of tariffs los map
		return ($nights >= min($this->min_max_los_tariffs_map[$room_id]) && $nights <= max($this->min_max_los_tariffs_map[$room_id]));
	}

	/**
	 * Sets the stay dates, check-in and check-out date timestamps.
	 * 
	 * @param 	string 	$from 	check-in date string in Y-m-d or VBO format.
	 * @param 	string 	$to 	check-out date string in Y-m-d or VBO format.
	 * 
	 * @return 	self
	 */
	public function setStayDates($from, $to)
	{
		if (empty($from) || empty($to)) {
			return $this;
		}

		$checkinh = 0;
		$checkinm = 0;
		$checkouth = 0;
		$checkoutm = 0;
		$timeopst = VikBooking::getTimeOpenStore();
		if (is_array($timeopst)) {
			if ($timeopst[0] < $timeopst[1]) {
				// check-in not allowed on a day where there is already a check out (no arrivals/depatures on the same day)
				$this->inonout_allowed = false;
			}
			$opent = VikBooking::getHoursMinutes($timeopst[0]);
			$closet = VikBooking::getHoursMinutes($timeopst[1]);
			$checkinh = $opent[0];
			$checkinm = $opent[1];
			$checkouth = $closet[0];
			$checkoutm = $closet[1];
		}
		$from_ts = VikBooking::getDateTimestamp($from, $checkinh, $checkinm);
		$to_ts 	 = VikBooking::getDateTimestamp($to, $checkouth, $checkoutm);

		// set stay dates and timestamps
		$this->stay_dates = [date('Y-m-d', $from_ts), date('Y-m-d', $to_ts)];
		$this->stay_ts 	  = [$from_ts, $to_ts];

		return $this;
	}

	/**
	 * Returns the current stay dates or timestamps.
	 * 
	 * @param 	bool 	$ts 	whether to get the date timestamps.
	 * 
	 * @return 	array 			the current stay dates or timestamps.
	 */
	public function getStayDates($ts = false)
	{
		return $ts ? $this->stay_ts : $this->stay_dates;
	}

	/**
	 * Sets a room party with adults and children, by optionally replacing the others.
	 * 
	 * @param 	int 	$adults 	the number of adults for this room party.
	 * @param 	int 	$children 	the number of children for this room party.
	 * @param 	bool 	$replace 	if true, any previously set room party will be replaced.
	 * 
	 * @return 	self
	 */
	public function setRoomParty($adults, $children = 0, $replace = false)
	{
		$room_party = [
			'adults'   => $adults,
			'children' => $children,
		];

		if ($replace) {
			$this->room_parties = [$room_party];
		} else {
			array_push($this->room_parties, $room_party);
		}

		return $this;
	}

	/**
	 * Returns the current room parties array.
	 * 
	 * @return 	array 	the current room parties.
	 */
	public function getRoomParties()
	{
		return $this->room_parties;
	}

	/**
	 * Sets and returns the flag to ignore the restrictions.
	 * 
	 * @param 	bool 	$set 	the boolean status to set.
	 * 
	 * @return 	bool 			the current ignore status.
	 */
	public function ignoreRestrictions($set = null)
	{
		if (is_bool($set)) {
			$this->ignore_restrictions = $set;
		}

		return $this->ignore_restrictions;
	}

	/**
	 * Sets and returns the flag to ignore the rooms availability.
	 * 
	 * @param 	bool 	$set 	the boolean status to set.
	 * 
	 * @return 	bool 			the current ignore status.
	 * 
	 * @since 	1.16.10 (J) - 1.6.10 (WP)
	 */
	public function ignoreAvailability($set = null)
	{
		if (is_bool($set)) {
			$this->ignore_availability = $set;
		}

		return $this->ignore_availability;
	}

	/**
	 * Takes the first payment ID "string", if available.
	 * 
	 * @return 	string|null
	 */
	protected function getDefaultPaymentId()
	{
		$dbo = JFactory::getDbo();

		$q = "SELECT `id`, `name` FROM `#__vikbooking_gpayments` WHERE `published`=1 ORDER BY `ordering` ASC";
		$dbo->setQuery($q, 0, 1);
		$data = $dbo->loadAssoc();

		if ($data) {
			return $data['id'] . '=' . $data['name'];
		}

		return null;
	}

	/**
	 * Returns the current nights/transfers ratio for split stays.
	 * 
	 * @return 	int 	the nights transfers ratio for the percent calculation.
	 * 
	 * @since 	1.16.0 (J) - 1.6.0 (WP)
	 */
	public function getNightsTransfersRatio()
	{
		return $this->nights_transfers_ratio;
	}

	/**
	 * Returns the default nights/transfers ratio for split stays.
	 * 
	 * @return 	int 	the nights transfers ratio defined in the configuration.
	 * 
	 * @since 	1.16.0 (J) - 1.6.0 (WP)
	 */
	public function getDefaultNightsTransfersRatio()
	{
		static $transferRatio = null;

		if ($transferRatio === null) {
			$config = VBOFactory::getConfig();
			$transferRatio = (int) $config->get('split_stay_ratio', 50);
		}

		return $transferRatio;
	}

	/**
	 * Sets the nights/transfers ratio for split stays. Use it to start applying limits.
	 * 
	 * @param 	?int 	$ratio 	Optional ratio integer value, or default value will apply.
	 * 
	 * @return 	self
	 * 
	 * @since 	1.16.0 (J) - 1.6.0 (WP)
	 */
	public function setNightsTransfersRatio(?int $ratio = null)
	{
		if (is_int($ratio)) {
			$this->nights_transfers_ratio = $ratio;
		} else {
			$this->nights_transfers_ratio = $this->getDefaultNightsTransfersRatio();
		}

		return $this;
	}

	/**
	 * Tells whether we need to behave for the front-end booking process.
	 * 
	 * @return 	bool 	true if we are in the front-end booking process or false.
	 * 
	 * @since 	1.16.0 (J) - 1.6.0 (WP)
	 */
	public function isFrontBooking()
	{
		return (bool) $this->is_front_booking;
	}

	/**
	 * Toggles the flag to behave for the front-end booking process.
	 * 
	 * @return 	self
	 * 
	 * @since 	1.16.0 (J) - 1.6.0 (WP)
	 */
	public function setIsFrontBooking($is_front = true)
	{
		$this->is_front_booking = (bool)$is_front;

		return $this;
	}

	/**
	 * This helper method aims to collect the stay dates of each room in
	 * a booking with split stay. Records will be loaded by ID ascending,
	 * so in the same exact way as the rooms get stored. For this reason,
	 * it is then possible to match the stay dates of a room by array-key,
	 * even if bookings with split stay should always have different room IDs.
	 * 
	 * @param 	int 	$bid 	the website reservation ID.
	 * 
	 * @return 	array 			the list of busy records with stay dates information.
	 * 
	 * @since 	1.16.0 (J) - 1.6.0 (WP)
	 */
	public function loadSplitStayBusyRecords($bid)
	{
		$dbo = JFactory::getDbo();

		$bid = (int)$bid;

		/**
		 * It is fundamental to keep the ID column as the busy record ID
		 * to allow the room switching in case of booking modification.
		 */
		$q = "SELECT `ob`.`idorder`, `b`.`id`, `b`.`idroom`, `b`.`checkin`, `b`.`checkout`, `b`.`sharedcal` 
			FROM `#__vikbooking_ordersbusy` AS `ob` 
			LEFT JOIN `#__vikbooking_busy` AS `b` ON `ob`.`idbusy`=`b`.`id` 
			WHERE `ob`.`idorder`={$bid} 
			ORDER BY `b`.`sharedcal` ASC, `b`.`id` ASC;";
		$dbo->setQuery($q);
		$records = $dbo->loadAssocList();

		if (!$records) {
			// the booking may no longer exist, or maybe it was cancelled
			return [];
		}

		return $records;
	}

	/**
	 * Returns a list of the tax rate records.
	 * 
	 * @return 	array 	the list of tax rates.
	 * 
	 * @since 	1.16.0 (J) - 1.6.0 (WP)
	 */
	public function getTaxRates()
	{
		$dbo = JFactory::getDbo();
		$dbo->setQuery("SELECT * FROM `#__vikbooking_iva`;");

		return $dbo->loadAssocList();
	}

	/**
	 * Sets the IDs of the rooms to filter or use.
	 * 
	 * @param 	mixed 	$room_ids 	the list of room IDs to filter or use, or int room ID.
	 * 
	 * @return 	self
	 */
	public function setRoomIds($room_ids = [])
	{
		if (is_scalar($room_ids)) {
			// single room ID integer
			$room_ids = [$room_ids];
		}

		$this->room_ids = $room_ids;

		return $this;
	}

	/**
	 * Returns the current room ids to filter or use.
	 * 
	 * @return 	array 	the current room ids.
	 */
	public function getRoomIds()
	{
		return $this->room_ids;
	}

	/**
	 * In case of no availability, overrides the default number of days to check
	 * prior and after the originally requested check-in and check-out dates.
	 * 
	 * @param 	int 	$days 	the total number of days to use back and forth.
	 * 
	 * @return 	self
	 */
	public function setBackForthDays($days)
	{
		if (is_int($days) && $days >= 0) {
			$this->back_and_forth = $days;
		}

		return $this;
	}

	/**
	 * Returns the current number of back and forth days.
	 * 
	 * @return 	int 	the current number of days.
	 */
	public function getBackForthDays()
	{
		return $this->back_and_forth;
	}

	/**
	 * Sets warning messages by concatenating the existing ones.
	 *
	 * @param 	string 		$str
	 *
	 * @return 	self
	 */
	protected function setWarning($str)
	{
		$this->warning .= $str . "\n";

		return $this;
	}

	/**
	 * Gets the current warning string.
	 *
	 * @return 	string
	 */
	public function getWarning()
	{
		return rtrim($this->warning, "\n");
	}

	/**
	 * Sets errors by concatenating the existing ones.
	 *
	 * @param 	string 		$str
	 *
	 * @return 	self
	 */
	protected function setError($str)
	{
		$this->error .= $str . "\n";

		return $this;
	}

	/**
	 * Gets the current error string.
	 *
	 * @return 	string
	 */
	public function getError()
	{
		return rtrim($this->error, "\n");
	}

	/**
	 * Gets the current error code.
	 *
	 * @return 	int
	 */
	public function getErrorCode()
	{
		return $this->errorCode;
	}
}
