<?php
/** 
 * @package     VikBooking
 * @subpackage  core
 * @author      E4J s.r.l.
 * @copyright   Copyright (C) 2021 E4J s.r.l. All Rights Reserved.
 * @license     http://www.gnu.org/licenses/gpl-2.0.html GNU/GPL
 * @link        https://vikwp.com
 */

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

/**
 * VikBooking bookings controller.
 *
 * @since 	1.16.0 (J) - 1.6.0 (WP)
 */
class VikBookingControllerBookings extends JControllerAdmin
{
	/**
	 * AJAX endpoint to search for an extra service name.
	 * 
	 * @return 	void
	 */
	public function search_service()
	{
		if (!JSession::checkToken()) {
			VBOHttpDocument::getInstance()->close(403, JText::translate('JINVALID_TOKEN'));
		}

		$dbo = JFactory::getDbo();

		$service_name = VikRequest::getString('service_name', '', 'request');
		$max_results  = VikRequest::getInt('max_results', 10, 'request');

		$sql_term = $dbo->quote("%{$service_name}%");
		$sql_clause = !empty($service_name) ? 'LIKE ' . $sql_term : 'IS NOT NULL';

		$q = "SELECT `or`.`idorder`, `or`.`idroom`, `or`.`adults`, `or`.`children`, `or`.`extracosts`, `o`.`days` AS `nights`, `o`.`ts`, `r`.`name` AS `room_name`
			FROM `#__vikbooking_ordersrooms` AS `or`
			LEFT JOIN `#__vikbooking_orders` AS `o` ON `or`.`idorder`=`o`.`id`
			LEFT JOIN `#__vikbooking_rooms` AS `r` ON `or`.`idroom`=`r`.`id`
			WHERE `or`.`extracosts` {$sql_clause}
			ORDER BY `or`.`idorder` DESC";
		$dbo->setQuery($q, 0, $max_results);
		$dbo->execute();
		if (!$dbo->getNumRows()) {
			// no results
			VBOHttpDocument::getInstance()->json([]);
		}

		$results = $dbo->loadAssocList();

		$matching_services = [];

		foreach ($results as $k => $result) {
			$extra_services = json_decode($result['extracosts'], true);
			if (empty($extra_services)) {
				continue;
			}
			foreach ($extra_services as $extra_service) {
				if (empty($service_name) || stristr($extra_service['name'], $service_name) !== false || stristr($service_name, $extra_service['name']) !== false) {
					// matching service found
					$matching_service = $result;
					unset($matching_service['extracosts']);
					$matching_service['service'] = $extra_service;
					$matching_service['service']['format_cost'] = VikBooking::getCurrencySymb() . ' ' . VikBooking::numberFormat($extra_service['cost']);
					$matching_service['format_dt'] = VikBooking::formatDateTs($result['ts']);
					// push result
					$matching_services[] = $matching_service;
					if (count($matching_services) >= $max_results) {
						break 2;
					}
				}
			}
		}

		// output the JSON encoded list of matching results found
		VBOHttpDocument::getInstance()->json($matching_services);
	}

	/**
	 * AJAX endpoint to count the number of uses for various coupon codes.
	 * 
	 * @return 	void
	 */
	public function coupons_use_count()
	{
		if (!JSession::checkToken()) {
			VBOHttpDocument::getInstance()->close(403, JText::translate('JINVALID_TOKEN'));
		}

		$dbo = JFactory::getDbo();

		$coupon_codes = VikRequest::getVar('coupon_codes', array());

		$use_counts = [];

		foreach ($coupon_codes as $coupon_code) {
			$q = "SELECT COUNT(*) FROM `#__vikbooking_orders` WHERE `coupon` LIKE " . $dbo->quote("%;{$coupon_code}");
			$dbo->setQuery($q);
			$dbo->execute();
			$use_counts[] = [
				'code'  => $coupon_code,
				'count' => (int)$dbo->loadResult(),
			];
		}

		// output the JSON encoded list of coupon use counts
		VBOHttpDocument::getInstance()->json($use_counts);
	}

	/**
	 * AJAX endpoint to dynamically search for customers. Compatible with select2.
	 * 
	 * @return 	void
	 */
	public function customers_search()
	{
		if (!JSession::checkToken()) {
			VBOHttpDocument::getInstance()->close(403, JText::translate('JINVALID_TOKEN'));
		}

		$dbo = JFactory::getDbo();

		$term = VikRequest::getString('term', '', 'request');

		$response = [
			'results' => [],
			'pagination' => [
				'more' => false,
			],
		];

		if (empty($term)) {
			// output the JSON object with no results
			VBOHttpDocument::getInstance()->json($response);
		}

		$sql_term = $dbo->quote("%{$term}%");

		$q = "SELECT `c`.`id`, `c`.`first_name`, `c`.`last_name`, `c`.`country`, 
			(SELECT COUNT(*) FROM `#__vikbooking_customers_orders` AS `co` WHERE `co`.`idcustomer`=`c`.`id`) AS `tot_bookings` 
			FROM `#__vikbooking_customers` AS `c` 
			WHERE CONCAT_WS(' ', `c`.`first_name`, `c`.`last_name`) LIKE {$sql_term} 
			OR `email` LIKE {$sql_term} 
			ORDER BY `c`.`first_name` ASC, `c`.`last_name` ASC;";
		$dbo->setQuery($q);
		$customers = $dbo->loadAssocList();

		if ($customers) {
			foreach ($customers as $k => $customer) {
				$customers[$k]['text'] = trim($customer['first_name'] . ' ' . $customer['last_name']) . ' (' . $customer['tot_bookings'] . ')';
			}
			// push results found
			$response['results'] = $customers;
		}

		// output the JSON encoded object with results found
		VBOHttpDocument::getInstance()->json($response);
	}

	/**
	 * AJAX endpoint to dynamically search for rooms. Compatible with select2.
	 * 
	 * @return 	void
	 * 
	 * @since 	1.16.10 (J) - 1.6.10 (WP)
	 */
	public function rooms_search()
	{
		if (!JSession::checkToken()) {
			VBOHttpDocument::getInstance()->close(403, JText::translate('JINVALID_TOKEN'));
		}

		$dbo = JFactory::getDbo();
		$app = JFactory::getApplication();

		$term = $app->input->getString('term', '');

		$response = [
			'results' => [],
			'pagination' => [
				'more' => false,
			],
		];

		if (empty($term)) {
			// output the JSON object with no results
			VBOHttpDocument::getInstance($app)->json($response);
		}

		$dbo->setQuery(
			$dbo->getQuery(true)
				->select([
					$dbo->qn('id'),
					$dbo->qn('name', 'text'),
					$dbo->qn('img'),
				])
				->from($dbo->qn('#__vikbooking_rooms'))
				->where($dbo->qn('name') . ' LIKE ' . $dbo->q("%{$term}%"))
				->order($dbo->qn('avail') . ' DESC')
				->order($dbo->qn('name') . ' ASC')
		);

		// set results found
		$response['results'] = $dbo->loadAssocList();

		// load and map mini thumbnails
		$mini_thumbnails = VBORoomHelper::getInstance()->loadMiniThumbnails($response['results']);
		$response['results'] = array_map(function($room) use ($mini_thumbnails) {
			if ($mini_thumbnails[$room['id']] ?? '') {
				// set mini thumbnail URL
				$room['img'] = $mini_thumbnails[$room['id']];
			} else {
				unset($room['img']);
			}
			return $room;
		}, $response['results']);

		// output the JSON encoded object with results found
		VBOHttpDocument::getInstance()->json($response);
	}

	/**
	 * AJAX endpoint to dynamically search for bookings. Compatible with select2.
	 * 
	 * @return 	void
	 * 
	 * @since 	1.18.0 (J) - 1.8.0 (WP)
	 */
	public function bookings_search()
	{
		if (!JSession::checkToken()) {
			VBOHttpDocument::getInstance()->close(403, JText::translate('JINVALID_TOKEN'));
		}

		$dbo = JFactory::getDbo();
		$app = JFactory::getApplication();

		$booking_key = trim($app->input->getString('term', ''));
		$booking_status = array_values(array_filter((array) $app->input->get('status', [], 'array')));

		$response = [
			'results' => [],
			'pagination' => [
				'more' => false,
			],
		];

		if (empty($booking_key)) {
			// output the JSON object with no results
			VBOHttpDocument::getInstance($app)->json($response);
		}

		// attempt to detect a booking ID
		$booking_id = 0;
		if (preg_match("/^[0-9]+$/", $booking_key)) {
			// only numbers should be a booking ID
			$booking_id = $booking_key;
		} elseif (preg_match('/^(?=.*?\d)(?=.*?[A-Z])[A-Z\d]+$/', $booking_key)) {
			/**
			 * Matched both numbers and upper-case letters, so it has to be an OTA booking ID, not a customer name.
			 * Regex breakdown:
			 * beginning of string
			 * lookahead for at least one digit
			 * lookahead for at least one upper-case letter
			 * match one or more upper-case letters or digits
			 * end of string
			 */
			$booking_id = $booking_key;
		}

		// start the query
		$q = $dbo->getQuery(true)
			->select([
				$dbo->qn('o.id'),
				$dbo->qn('o.custdata'),
				$dbo->qn('o.days'),
				$dbo->qn('o.status'),
				$dbo->qn('o.checkin'),
				$dbo->qn('o.checkout'),
				$dbo->qn('o.idorderota'),
				$dbo->qn('o.channel'),
				$dbo->qn('c.first_name'),
				$dbo->qn('c.last_name'),
				$dbo->qn('c.pic'),
			])
			->from($dbo->qn('#__vikbooking_orders', 'o'))
			->leftJoin($dbo->qn('#__vikbooking_customers_orders', 'co') . ' ON ' . $dbo->qn('co.idorder') . ' = ' . $dbo->qn('o.id'))
			->leftJoin($dbo->qn('#__vikbooking_customers', 'c') . ' ON ' . $dbo->qn('c.id') . ' = ' . $dbo->qn('co.idcustomer'))
			->where($dbo->qn('o.closure') . ' = 0');

		if ($booking_status) {
			// filter by booking status
			if (count($booking_status) === 1) {
				// single booking status
				$q->where($dbo->qn('o.status') . ' = ' . $dbo->q($booking_status[0]));
			} else {
				// multiple booking statuses
				$q->where($dbo->qn('o.status') . ' IN (' . implode(', ', array_map([$dbo, 'q'], $booking_status)) . ')');
			}
		}

		if (!empty($booking_id)) {
			// search by booking ID or OTA booking ID only
			if (preg_match("/^[0-9]+$/", (string) $booking_id)) {
            	// only numbers could be both website and OTA
				$q->andWhere([
					$dbo->qn('o.id') . ' = ' . (int) $booking_id,
					$dbo->qn('o.idorderota') . ' = ' . $dbo->q($booking_id),
				], $glue = 'OR');
			} else {
				// alphanumeric IDs can only belong to an OTA reservation
            	$q->where($dbo->qn('o.idorderota') . ' = ' . $dbo->q($booking_id));
			}
		} else {
			// search by different values
			if (stripos($booking_key, 'id:') === 0) {
				// search by ID or OTA ID
				$seek_parts = explode('id:', $booking_key);
				$seek_value = trim($seek_parts[1]);
				$q->andWhere([
					$dbo->qn('o.id') . ' = ' . $dbo->q($seek_value),
					$dbo->qn('o.idorderota') . ' = ' . $dbo->q($seek_value),
				], $glue = 'OR');
			} elseif (stripos($booking_key, 'otaid:') === 0) {
				// search by OTA Booking ID
				$seek_parts = explode('otaid:', $booking_key);
				$seek_value = trim($seek_parts[1]);
				$q->where($dbo->qn('o.idorderota') . ' = ' . $dbo->q($seek_value));
			} elseif (stripos($booking_key, 'coupon:') === 0) {
				// search by coupon code
				$seek_parts = explode('coupon:', $booking_key);
				$seek_value = trim($seek_parts[1]);
				$q->where($dbo->qn('o.coupon') . ' LIKE ' . $dbo->q("%{$seek_value}%"));
			} elseif (stripos($booking_key, 'name:') === 0) {
				// search by customer nominative
				$seek_parts = explode('name:', $booking_key);
				$seek_value = trim($seek_parts[1]);
				$q->where('CONCAT_WS(\' \', ' . $dbo->qn('c.first_name') . ', ' . $dbo->qn('c.last_name') . ') LIKE ' . $dbo->q("%{$seek_value}%"));
			} elseif (strpos($booking_key, '@') !== false) {
				// search by customer email
				$q->where($dbo->qn('o.custmail') . ' = ' . $dbo->q($booking_key));
			} elseif (strpos($booking_key, '+') === 0) {
				// search by customer phone
				$q->where($dbo->qn('o.phone') . ' = ' . $dbo->q($booking_key));
			} else {
				// seek for various values
				if (preg_match("/^[a-z\s]+$/i", (string) $booking_key)) {
					// when only letters (or spaces) look only for the customer name
					$q->where('CONCAT_WS(\' \', ' . $dbo->qn('c.first_name') . ', ' . $dbo->qn('c.last_name') . ') LIKE ' . $dbo->q("%{$booking_key}%"));
				} else {
					// look for both customer name and booking ID
					$q->andWhere([
						'CONCAT_WS(\' \', ' . $dbo->qn('c.first_name') . ', ' . $dbo->qn('c.last_name') . ') LIKE ' . $dbo->q("%{$booking_key}%"),
						$dbo->qn('o.id') . ' = ' . $dbo->q($booking_key),
						$dbo->qn('o.idorderota') . ' = ' . $dbo->q($booking_key),
					], $glue = 'OR');
				}
			}
		}

		// order by most recent bookings
		$q->order($dbo->qn('id') . ' DESC');

		$dbo->setQuery($q);

		// set results found
		$response['results'] = $dbo->loadAssocList();

		// default icon for website reservations
		$source_def_icon_cls = VikBookingIcons::i('hotel');

		// map the results with the required properties
		$response['results'] = array_map(function($booking) use ($source_def_icon_cls) {
			// build "text" property
			$text = $booking['id'];
			if (!empty($booking['first_name'])) {
				// use customer nominative when available
				$text = trim($booking['first_name'] . ' ' . $booking['last_name']);
			} elseif (!empty($booking['custdata'])) {
				$text = VikBooking::getFirstCustDataField($booking['custdata']);
			}
			$booking['text'] = $text;

			// build "img" property
			if (!empty($booking['pic'])) {
				// use guest profile picture
				$booking['img'] = strpos($booking['pic'], 'http') === 0 ? $booking['pic'] : VBO_SITE_URI . 'resources/uploads/' . $booking['pic'];
			} elseif (!empty($booking['channel'])) {
				// use channel logo
				$ch_logo_obj = VikBooking::getVcmChannelsLogo($booking['channel'], true);
				$booking['img'] = is_object($ch_logo_obj) ? $ch_logo_obj->getTinyLogoURL() : '';
			}

			if (empty($booking['img'])) {
				// always set an empty string
				$booking['img'] = '';
				// set the default icon class
				$booking['icon_class'] = $source_def_icon_cls;
			}

			// return the mapped booking element
			return $booking;
		}, $response['results']);

		// output the JSON encoded object with results found
		VBOHttpDocument::getInstance()->json($response);
	}

	/**
	 * AJAX endpoint to dynamically search for customers and build elements. Compatible with select2.
	 * 
	 * @return 	void
	 * 
	 * @since 	1.18.0 (J) - 1.8.0 (WP)
	 */
	public function customer_elements_search()
	{
		if (!JSession::checkToken()) {
			VBOHttpDocument::getInstance()->close(403, JText::translate('JINVALID_TOKEN'));
		}

		$dbo = JFactory::getDbo();
		$app = JFactory::getApplication();

		$search_key = trim($app->input->getString('term', ''));

		$response = [
			'results' => [],
			'pagination' => [
				'more' => false,
			],
		];

		if (empty($search_key)) {
			// output the JSON object with no results
			VBOHttpDocument::getInstance($app)->json($response);
		}

		// start the query
		$q = $dbo->getQuery(true)
			->select('*')
			->from($dbo->qn('#__vikbooking_customers'))
			->where(1);

		if (preg_match('/^[a-z0-9\.\-\_]+\@[a-z0-9\.\-\_]+\.[a-z0-9\.\-\_]+$/i', $search_key)) {
			// full email address detected
			$q->where($dbo->qn('email') . ' = ' . $dbo->q($search_key));
		} else {
			// search by different values
			$seek_clauses = [];

			// search by nominative
			$seek_clauses[] = 'CONCAT_WS(" ", ' . $dbo->qn('first_name') . ', ' . $dbo->qn('last_name') . ') LIKE ' . $dbo->q('%' . $search_key . '%');

			// search by company name
			$seek_clauses[] = $dbo->qn('company') . ' LIKE ' . $dbo->q('%' . $search_key . '%');

			if (preg_match('/^\+?[0-9\s]+$/i', $search_key)) {
				// search by phone number
				$seek_clauses[] = $dbo->qn('phone') . ' = ' . $dbo->q($search_key);
			}

			if (strpos($search_key, '@') !== false) {
				// search by email address
				$seek_clauses[] = $dbo->qn('email') . ' LIKE ' . $dbo->q('%' . $search_key . '%');
			}

			if (preg_match('/[0-9]+/', $search_key)) {
				// search by company VAT number
				$seek_clauses[] = $dbo->qn('vat') . ' = ' . $dbo->q($search_key);

				// search by PIN code
				$seek_clauses[] = $dbo->qn('pin') . ' = ' . $dbo->q($search_key);
			}

			// set multiple search clauses
			$q->andWhere($seek_clauses, 'OR');
		}

		// order by customer nominative
		$q->order($dbo->qn('first_name') . ' ASC');
		$q->order($dbo->qn('last_name') . ' ASC');

		$dbo->setQuery($q);

		// set results found
		$response['results'] = $dbo->loadAssocList();

		// default icon for customers
		$source_def_icon_cls = VikBookingIcons::i('user');

		// map the results with the required properties
		$response['results'] = array_map(function($customer) use ($source_def_icon_cls) {
			// build "text" property
			$customer['text'] = trim($customer['first_name'] . ' ' . $customer['last_name']);

			// build "img" property
			if (!empty($customer['pic'])) {
				// use customer profile picture
				$customer['img'] = strpos($customer['pic'], 'http') === 0 ? $customer['pic'] : VBO_SITE_URI . 'resources/uploads/' . $customer['pic'];
			} elseif (!empty($customer['country']) && is_file(implode(DIRECTORY_SEPARATOR, [VBO_ADMIN_PATH, 'resources', 'countries', $customer['country'] . '.png']))) {
				// use customer country flag
				$customer['img'] = VBO_ADMIN_URI . 'resources/countries/' . $customer['country'] . '.png';
				$customer['img_title'] = $customer['country'];
			}

			if (empty($customer['img'])) {
				// always set an empty string
				$customer['img'] = '';
				// set the default icon class
				$customer['icon_class'] = $source_def_icon_cls;
			}

			// handle custom fields
			if (!empty($customer['cfields'])) {
				$custom_fields = (array) json_decode($customer['cfields'], true);
				if ($custom_fields) {
					$customer['cfields'] = $custom_fields;
				}
			}
			if (!is_array($customer['cfields']) || !$customer['cfields']) {
				// ensure this is a null value
				$customer['cfields'] = null;
			}

			// return the mapped customer element
			return $customer;
		}, $response['results']);

		// output the JSON encoded object with results found
		VBOHttpDocument::getInstance()->json($response);
	}

	/**
	 * Regular task to update the status of a cancelled booking to pending (stand-by).
	 * 
	 * @return 	void
	 */
	public function set_to_pending()
	{
		$dbo = JFactory::getDbo();
		$app = JFactory::getApplication();

		$bid = $app->input->getInt('bid', 0);

		if (!JSession::checkToken() && !JSession::checkToken('get')) {
			$app->enqueueMessage(JText::translate('JINVALID_TOKEN'), 'error');
			$app->redirect('index.php?option=com_vikbooking&task=editorder&cid[]=' . $bid);
			$app->close();
		}

		$q = "SELECT * FROM `#__vikbooking_orders` WHERE `id`=" . $bid;
		$dbo->setQuery($q, 0, 1);
		$dbo->execute();
		if (!$dbo->getNumRows()) {
			$app->enqueueMessage('Booking not found', 'error');
			$app->redirect('index.php?option=com_vikbooking&task=orders');
			$app->close();
		}

		$booking = $dbo->loadAssoc();
		if ($booking['status'] != 'cancelled') {
			$app->enqueueMessage('Booking status must be -Cancelled-', 'error');
			$app->redirect('index.php?option=com_vikbooking&task=editorder&cid[]=' . $booking['id']);
			$app->close();
		}

		$q = "UPDATE `#__vikbooking_orders` SET `status`='standby' WHERE `id`=" . $booking['id'];
		$dbo->setQuery($q);
		$dbo->execute();

		$app->enqueueMessage(JText::translate('JLIB_APPLICATION_SAVE_SUCCESS'));
		$app->redirect('index.php?option=com_vikbooking&task=editorder&cid[]=' . $booking['id']);
		$app->close();
	}

	/**
	 * AJAX endpoint to assign a room index to a room booking record.
	 * 
	 * @return 	void
	 */
	public function set_room_booking_subunit()
	{
		if (!JSession::checkToken()) {
			VBOHttpDocument::getInstance()->close(403, JText::translate('JINVALID_TOKEN'));
		}

		$dbo = JFactory::getDbo();
		$app = JFactory::getApplication();

		$bid = $app->input->getInt('bid', 0);
		$rid = $app->input->getInt('rid', 0);
		$orkey = $app->input->getInt('orkey', 0);
		$rindex = $app->input->getInt('rindex', 0);

		if (empty($bid) || empty($rid)) {
			VBOHttpDocument::getInstance()->close(500, 'Missing request values');
		}

		$q = "SELECT * FROM `#__vikbooking_orders` WHERE `id`=" . $bid;
		$dbo->setQuery($q, 0, 1);
		$booking = $dbo->loadAssoc();
		if (!$booking) {
			VBOHttpDocument::getInstance()->close(404, 'Booking not found');
		}

		$booking_rooms = VikBooking::loadOrdersRoomsData($booking['id']);
		if (!$booking_rooms) {
			VBOHttpDocument::getInstance()->close(500, 'No rooms booking found');
		}

		if (!isset($booking_rooms[$orkey]) || $booking_rooms[$orkey]['idroom'] != $rid) {
			VBOHttpDocument::getInstance()->close(500, 'Invalid room booking record');
		}

		// update room record
		$room_record = new stdClass;
		$room_record->id = $booking_rooms[$orkey]['id'];
		$room_record->roomindex = $rindex;

		$dbo->updateObject('#__vikbooking_ordersrooms', $room_record, 'id');

		// build list of affected nights
		$nights_list_ymd   = [];
		$from_checkin_info = getdate($booking['checkin']);
		for ($n = 0; $n < $booking['days']; $n++) {
			// push affected night
			$nights_list_ymd[] = date('Y-m-d', mktime(0, 0, 0, $from_checkin_info['mon'], ($from_checkin_info['mday'] + $n), $from_checkin_info['year']));
		}

		// build return values
		$response = [
			'bid' 	 => $booking['id'],
			'rid' 	 => $booking_rooms[$orkey]['idroom'],
			'rindex' => $rindex,
			'from' 	 => date('Y-m-d', $booking['checkin']),
			'to' 	 => date('Y-m-d', $booking['checkout']),
			'nights' => $nights_list_ymd,
		];

		// output the JSON encoded object
		VBOHttpDocument::getInstance()->json($response);
	}

	/**
	 * AJAX endpoint to swap one sub-unit index with another for the same room ID and dates.
	 * 
	 * @return 	void
	 * 
	 * @since 	1.16.2 (J) - 1.6.2 (WP)
	 */
	public function swap_room_subunits()
	{
		if (!JSession::checkToken()) {
			VBOHttpDocument::getInstance()->close(403, JText::translate('JINVALID_TOKEN'));
		}

		$dbo = JFactory::getDbo();
		$app = JFactory::getApplication();

		$bid_one   = $app->input->getInt('bid_one', 0);
		$bid_two   = $app->input->getInt('bid_two', 0);
		$rid 	   = $app->input->getInt('rid', 0);
		$index_one = $app->input->getInt('index_one', 0);
		$index_two = $app->input->getInt('index_two', 0);
		$checkin   = $app->input->getString('checkin', '');

		if (!$bid_one || !$bid_two || !$rid || !$index_one || !$index_two || $index_one < 0 || $index_two < 0) {
			VBOHttpDocument::getInstance()->close(500, 'Missing or invalid request values');
		}

		// collect the booking information
		$booking_one = VikBooking::getBookingInfoFromID($bid_one);
		$booking_two = VikBooking::getBookingInfoFromID($bid_two);
		if (!$booking_one || !$booking_two) {
			VBOHttpDocument::getInstance()->close(404, 'Could not find the involved reservations');
		}

		// get room reservation records
		$rooms_one = VikBooking::loadOrdersRoomsData($bid_one);
		$rooms_two = VikBooking::loadOrdersRoomsData($bid_two);
		if (!$rooms_one || !$rooms_two) {
			VBOHttpDocument::getInstance()->close(404, 'Could not find the involved room reservation records');
		}

		// find the record IDs involved and room name
		$update_id_one = null;
		$update_id_two = null;
		$room_name 	   = '';

		foreach ($rooms_one as $room_one) {
			if ($room_one['idroom'] == $rid && $room_one['roomindex'] == $index_one) {
				$update_id_one = $room_one['id'];
				$room_name = $room_one['room_name'];
				break;
			}
		}

		foreach ($rooms_two as $room_two) {
			if ($room_two['idroom'] == $rid && $room_two['roomindex'] == $index_two) {
				$update_id_two = $room_two['id'];
				$room_name = $room_two['room_name'];
				break;
			}
		}

		if (!$update_id_one || !$update_id_two) {
			VBOHttpDocument::getInstance()->close(500, 'Could not find the involved room reservation record IDs');
		}

		// swap first room record
		$q = $dbo->getQuery(true);

		$q->update($dbo->qn('#__vikbooking_ordersrooms'))
			->set($dbo->qn('roomindex') . ' = ' . $index_two)
			->where($dbo->qn('id') . ' = ' . (int)$update_id_one);

		$dbo->setQuery($q);
		$dbo->execute();

		$result = (bool)$dbo->getAffectedRows();

		// swap second room record
		$q = $dbo->getQuery(true);

		$q->update($dbo->qn('#__vikbooking_ordersrooms'))
			->set($dbo->qn('roomindex') . ' = ' . $index_one)
			->where($dbo->qn('id') . ' = ' . (int)$update_id_two);

		$dbo->setQuery($q);
		$dbo->execute();

		$result = $result || (bool)$dbo->getAffectedRows();

		if (!$result) {
			VBOHttpDocument::getInstance()->close(500, 'No records were updated for the involved room reservation IDs');
		}

		// update history records
		$user = JFactory::getUser();
		VikBooking::getBookingHistoryInstance()->setBid($booking_one['id'])->store('MB', JText::sprintf('VBO_SWAP_ROOMS_LOG', $room_name, $index_one, $index_two) . " ({$user->name})");
		if ($booking_one['id'] != $booking_two['id']) {
			VikBooking::getBookingHistoryInstance()->setBid($booking_two['id'])->store('MB', JText::sprintf('VBO_SWAP_ROOMS_LOG', $room_name, $index_two, $index_one) . " ({$user->name})");
		}

		// output the JSON encoded response object
		VBOHttpDocument::getInstance()->json([
			'swap_from' => $index_one,
			'swap_to' => $index_two,
		]);
	}

	/**
	 * AJAX endpoint to remove the type flag (i.e. "overbooking") from a booking ID.
	 * 
	 * @return 	void
	 */
	public function delete_type_flag()
	{
		if (!JSession::checkToken()) {
			VBOHttpDocument::getInstance()->close(403, JText::translate('JINVALID_TOKEN'));
		}

		$dbo = JFactory::getDbo();
		$app = JFactory::getApplication();

		$bid  = $app->input->getUInt('bid', 0);
		$flag = $app->input->getString('flag', '');

		if (!$bid) {
			VBOHttpDocument::getInstance()->close(404, JText::translate('VBPEDITBUSYONE'));
		}

		$q = $dbo->getQuery(true);

		$q->update($dbo->qn('#__vikbooking_orders'))
			->set($dbo->qn('type') . ' = ' . $dbo->q(''))
			->where($dbo->qn('id') . ' = ' . $bid);

		$dbo->setQuery($q);
		$dbo->execute();

		if (!(bool)$dbo->getAffectedRows()) {
			VBOHttpDocument::getInstance()->close(500, 'Could not update the booking record');
		}

		if (!strcasecmp($flag, 'overbooking')) {
			// update history records
			$user = JFactory::getUser();
			VikBooking::getBookingHistoryInstance($bid)->store('OB', JText::translate('VBO_OVERBOOKING_FLAG_REMOVED') . " ({$user->name})");
		}

		VBOHttpDocument::getInstance()->json([$bid => 'ok']);
	}

	/**
	 * AJAX endpoint to set the AI options related to automatic guest review for a booking.
	 * 
	 * @return 	void
	 * 
	 * @since 	1.16.10 (J) - 1.6.10 (WP)
	 */
	public function set_ai_auto_guest_review_opt()
	{
		if (!JSession::checkToken()) {
			VBOHttpDocument::getInstance()->close(403, JText::translate('JINVALID_TOKEN'));
		}

		$dbo = JFactory::getDbo();
		$app = JFactory::getApplication();

		$bid = $app->input->getInt('bid', 0);
		$opt = $app->input->get('opt', [], 'array');

		if (!$bid || !$opt) {
			VBOHttpDocument::getInstance()->close(500, 'Missing request values');
		}

		// ensure the booking exists
		$booking = VikBooking::getBookingInfoFromID($bid);
		if (!$booking) {
			VBOHttpDocument::getInstance()->close(404, 'Could not find the involved reservation');
		}

		// get AI options for this booking
		$booking_ai_opts = (array) VBOFactory::getConfig()->getArray('ai_auto_guest_review_opt_' . $booking['id'], []);

		// update the requested options
		foreach ($opt as $param => $val) {
			if (is_bool($val) || is_numeric($val)) {
				$val = (int) $val;
			}
			// set new option value
			$booking_ai_opts[$param] = $val;
		}

		// update AI options for this booking
		VBOFactory::getConfig()->set('ai_auto_guest_review_opt_' . $booking['id'], $booking_ai_opts);

		// return the new preferences
		VBOHttpDocument::getInstance()->json($booking_ai_opts);
	}

	/**
	 * AJAX endpoint to register a new taking (payment).
	 * 
	 * @return 	void
	 * 
	 * @since 	1.16.10 (J) - 1.6.10 (WP)
	 */
	public function add_taking()
	{
		if (!JSession::checkToken()) {
			VBOHttpDocument::getInstance()->close(403, JText::translate('JINVALID_TOKEN'));
		}

		$dbo = JFactory::getDbo();
		$app = JFactory::getApplication();

		$bid    = $app->input->getInt('bid', 0);
		$amount = $app->input->getFloat('amount', 0);
		$payid  = $app->input->getInt('payid', 0);
		$descr  = $app->input->getString('descr', '');

		if (!$bid || !$amount || $amount < 0) {
			VBOHttpDocument::getInstance()->close(500, 'Missing or invalid request values.');
		}

		// ensure the booking exists
		$booking = VikBooking::getBookingInfoFromID($bid);
		if (!$booking) {
			VBOHttpDocument::getInstance()->close(404, 'Could not find the involved reservation.');
		}

		$new_tot_paid = $booking['totpaid'] + $amount;

		// update booking record
		$dbo->setQuery(
			$dbo->getQuery(true)
				->update($dbo->qn('#__vikbooking_orders'))
				->set($dbo->qn('totpaid') . ' = ' . $dbo->q($new_tot_paid))
				->where($dbo->qn('id') . ' = ' . (int) $booking['id'])
		);
		$dbo->execute();

		// update booking history
		$extra_data = new stdClass;
		$extra_data->register_new   = 1;
		$extra_data->amount_paid    = $amount;
		$extra_data->payment_method = $descr;
		if (!empty($payid)) {
			$pay_info = VikBooking::getPayment($payid);
			if ($pay_info) {
				$extra_data->payment_method = $pay_info['name'];
			}
		}
		VikBooking::getBookingHistoryInstance($booking['id'])
			->setExtraData($extra_data)
			->store(
				'PU',
				JText::sprintf('VBOPREVAMOUNTPAID', VikBooking::numberFormat($booking['totpaid']) . (!empty($extra_data->payment_method) ? ' (' . $extra_data->payment_method . ')' : ''))
			);

		// process completed
		VBOHttpDocument::getInstance()->json([
			'url'  => VBOFactory::getPlatform()->getUri()->admin('index.php?option=com_vikbooking&task=editorder&cid[]=' . $booking['id'], false),
		]);
	}

	/**
	 * AJAX endpoint to update a taking (payment) and related history record.
	 * 
	 * @return 	void
	 * 
	 * @since 	1.16.10 (J) - 1.6.10 (WP)
	 */
	public function update_taking()
	{
		if (!JSession::checkToken()) {
			VBOHttpDocument::getInstance()->close(403, JText::translate('JINVALID_TOKEN'));
		}

		$dbo = JFactory::getDbo();
		$app = JFactory::getApplication();

		$bid    = $app->input->getInt('bid', 0);
		$hid    = $app->input->getInt('hid', 0);
		$amount = $app->input->getFloat('amount', 0);
		$descr  = $app->input->getString('descr', '');
		$htype  = $app->input->getString('htype', 'PU');

		if (!$bid || !$hid || !$amount || $amount < 0) {
			VBOHttpDocument::getInstance()->close(500, 'Missing or invalid request values.');
		}

		// ensure the booking exists
		$booking = VikBooking::getBookingInfoFromID($bid);
		if (!$booking) {
			VBOHttpDocument::getInstance()->close(404, 'Could not find the involved reservation.');
		}

		// access the current history record
		$q = $dbo->getQuery(true)
			->select('*')
			->from($dbo->qn('#__vikbooking_orderhistory'))
			->where($dbo->qn('id') . ' = ' . (int) $hid)
			->where($dbo->qn('idorder') . ' = ' . (int) $booking['id'])
			->where($dbo->qn('type') . ' = ' . $dbo->q($htype));
		$dbo->setQuery($q, 0, 1);
		$history = $dbo->loadAssoc();

		if (!$history) {
			VBOHttpDocument::getInstance()->close(404, 'Could not find the history record to update.');
		}

		// get previous amount paid
		$history_data = (object) json_decode(($history['data'] ?: '{}'));
		$prev_amount_paid = $history_data->amount_paid ?? 0;

		// calculate new amount paid
		if ($prev_amount_paid > $amount) {
			$new_tot_paid = $booking['totpaid'] - ($prev_amount_paid - $amount);
		} else {
			$new_tot_paid = $booking['totpaid'] + ($amount - $prev_amount_paid);
		}

		// update booking record
		$dbo->setQuery(
			$dbo->getQuery(true)
				->update($dbo->qn('#__vikbooking_orders'))
				->set($dbo->qn('totpaid') . ' = ' . $dbo->q($new_tot_paid))
				->where($dbo->qn('id') . ' = ' . (int) $booking['id'])
		);
		$dbo->execute();

		// get currently logged user
		$user  = JFactory::getUser();
		$uname = $user->name;

		// update history extra data
		$history_data->register_new   = 1;
		$history_data->updated        = JFactory::getDate()->toSql();
		$history_data->updated_by     = $uname;
		$history_data->amount_paid    = $amount;
		$history_data->payment_method = $descr;

		// set new record description
		$new_descr = trim($history['descr'] . "\n* " . JText::sprintf('VBO_MODIFIED_ON_SMT', JFactory::getDate()->toSql(true) . ' (' . $uname . ')'));

		// update history record
		$dbo->setQuery(
			$dbo->getQuery(true)
				->update($dbo->qn('#__vikbooking_orderhistory'))
				->set($dbo->qn('descr') . ' = ' . $dbo->q($new_descr))
				->set($dbo->qn('totpaid') . ' = ' . $dbo->q($new_tot_paid))
				->set($dbo->qn('data') . ' = ' . $dbo->q(json_encode($history_data)))
				->where($dbo->qn('id') . ' = ' . (int) $history['id'])
		);
		$dbo->execute();

		// process completed
		VBOHttpDocument::getInstance()->json([
			'url'  => VBOFactory::getPlatform()->getUri()->admin('index.php?option=com_vikbooking&task=editorder&cid[]=' . $booking['id'], false),
		]);
	}

	/**
	 * AJAX endpoint to delete a booking history event.
	 * 
	 * @return 	void
	 * 
	 * @since 	1.16.10 (J) - 1.6.10 (WP)
	 */
	public function delete_history_record()
	{
		if (!JSession::checkToken()) {
			VBOHttpDocument::getInstance()->close(403, JText::translate('JINVALID_TOKEN'));
		}

		$dbo = JFactory::getDbo();
		$app = JFactory::getApplication();

		$bid   = $app->input->getInt('bid', 0);
		$hid   = $app->input->getInt('hid', 0);
		$htype = $app->input->getString('htype', 'PU');

		if (!$bid || !$hid) {
			VBOHttpDocument::getInstance()->close(500, 'Missing or invalid request values.');
		}

		// ensure the booking exists
		$booking = VikBooking::getBookingInfoFromID($bid);
		if (!$booking) {
			VBOHttpDocument::getInstance()->close(404, 'Could not find the involved reservation.');
		}

		// access the current history record
		$q = $dbo->getQuery(true)
			->select('*')
			->from($dbo->qn('#__vikbooking_orderhistory'))
			->where($dbo->qn('id') . ' = ' . (int) $hid)
			->where($dbo->qn('idorder') . ' = ' . (int) $booking['id'])
			->where($dbo->qn('type') . ' = ' . $dbo->q($htype));
		$dbo->setQuery($q, 0, 1);
		$history = $dbo->loadAssoc();

		if (!$history) {
			VBOHttpDocument::getInstance()->close(404, 'Could not find the history record to update.');
		}

		// get previous amount paid
		$history_data = (object) json_decode(($history['data'] ?: '{}'));
		$prev_amount_paid = $history_data->amount_paid ?? 0;

		// calculate new amount paid
		$new_tot_paid = $booking['totpaid'];
		if ($prev_amount_paid) {
			$new_tot_paid = $booking['totpaid'] - $prev_amount_paid;
		}

		// update booking record
		$dbo->setQuery(
			$dbo->getQuery(true)
				->update($dbo->qn('#__vikbooking_orders'))
				->set($dbo->qn('totpaid') . ' = ' . $dbo->q($new_tot_paid))
				->where($dbo->qn('id') . ' = ' . (int) $booking['id'])
		);
		$dbo->execute();

		// delete history record
		$dbo->setQuery(
			$dbo->getQuery(true)
				->delete($dbo->qn('#__vikbooking_orderhistory'))
				->where($dbo->qn('id') . ' = ' . (int) $hid)
				->where($dbo->qn('idorder') . ' = ' . (int) $booking['id'])
				->where($dbo->qn('type') . ' = ' . $dbo->q($htype))
		);

		$dbo->execute();
		$aff_rows = $dbo->getAffectedRows();

		// process completed
		VBOHttpDocument::getInstance()->json([
			'rows' => $aff_rows,
			'url'  => VBOFactory::getPlatform()->getUri()->admin('index.php?option=com_vikbooking&task=editorder&cid[]=' . $booking['id'], false),
		]);
	}

	/**
	 * AJAX endpoint to check whether a whole booking can be modified with new stay dates.
	 * 
	 * @return 	void
	 * 
	 * @since 	1.18.2 (J) - 1.8.2 (WP)
	 */
	public function is_booking_modifiable()
	{
		if (!JSession::checkToken()) {
			VBOHttpDocument::getInstance()->close(403, JText::translate('JINVALID_TOKEN'));
		}

		$app = JFactory::getApplication();

		$bid      = $app->input->getInt('bid', 0);
		$checkin  = $app->input->getString('checkin');
		$checkout = $app->input->getString('checkout');

		if (!$bid || empty($checkin) || empty($checkout)) {
			VBOHttpDocument::getInstance()->close(400, 'Missing booking information.');
		}

		try {
			// check whether the booking can be modified
			$modifiable = (new VBOModelReservation)->bookingModifiable($bid, $checkin, $checkout);
		} catch (Exception $e) {
			// propagate the error
			VBOHttpDocument::getInstance()->close($e->getCode() ?: 400, $e->getMessage() ?: 'Validation failure.');
		}

		// validation process completed
		VBOHttpDocument::getInstance()->json([
			'modifiable' => $modifiable,
			'bid'        => $bid,
			'checkin'    => $checkin,
			'checkout'   => $checkout,
		]);
	}
}
