<?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');
        }

        // clone room reservation records
        $current_rooms_one = $rooms_one;
        $current_rooms_two = $rooms_two;

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

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

        foreach ($rooms_two as $k => $room_two) {
            if ($room_two['idroom'] == $rid && $room_two['roomindex'] == $index_two) {
                $update_id_two = $room_two['id'];
                $room_name = $room_two['room_name'];
                $current_rooms_two[$k]['roomindex'] = $index_one;
                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 record(s)
        $user = JFactory::getUser();
        VikBooking::getBookingHistoryInstance($booking_one['id'])
            ->setPrevBooking(array_merge($booking_one, ['rooms_info' => $rooms_one]))
            ->setBookingData($booking_one, $current_rooms_one)
            ->store('MB', JText::sprintf('VBO_SWAP_ROOMS_LOG', $room_name, $index_one, $index_two) . " ({$user->name})");
        if ($booking_one['id'] != $booking_two['id']) {
            // update history for the second booking involved
            VikBooking::getBookingHistoryInstance($booking_two['id'])
                ->setPrevBooking(array_merge($booking_two, ['rooms_info' => $rooms_two]))
                ->setBookingData($booking_two, $current_rooms_two)
                ->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,
        ]);
    }

    /**
     * AJAX endpoint to resolve room (sub-unit) booking record assignment.
     * 
     * @return  void
     * 
     * @since   1.18.7 (J) - 1.8.7 (WP)
     */
    public function resolve_room_assignment()
    {
        $app = JFactory::getApplication();

        if (!JSession::checkToken()) {
            VBOHttpDocument::getInstance($app)->close(403, JText::translate('JINVALID_TOKEN'));
        }        

        // gather request values
        $booking_id      = $app->input->getUInt('bid', 0);
        $id_room         = $app->input->getUInt('id_room', 0);
        $room_booking_id = $app->input->getUInt('room_booking_id', 0);
        $roomres_index   = $app->input->getInt('booking_room_index', -1);
        $days_bound      = $app->input->getUInt('days_bound', 0);
        $max_exec_time   = $app->input->getUInt('max_exec_time', 0);
        $count_all       = $app->input->getBool('count_all', false);

        $skip_booking_ids = (array) $app->input->getInt('skip_booking_ids', []);
        $skip_moveset_signatures = (array) $app->input->getString('skip_moveset_signatures', []);

        if (!$booking_id) {
            VBOHttpDocument::getInstance($app)->close(400, 'Missing booking information.');
        }

        if ($room_booking_id) {
            // an exact booking room record ID was provided
            $booking_rooms = VikBooking::loadOrdersRoomsData($booking_id);
            foreach ($booking_rooms as $index => $booking_room) {
                if ($booking_room['id'] == $room_booking_id) {
                    // match found, set reliable properties
                    $id_room = $booking_room['idroom'];
                    $roomres_index = $index;
                    break;
                }
            }
        }

        // build room re-assignment options
        $options = [
            'id'                      => $booking_id,
            'id_room'                 => $id_room,
            'booking_room_index'      => $roomres_index,
            'days_bound'              => $days_bound ?: null,
            'max_exec_time'           => $max_exec_time,
            'count_all'               => $count_all,
            'skip_booking_ids'        => $skip_booking_ids,
            'skip_moveset_signatures' => $skip_moveset_signatures,
        ];

        try {
            // let the room re-assignment operations start
            $moveset = VBOBookingRelocator::getInstance($options)
                ->findRelocation();
        } catch (Exception $e) {
            // propagate the error
            VBOHttpDocument::getInstance($app)->close($e->getCode(), $e->getMessage());
        }

        // build moveset layout data
        $layout_data = [
            'booking_id' => $booking_id,
            'moveset'    => $moveset,
            'options'    => $options,
        ];

        // render moveset layout HTML
        $movesetHtml = JLayoutHelper::render('overview.relocationmoveset', $layout_data);

        // validation process completed
        VBOHttpDocument::getInstance($app)->json([
            'html'             => $movesetHtml,
            'movesetSignature' => $moveset->getSignature(),
            'solutionsCount'   => $moveset->getSolutionsCount(),
        ]);
    }

    /**
     * AJAX endpoint to apply a moveset to resolve room assignments.
     * 
     * @return  void
     * 
     * @since   1.18.7 (J) - 1.8.7 (WP)
     */
    public function apply_room_assignment()
    {
        $app = JFactory::getApplication();
        $dbo = JFactory::getDbo();

        if (!JSession::checkToken()) {
            VBOHttpDocument::getInstance($app)->close(403, JText::translate('JINVALID_TOKEN'));
        }        

        // gather request values
        $moveset = $app->input->getString('moveset');
        $undo = $app->input->getBool('undo', false);

        if (!$moveset) {
            VBOHttpDocument::getInstance($app)->close(400, 'Missing reassignment moveset.');
        }

        // room details empty container
        $roomDetails = [];

        // confirm the integrity of the operations to execute
        $confirmedOperations = [];

        // scan operation steps
        $operationSteps = explode('-', $moveset);
        foreach ($operationSteps as $operationStep) {
            // obtain move details
            $moveData = explode('.', $operationStep);
            if (count($moveData) < 4) {
                VBOHttpDocument::getInstance($app)->close(400, 'Invalid moveset operation.');
            }

            // fetch requested room record
            $dbo->setQuery(
                $dbo->getQuery(true)
                    ->select('*')
                    ->from($dbo->qn('#__vikbooking_ordersrooms'))
                    ->where($dbo->qn('id') . ' = ' . (int) ($moveData[1] ?? 0))
                    ->where($dbo->qn('idorder') . ' = ' . (int) ($moveData[0] ?? 0))
            );
            $roomRecord = $dbo->loadAssoc();

            if (!$roomRecord) {
                VBOHttpDocument::getInstance($app)->close(404, 'Could not find moveset operation.');
            }

            // confirm operation
            $confirmedOperations[] = $moveData;

            if (!$roomDetails) {
                // load the first and only room details
                $roomDetails = VikBooking::getRoomInfo((int) $roomRecord['idroom'], ['id', 'name'], true);
            }
        }

        if (!$confirmedOperations) {
            VBOHttpDocument::getInstance($app)->close(400, 'No valid moveset operations.');
        }

        // get current CMS user and name
        $user = JFactory::getUser();
        $userName = $user->name;

        // determine action name
        $actionName = $undo ? JText::translate('VBO_UNDO_CHANGES') : JText::translate('VBO_RESOLVE_ROOM_ASSIGNMENT');

        // apply all moves
        foreach ($confirmedOperations as $moveData) {
            // determine the room index to apply
            $prev_room_index = $undo ? (int) ($moveData[3] ?? 0) : (int) ($moveData[2] ?? 0);
            $new_room_index = $undo ? (int) ($moveData[2] ?? 0) : (int) ($moveData[3] ?? 0);
            // update current room record
            $dbo->setQuery(
                $dbo->getQuery(true)
                    ->update($dbo->qn('#__vikbooking_ordersrooms'))
                    ->set($dbo->qn('roomindex') . ' = ' . ($new_room_index ?: 'NULL'))
                    ->where($dbo->qn('id') . ' = ' . (int) ($moveData[1] ?? 0))
                    ->where($dbo->qn('idorder') . ' = ' . (int) ($moveData[0] ?? 0))
            );
            $dbo->execute();

            // Booking History
            VikBooking::getBookingHistoryInstance((int) ($moveData[0] ?? 0))
                ->setExtraData([
                    'action' => 'resolve_room_assignment',
                    'type'   => $undo ? 'undo' : 'apply',
                ])
                ->store(
                    'MB',
                    sprintf(
                        '(%s) %s: %s',
                        (string) $userName,
                        $actionName,
                        JText::sprintf('VBOROOMSUBUNITCHANGEFT', $roomDetails['name'], $prev_room_index, $new_room_index)
                    )
                );
        }

        // process completed
        VBOHttpDocument::getInstance($app)->json([
            'success' => true,
        ]);
    }
}
