<?php
/** 
 * @package     VikBooking
 * @subpackage  core
 * @author      E4J s.r.l.
 * @copyright   Copyright (C) 2025 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!');

/**
 * Door Access integration provider for TTLock.
 * 
 * @since   1.18.4 (J) - 1.8.4 (WP)
 * 
 * @link    https://euopen.ttlock.com/document/doc?urlName=userGuide%2FekeyEn.html
 */
final class VBODooraccessProviderTtlock extends VBODooraccessIntegrationAware
{
    /**
     * @var     ?string
     */
    private ?string $oauthToken = null;

    /**
     * @var     array
     */
    private array $httpHeaders = [];

    /**
     * @inheritDoc
     */
    public function getAlias()
    {
        return basename(__FILE__, '.php');
    }

    /**
     * @inheritDoc
     */
    public function getName()
    {
        return 'TTLock - Smart Locks';
    }

    /**
     * @inheritDoc
     */
    public function getShortName()
    {
        return 'TTLock';
    }

    /**
     * @inheritDoc
     */
    public function getIcon()
    {
        return VBO_ADMIN_URI . 'resources/ttlock-vikbooking-integration-logo.png';
    }

    /**
     * @inheritDoc
     */
    public function getParams()
    {
        return [
            'ai' => [
                'type'    => 'checkbox',
                'label'   => JText::translate('VBO_AI_SUPPORT'),
                'help'    => JText::translate('VBO_DAC_AI_SUPPORT_HELP'),
                'default' => 1,
            ],
            'passquant' => [
                'type'    => 'select',
                'label'   => JText::translate('VBO_PASSCODES'),
                'help'    => JText::translate('VBO_PASSCODES_QUANT_HELP'),
                'options' => [
                    1 => JText::translate('VBO_ONE_PER_DEVICE'),
                    2 => JText::translate('VBO_ONE_PER_BOOKING'),
                ],
                'default' => 1,
            ],
            'client_id' => [
                'type'  => 'text',
                'label' => 'Client ID',
            ],
            'client_secret' => [
                'type'  => 'password',
                'label' => 'Client Secret',
            ],
            'username' => [
                'type'  => 'text',
                'label' => 'Username',
            ],
            'password' => [
                'type'  => 'password',
                'label' => 'Password',
            ],
        ];
    }

    /**
     * @inheritDoc
     */
    public function canUnlockDevices()
    {
        // this method is called when the integration has loaded its profile record
        // we return true only if the apposite AI setting is enabled

        $settings = $this->getSettings();

        return !empty($settings['ai']);
    }

    /**
     * Device capability implementation to unlock a device.
     * 
     * @param   VBODooraccessIntegrationDevice  $device     The device executing the capability.
     * @param   ?array                          $options    Optional settings populated from capability parameters.
     * 
     * @return  VBODooraccessDeviceCapabilityResult
     * 
     * @link    https://euopen.ttlock.com/document/doc?urlName=cloud%2Fgateway%2FunlockEn.html
     */
    public function unlockDevice(VBODooraccessIntegrationDevice $device, ?array $options = null)
    {
        // start transporter
        $transporter = $this->createHTTPTransporter();

        // obtain integration settings after initializing the transporter
        $settings = $this->getSettings();

        // build request data
        $data = [
            'clientId'    => $settings['client_id'],
            'accessToken' => $settings['_oauth']['access_token'],
            'lockId'      => $device->getID(),
            'date'        => time() . '000',
        ];

        // make the API request
        $response = $transporter->post('https://euapi.ttlock.com/v3/lock/unlock', $data, [], 60);

        // obtain the response data
        $responseData = (array) json_decode((string) $response->body, true);

        if ($response->code != 200 || !empty($responseData['errcode'])) {
            // an error occurred
            throw new Exception($responseData['errmsg'] ?? $response->body ?: 'Error unlocking the device.', ($response->code != 200 ? $response->code : 500));
        }

        return (new VBODooraccessDeviceCapabilityResult)->setText(sprintf('The device "%s" was unlocked!', $device->getName()));
    }

    /**
     * Device capability implementation to lock a device.
     * 
     * @param   VBODooraccessIntegrationDevice  $device     The device executing the capability.
     * @param   ?array                          $options    Optional settings populated from capability parameters.
     * 
     * @return  VBODooraccessDeviceCapabilityResult
     * 
     * @link    https://euopen.ttlock.com/document/doc?urlName=cloud%2Fgateway%2FunlockEn.html
     */
    public function lockDevice(VBODooraccessIntegrationDevice $device, ?array $options = null)
    {
        // start transporter
        $transporter = $this->createHTTPTransporter();

        // obtain integration settings after initializing the transporter
        $settings = $this->getSettings();

        // build request data
        $data = [
            'clientId'    => $settings['client_id'],
            'accessToken' => $settings['_oauth']['access_token'],
            'lockId'      => $device->getID(),
            'date'        => time() . '000',
        ];

        // make the API request
        $response = $transporter->post('https://euapi.ttlock.com/v3/lock/lock', $data, [], 60);

        // obtain the response data
        $responseData = (array) json_decode((string) $response->body, true);

        if ($response->code != 200 || !empty($responseData['errcode'])) {
            // an error occurred
            throw new Exception($responseData['errmsg'] ?? $response->body ?: 'Error locking the device.', ($response->code != 200 ? $response->code : 500));
        }

        return (new VBODooraccessDeviceCapabilityResult)->setText(sprintf('The device "%s" was locked!', $device->getName()));
    }

    /**
     * Device capability implementation to list a device passcodes.
     * 
     * @param   VBODooraccessIntegrationDevice  $device     The device executing the capability.
     * @param   ?array                          $options    Optional settings populated from capability parameters.
     * 
     * @return  VBODooraccessDeviceCapabilityResult
     * 
     * @throws  Exception
     * 
     * @link    https://euopen.ttlock.com/document/doc?urlName=cloud%2Fpasscode%2FlistEn.html
     */
    public function listPasscodes(VBODooraccessIntegrationDevice $device, ?array $options = null)
    {
        $passcodes = [];

        // start transporter
        $transporter = $this->createHTTPTransporter();

        // obtain integration settings after initializing the transporter
        $settings = $this->getSettings();

        // request page settings
        $pageNo = 1;
        $pageSize = 100;
        $reqCount = 0;
        $reqMax = 5;

        // start a loop to support pagination
        while (true) {
            if ($reqCount >= $reqMax) {
                // too many requests
                break;
            }

            // build query string data
            $data = [
                'clientId'    => $settings['client_id'],
                'accessToken' => $settings['_oauth']['access_token'],
                'lockId'      => $device->getID(),
                'searchStr'   => $options['search'] ?? null,
                'pageNo'      => $pageNo,
                'pageSize'    => $pageSize,
                'orderBy'     => 1,
                'date'        => time() . '000',
            ];

            // make a request to obtain all created passcodes of a lock
            $response = $transporter->get('https://euapi.ttlock.com/v3/lock/listKeyboardPwd?' . http_build_query($data), [], 20);

            // obtain the response data
            $responseData = (array) json_decode((string) $response->body, true);

            if ($response->code != 200 || !empty($responseData['errcode'])) {
                // an error occurred
                throw new Exception($responseData['errmsg'] ?? $response->body ?: 'Error fetching device passcodes.', ($response->code != 200 ? $response->code : 500));
            }

            // increase request counter
            $reqCount++;

            if (!empty($responseData['list'])) {
                $passcodes = array_merge($passcodes, $responseData['list']);
            }

            if (($responseData['pages'] ?? 0) > $pageNo) {
                // go to the next loop
                $pageNo++;

                continue;
            }

            // all passcodes were read
            break;
        }

        if (!$passcodes) {
            throw new Exception('No passcodes found for the device.', 404);
        }

        // list of passcode IDs and values obtained
        $passcodesAssoc = [];

        // build HTML output
        $output = '';

        // lang defs
        $lang_passcode  = JText::translate('VBO_PASSCODE');
        $lang_startdate = JText::translate('VBNEWPKGDFROM');
        $lang_enddate   = JText::translate('VBNEWPKGDTO');
        $lang_createdon = JText::translate('VBOINVCREATIONDATE');
        $lang_createdby = JText::translate('VBCSVCREATEDBY');
        $lang_custom    = JText::translate('VBO_CUSTOM');

        // table head
        $output .= <<<HTML
<div class="vbo-dac-table-wrap">
    <table class="vbo-dac-table">
        <thead>
            <tr>
                <td>Passcode ID</td>
                <td>Passcode Name</td>
                <td>{$lang_passcode}</td>
                <td>Passcode Type</td>
                <td>{$lang_startdate}</td>
                <td>{$lang_enddate}</td>
                <td>{$lang_createdon}</td>
                <td>{$lang_createdby}</td>
                <td>{$lang_custom}</td>
            </tr>
        </thead>
        <tbody>
HTML;

        // scan all passcodes obtained
        foreach ($passcodes as $passcode) {
            // set passcode properties
            $passcodeId = $passcode['keyboardPwdId'] ?? '';
            $passcodeValue = $passcode['keyboardPwd'] ?? '';
            $passcodeName = $passcode['keyboardPwdName'] ?? '';
            $passcodeType = $this->getPasscodeTypes((int) ($passcode['keyboardPwdType'] ?? 0), true);
            $startDate = !empty($passcode['startDate']) ? date('Y-m-d H:i:s', ($passcode['startDate'] ?: 1000) / 1000) : '---';
            $endDate = !empty($passcode['endDate']) ? date('Y-m-d H:i:s', ($passcode['endDate'] ?: 1000) / 1000) : '---';
            $sendDate = !empty($passcode['sendDate']) ? date('Y-m-d H:i:s', ($passcode['sendDate'] ?: 1000) / 1000) : '---';
            $senderUsername = $passcode['senderUsername'] ?? '';
            $isCustom = ($passcode['isCustom'] ?? 0) == 1 ? JText::translate('VBYES') : JText::translate('VBNO');

            // bind passcode id values
            $passcodesAssoc[$passcodeId] = [
                'name'  => $passcodeName,
                'value' => $passcodeValue,
            ];

            // build passcode HTML code
            $output .= <<<HTML
            <tr>
                <td><span class="vbo-dac-table-passcode-id">{$passcodeId}</span></td>
                <td><span class="vbo-dac-table-passcode-name">{$passcodeName}</span></td>
                <td><span class="vbo-dac-table-passcode-code">{$passcodeValue}</span></td>
                <td>{$passcodeType}</td>
                <td>{$startDate}</td>
                <td>{$endDate}</td>
                <td>{$sendDate}</td>
                <td>{$senderUsername}</td>
                <td>{$isCustom}</td>
            </tr>
HTML;
        }

        // close table
        $output .= <<<HTML
        </tbody>
    </table>
</div>
HTML;

        // return the capability result object by setting the output value
        return (new VBODooraccessDeviceCapabilityResult($passcodesAssoc))
            ->setOutput($output);
    }

    /**
     * Device capability implementation to create a random passcode for a device (generated by TTLock).
     * Notice that the random passcode generation of TTLock is based on the password type and the validity dates.
     * This means that deleting a passcode for a device that was valid on certain dates, will trigger an error
     * if another random passcode is generated for the same device and validity dates. It is, therefore, unsafe
     * to rely on this passcode generation method when passcodes are generated at the time of booking, because
     * modifications or cancellations may occur and a new re-generation of passcode may be requested for new bookings.
     * 
     * @param   VBODooraccessIntegrationDevice  $device     The device executing the capability.
     * @param   ?array                          $options    Optional settings populated from capability parameters.
     * 
     * @return  VBODooraccessDeviceCapabilityResult
     * 
     * @link    https://euopen.ttlock.com/document/doc?urlName=cloud%2Fpasscode%2FgetEn.html
     */
    public function createRandomPasscode(VBODooraccessIntegrationDevice $device, ?array $options = null)
    {
        // start transporter
        $transporter = $this->createHTTPTransporter();

        // obtain integration settings after initializing the transporter
        $settings = $this->getSettings();

        // build request data
        $data = [
            'clientId'        => $settings['client_id'],
            'accessToken'     => $settings['_oauth']['access_token'],
            'lockId'          => $device->getID(),
            'keyboardPwdType' => (int) ($options['pwdtype'] ?? 3),
            'keyboardPwdName' => $options['pwdname'] ?? null,
            'startDate'       => strtotime($options['startdate'] ?? date('Y-m-d H:i:s')) . '000',
            'endDate'         => strtotime($options['enddate'] ?? date('Y-m-d H:i:s')) . '000',
            'date'            => time() . '000',
        ];

        // make the API request
        $response = $transporter->post('https://euapi.ttlock.com/v3/keyboardPwd/get', $data, [], 60);

        // obtain the response data
        $responseData = (array) json_decode((string) $response->body, true);

        if ($response->code != 200 || !empty($responseData['errcode'])) {
            // an error occurred
            throw new Exception($responseData['errmsg'] ?? $response->body ?: 'Error getting a random passcode for the device.', ($response->code != 200 ? $response->code : 500));
        }

        // build result properties to bind
        $resultProps = [
            'keyboardPwd'   => (string) ($responseData['keyboardPwd'] ?? ''),
            'keyboardPwdId' => (string) ($responseData['keyboardPwdId'] ?? ''),
            'listingId'     => (int) ($options['listing_id'] ?? 0),
        ];

        // get the listing name, if available
        $listingName = '';
        if (!empty($resultProps['listingId'])) {
            $listingData = VikBooking::getRoomInfo($resultProps['listingId'], ['name'], true);
            $listingName = sprintf('%s: ', $listingData['name'] ?? '');
        }

        // wrap and return the device capability result object
        return (new VBODooraccessDeviceCapabilityResult($resultProps))
            ->setPasscode($resultProps['keyboardPwd'])
            ->setText($listingName . JText::sprintf('VBO_PASSCODE_GEN_OK_DEVICE', $resultProps['keyboardPwd'], $device->getName()));
    }

    /**
     * Device capability implementation to create a custom passcode for a device.
     * 
     * @param   VBODooraccessIntegrationDevice  $device     The device executing the capability.
     * @param   ?array                          $options    Optional settings populated from capability parameters.
     * 
     * @return  VBODooraccessDeviceCapabilityResult
     * 
     * @link    https://euopen.ttlock.com/document/doc?urlName=cloud%2Fpasscode%2FaddEn.html
     */
    public function createCustomPasscode(VBODooraccessIntegrationDevice $device, ?array $options = null)
    {
        // start transporter
        $transporter = $this->createHTTPTransporter();

        // obtain integration settings after initializing the transporter
        $settings = $this->getSettings();

        if (empty($options['pwdvalue'])) {
            // passcode value cannot be empty
            $options['pwdvalue'] = $this->generateRandomPasscode();
        }

        // build request data
        $data = [
            'clientId'        => $settings['client_id'],
            'accessToken'     => $settings['_oauth']['access_token'],
            'lockId'          => $device->getID(),
            'keyboardPwd'     => (int) $options['pwdvalue'],
            'keyboardPwdName' => $options['pwdname'] ?? null,
            'keyboardPwdType' => (int) ($options['pwdtype'] ?? 3),
            'startDate'       => strtotime($options['startdate'] ?? date('Y-m-d H:i:s')) . '000',
            'endDate'         => strtotime($options['enddate'] ?? date('Y-m-d H:i:s')) . '000',
            'addType'         => 2,
            'date'            => time() . '000',
        ];

        // make the API request
        $response = $transporter->post('https://euapi.ttlock.com/v3/keyboardPwd/add', $data, [], 60);

        // obtain the response data
        $responseData = (array) json_decode((string) $response->body, true);

        if ($response->code != 200 || !empty($responseData['errcode'])) {
            // an error occurred
            throw new Exception($responseData['errmsg'] ?? $response->body ?: 'Error adding a custom passcode to the device.', ($response->code != 200 ? $response->code : 500));
        }

        // build result properties to bind
        $resultProps = [
            'keyboardPwd'   => (string) $options['pwdvalue'],
            'keyboardPwdId' => (string) ($responseData['keyboardPwdId'] ?? ''),
            'listingId'     => (int) ($options['listing_id'] ?? 0),
        ];

        // get the listing name, if available
        $listingName = '';
        if (!empty($resultProps['listingId'])) {
            $listingData = VikBooking::getRoomInfo($resultProps['listingId'], ['name'], true);
            $listingName = sprintf('%s: ', $listingData['name'] ?? '');
        }

        // wrap and return the device capability result object
        return (new VBODooraccessDeviceCapabilityResult($resultProps))
            ->setPasscode($resultProps['keyboardPwd'])
            ->setText($listingName . JText::sprintf('VBO_PASSCODE_GEN_OK_DEVICE', $resultProps['keyboardPwd'], $device->getName()));
    }

    /**
     * Device capability implementation to delete a passcode from a device.
     * 
     * @param   VBODooraccessIntegrationDevice  $device     The device executing the capability.
     * @param   ?array                          $options    Optional settings populated from capability parameters.
     * 
     * @return  VBODooraccessDeviceCapabilityResult
     * 
     * @link    https://euopen.ttlock.com/document/doc?urlName=cloud%2Fpasscode%2FdeleteEn.html
     */
    public function deletePasscode(VBODooraccessIntegrationDevice $device, ?array $options = null)
    {
        // start transporter
        $transporter = $this->createHTTPTransporter();

        // obtain integration settings after initializing the transporter
        $settings = $this->getSettings();

        // build request data
        $data = [
            'clientId'      => $settings['client_id'],
            'accessToken'   => $settings['_oauth']['access_token'],
            'lockId'        => $device->getID(),
            'keyboardPwdId' => $options['pwdid'] ?? null,
            'deleteType'    => 2,
            'date'          => time() . '000',
        ];

        // make the API request
        $response = $transporter->post('https://euapi.ttlock.com/v3/keyboardPwd/delete', $data, [], 60);

        // obtain the response data
        $responseData = (array) json_decode((string) $response->body, true);

        if ($response->code != 200 || !empty($responseData['errcode'])) {
            // an error occurred
            throw new Exception($responseData['errmsg'] ?? $response->body ?: 'Error deleting passcode from device.', ($response->code != 200 ? $response->code : 500));
        }

        return (new VBODooraccessDeviceCapabilityResult)
            ->setText(JText::sprintf('VBO_PASSCODE_DEL_OK_DEVICE', $device->getName()));
    }

    /**
     * @inheritDoc
     */
    public function createBookingDoorAccess(VBODooraccessIntegrationDevice $device, int $listingId, VBOBookingRegistry $registry)
    {
        // access the integration settings
        $settings = $this->getSettings();

        // build booking-listing signature
        $signature = sprintf('%d-%d', $registry->getID(), $listingId);

        // access booking registry DAC data for passcodes generated
        $passcodesBuffer = $registry->getDACProperty($this->getAlias(), 'passcodes', []);

        // determine the passcode value to use, either a new one or a previous one for the same booking
        if (($settings['passquant'] ?? 0) == 2 && ($passcodesBuffer[$signature] ?? null)) {
            // use the previously generated passcode for this booking and listing also on this device
            $passcodeValue = $passcodesBuffer[$signature];
        } else {
            // generate custom, yet random, passcode value of 8 digits for this device
            $passcodeValue = $this->generateRandomPasscode();
        }

        // prepare the options for creating a custom passcode (randomly generated by us)
        $options = [
            // use a password name that can be used later to find it under this booking and listing ID
            'pwdname'   => sprintf('bid:%d-%d', $registry->getID(), $listingId),
            // set the passcode validity start date and time
            'startdate' => date('Y-m-d H:i:00', $registry->getProperty('checkin', 0)),
            // set the passcode validity end date and time
            'enddate'   => date('Y-m-d H:i:00', $registry->getProperty('checkout', 0)),
            // custom passcode value to create on the device
            'pwdvalue' => $passcodeValue,
            // inject the listing ID for completion of data
            'listing_id' => $listingId,
        ];

        // create custom passcode on the current device
        $result = $this->createCustomPasscode($device, $options);

        // update booking registry DAC data for passcodes generated
        $passcodesBuffer[$signature] = $result->getPasscode();
        $registry->setDACProperty($this->getAlias(), 'passcodes', $passcodesBuffer);

        return $result;
    }

    /**
     * @inheritDoc
     */
    public function modifyBookingDoorAccess(VBODooraccessIntegrationDevice $device, int $listingId, VBOBookingRegistry $registry)
    {
        // searching, deleting and re-creating passcodes is always safer in case of
        // booking modification for possibly different listing IDs involved

        // find the passcode data that were previously created for this booking
        $previousDevicePasscodes = VikBooking::getBookingHistoryInstance($registry->getID())
            ->getEventsWithData(['ND', 'MD'], function($data) use ($device) {
                $data = (array) $data;
                // ensure the passcode was generated for this provider, profile and device
                return ($data['provider'] ?? '') == $this->getProfileProvider() &&
                    ($data['profile'] ?? '') == $this->getProfileID() &&
                    ($data['device'] ?? '') == $device->getID() &&
                    (!empty($data['passcode']) || !empty($data['props']));
            });

        if (!$previousDevicePasscodes) {
            // no passcodes were previously created for this booking
            // process the modification as a new door access creation (with TTLock random passcode)
            return $this->createBookingDoorAccess($device, $listingId, $registry);
        }

        // scan all previously created passcodes in DESC order on this device and delete them
        $previousPasscodeIds = [];
        foreach (array_reverse($previousDevicePasscodes) as $previousData) {
            // ensure we only have array values
            $previousData = (array) json_decode(json_encode($previousData), true);

            // get the previous passcode
            $previousPasscode = ($previousData['passcode'] ?? '') ?: ($previousData['props']['keyboardPwdId'] ?? '');

            if (empty($previousPasscode) || in_array($previousPasscode, $previousPasscodeIds)) {
                // no passcode ID to delete, or already deleted
                continue;
            }

            // push processed passcode ID
            $previousPasscodeIds[] = $previousPasscode;

            try {
                // delete previous passcode for this booking
                $this->deletePasscode($device, [
                    'pwdid' => $previousPasscode,
                ]);
            } catch (Exception $e) {
                // do nothing on error
            }
        }

        // process the modification as a new door access creation, always with custom passcodes
        return $this->createBookingDoorAccess($device, $listingId, $registry);
    }

    /**
     * @inheritDoc
     */
    public function cancelBookingDoorAccess(VBODooraccessIntegrationDevice $device, int $listingId, VBOBookingRegistry $registry)
    {
        try {
            // find the previously created passcode for this booking and listing
            $findResult = $this->listPasscodes($device, [
                'search' => sprintf('bid:%d-%d', $registry->getID(), $listingId),
            ]);

            if (!$findResult->getProperties()) {
                // passcode not found
                throw new Exception('Previous passcode not found.', 404);
            }
        } catch (Exception $e) {
            // nothing to cancel, but prevent unwanted errors not related to the real cancellation
            return null;
        }

        // iterate the list of passcodes found, even if only one is expected
        foreach ($findResult->getProperties() as $pwdId => $pwdData) {
            // delete the first passcode found
            return $this->deletePasscode($device, [
                'pwdid' => $pwdId,
            ]);
        }
    }

    /**
     * @inheritDoc
     */
    public function handleUnlockDevice(VBODooraccessIntegrationDevice $device)
    {
        // unlock the requested device
        return $this->unlockDevice($device);
    }

    /**
     * @inheritDoc
     */
    public function getPasscodeFromHistoryResult(array $resultProperties)
    {
        // creating a passcode should bind its value within the device capability result object
        return $resultProperties['keyboardPwd'] ?? null;
    }

    /**
     * @inheritDoc
     * 
     * @link    https://euopen.ttlock.com/document/doc?urlName=cloud%2Flock%2FlistEn.html
     */
    protected function fetchRemoteDevices()
    {
        $devices = [];

        // start transporter
        $transporter = $this->createHTTPTransporter();

        // obtain settings after initializing the transporter
        $settings = $this->getSettings();

        // request page settings
        $pageNo = 1;
        $pageSize = 20;
        $reqCount = 0;
        $reqMax = 20;

        // start a loop to support pagination
        while (true) {
            if ($reqCount >= $reqMax) {
                // too many requests
                break;
            }

            // build query string data
            $data = [
                'clientId'    => $settings['client_id'],
                'accessToken' => $settings['_oauth']['access_token'],
                'pageNo'      => $pageNo,
                'pageSize'    => $pageSize,
                'date'        => time() . '000',
            ];

            // make a request to obtain the lock list of an account
            $response = $transporter->get('https://euapi.ttlock.com/v3/lock/list?' . http_build_query($data), [], 20);

            // obtain the response data
            $responseData = (array) json_decode((string) $response->body, true);

            if ($response->code != 200 || !empty($responseData['errcode'])) {
                // an error occurred
                throw new Exception($responseData['errmsg'] ?? $response->body ?: 'Error fetching the remove devices.', ($response->code != 200 ? $response->code : 500));
            }

            // increase request counter
            $reqCount++;

            if (!empty($responseData['list'])) {
                $devices = array_merge($devices, $responseData['list']);
            }

            if (($responseData['pages'] ?? 0) > $pageNo) {
                // go to the next loop
                $pageNo++;

                continue;
            }

            // all devices were read
            break;
        }

        if (!$devices) {
            throw new Exception('No devices found under the current account.', 500);
        }

        return $devices;
    }

    /**
     * @inheritDoc
     */
    protected function decorateDeviceProperties(VBODooraccessIntegrationDevice $decorator, array $device)
    {
        // set device ID
        $decorator->setID($device['lockId'] ?? '');

        // set device name
        $decorator->setName($device['lockAlias'] ?? $device['lockName'] ?? '');

        // set device description
        $decorator->setDescription($device['groupName'] ?? '');

        // set device icon
        $decorator->setIcon('<i class="' . VikBookingIcons::i('fingerprint') . '"></i>');

        // set device model
        $decorator->setModel($device['lockName'] ?? '');

        if ($device['electricQuantity'] ?? null) {
            // set device battery level
            $decorator->setBatteryLevel((float) $device['electricQuantity']);
        }

        // set device capabilities
        $decorator->setCapabilities([
            // unlock device
            $this->createDeviceCapability([
                'id'          => 'unlock_device',
                'title'       => JText::translate('VBDASHUNLOCK'),
                'description' => JText::translate('VBO_UNLOCK_DEVICE_HELP'),
                'icon'        => '<i class="' . VikBookingIcons::i('unlock') . '"></i>',
                'callback'    => 'unlockDevice',
            ]),
            // lock device
            $this->createDeviceCapability([
                'id'          => 'lock_device',
                'title'       => JText::translate('VBO_LOCK'),
                'description' => JText::translate('VBO_LOCK_DEVICE_HELP'),
                'icon'        => '<i class="' . VikBookingIcons::i('lock') . '"></i>',
                'callback'    => 'lockDevice',
            ]),
            // read passcodes
            $this->createDeviceCapability([
                'id'          => 'list_passcodes',
                'title'       => JText::translate('VBO_LIST_PASSCODES'),
                'description' => JText::translate('VBO_LIST_PASSCODES_HELP'),
                'icon'        => '<i class="' . VikBookingIcons::i('key') . '"></i>',
                'callback'    => 'listPasscodes',
                'params'      => [
                    'search' => [
                        'type'  => 'text',
                        'label' => JText::translate('VBO_SEARCH_PASSCODE'),
                        'help'  => JText::translate('VBO_OPT_SEARCH_KEYWORD'),
                    ],
                ],
            ]),
            // create (random) passcode
            $this->createDeviceCapability([
                'id'          => 'create_passcode',
                'title'       => JText::translate('VBO_CREATE_PASSCODE'),
                'description' => JText::translate('VBO_CREATE_PASSCODE_HELP'),
                'icon'        => '<i class="' . VikBookingIcons::i('plus') . '"></i>',
                'callback'    => 'createRandomPasscode',
                'params'      => [
                    'pwdname' => [
                        'type'  => 'text',
                        'label' => JText::translate('VBO_PASSCODE_NAME'),
                        'help'  => JText::translate('VBO_OPT_PASSCODE_NAME'),
                    ],
                    'pwdtype' => [
                        'type' => 'select',
                        'label' => JText::translate('VBPSHOWSEASONSTHREE'),
                        'options' => array_combine(array_keys($this->getPasscodeTypes()), array_column($this->getPasscodeTypes(), 'name')),
                        'default' => 3,
                    ],
                    'startdate' => [
                        'type'  => 'datetime',
                        'label' => JText::translate('VBNEWPKGDFROM'),
                        'help'  => JText::translate('VBO_PASSCODE_VALID_START'),
                    ],
                    'enddate' => [
                        'type'  => 'datetime',
                        'label' => JText::translate('VBNEWPKGDTO'),
                        'help'  => JText::translate('VBO_PASSCODE_VALID_END'),
                    ],
                ],
            ]),
            // create (custom) passcode
            $this->createDeviceCapability([
                'id'          => 'create_custom_passcode',
                'title'       => JText::translate('VBO_CREATE_PASSCODECUST'),
                'description' => JText::translate('VBO_CREATE_PASSCODECUST_HELP'),
                'icon'        => '<i class="' . VikBookingIcons::i('user-plus') . '"></i>',
                'callback'    => 'createCustomPasscode',
                'params'      => [
                    'pwdvalue' => [
                        'type'    => 'text',
                        'label'   => JText::translate('VBO_PASSCODE'),
                        'help'    => JText::translate('VBO_PASSCODE_EMPTY_HELP') . ' 4-9 digits, first digit should not be 0.',
                        'attributes' => [
                            'pattern' => '[1-9][0-9]{3,8}',
                        ],
                    ],
                    'pwdname' => [
                        'type'  => 'text',
                        'label' => JText::translate('VBO_PASSCODE_NAME'),
                        'help'  => JText::translate('VBO_OPT_PASSCODE_NAME'),
                    ],
                    'pwdtype' => [
                        'type' => 'select',
                        'label' => JText::translate('VBPSHOWSEASONSTHREE'),
                        'options' => array_combine(array_keys($this->getPasscodeTypes()), array_column($this->getPasscodeTypes(), 'name')),
                        'default' => 3,
                    ],
                    'startdate' => [
                        'type'  => 'datetime',
                        'label' => JText::translate('VBNEWPKGDFROM'),
                        'help'  => JText::translate('VBO_PASSCODE_VALID_START'),
                    ],
                    'enddate' => [
                        'type'  => 'datetime',
                        'label' => JText::translate('VBNEWPKGDTO'),
                        'help'  => JText::translate('VBO_PASSCODE_VALID_END'),
                    ],
                ],
            ]),
            // delete passcode
            $this->createDeviceCapability([
                'id'          => 'delete_passcode',
                'title'       => JText::translate('VBO_DELETE_PASSCODE'),
                'description' => JText::translate('VBO_DELETE_PASSCODE_HELP'),
                'icon'        => '<i class="' . VikBookingIcons::i('trash') . '"></i>',
                'callback'    => 'deletePasscode',
                'params'      => [
                    'pwdid' => [
                        'type'  => 'text',
                        'label' => 'Passcode ID',
                    ],
                ],
            ]),
        ]);

        // set device payload by unsetting the unwanted properties
        unset($device['lockData']);
        $decorator->setPayload($device);
    }

    /**
     * Generates a random serial code made of only digits with a given length.
     * The sequence obtained will never start with 0 to allow integer casting.
     * 
     * @param   int     $length     The passcode length.
     * 
     * @return  string
     */
    private function generateRandomPasscode(int $length = 8)
    {
        return rand(1, 9) . VikBooking::getCPinInstance()->generateSerialCode($length - 1, ['0123456789']);
    }

    /**
     * Maps the supported passcode type identifiers with name and description.
     * 
     * @param   ?int    $type   Optional passcode type identifier to fetch.
     * @param   bool    $name   True to get only the passcode name.
     * 
     * @return  array|string    Full list, passcode type array or passcode string name.
     */
    private function getPasscodeTypes(?int $type = null, bool $name = false)
    {
        $list = [
            1 => [
                'name' => 'One-time',
                'descr' => 'Valid only once within 6 hours from the Start Time.',
            ],
            2 => [
                'name' => 'Permanent',
                'descr' => 'Code must be used at least once within 24 Hours after the Start Time, or it will be invalidated.',
            ],
            3 => [
                'name' => 'Period',
                'descr' => 'Code must be used at least once within 24 Hours after the Start Time, or it will be invalidated.',
            ],
            4 => [
                'name' => 'Delete',
                'descr' => 'The code will delete all other codes.',
            ],
            5 => [
                'name' => 'Weekend Cyclic',
                'descr' => 'The code is valid during the time period at the weekend.',
            ],
            6 => [
                'name' => 'Daily Cyclic',
                'descr' => 'The code is valid during the time period everyday.',
            ],
            7 => [
                'name' => 'Workday Cyclic',
                'descr' => 'The code is valid during the time period on workdays.',
            ],
            8 => [
                'name' => 'Monday Cyclic',
                'descr' => 'The code is valid during the time period on Mondays.',
            ],
            9 => [
                'name' => 'Tuesday Cyclic',
                'descr' => 'The code is valid during the time period on Tuesdays.',
            ],
            10 => [
                'name' => 'Wednesday Cyclic',
                'descr' => 'The code is valid during the time period on Wednesdays.',
            ],
            11 => [
                'name' => 'Thursday Cyclic',
                'descr' => 'The code is valid during the time period on Thursdays.',
            ],
            12 => [
                'name' => 'Friday Cyclic',
                'descr' => 'The code is valid during the time period on Fridays.',
            ],
            13 => [
                'name' => 'Saturday Cyclic',
                'descr' => 'The code is valid during the time period on Saturdays.',
            ],
            14 => [
                'name' => 'Sunday Cyclic',
                'descr' => 'The code is valid during the time period on Sundays.',
            ],
        ];

        if (!$type) {
            return $list;
        }

        if (!$name) {
            return $list[$type] ?? [];
        }

        return $list[$type]['name'] ?? '';
    }

    /**
     * Creates the HTTP Transporter to establish API connections with TTLock.
     * An integration profile record is supposed to be set before making an HTTP request.
     * 
     * @param   ?array  $options    Optional transporter options.
     * 
     * @return  object              The prepared HTTP transporter object with bearer token.
     * 
     * @throws  Exception
     */
    private function createHTTPTransporter(?array $options = null)
    {
        // access current profile settings
        $settings = $this->getSettings();

        if (empty($settings['client_id']) || empty($settings['client_secret']) || empty($settings['username']) || empty($settings['password'])) {
            throw new Exception('Missing integration profile credentials (settings).', 500);
        }

        // obtain a valid OAuth token
        if ($options['renew_token'] ?? null) {
            // force the token renewal
            $this->oauthToken = $this->renewOauthToken();
        } else {
            // get the possibly valid token
            $this->oauthToken = $this->getOauthToken();
        }

        // set HTTP headers
        $this->httpHeaders = [
            'Authorization' => "Bearer {$this->oauthToken}",
            'ContentType' => 'application/x-www-form-urlencoded',
        ];

        return new JHttp;
    }

    /**
     * Obtains an active OAuth (Bearer) token to establish API connections with TTLock.
     * 
     * @return  string      An active OAuth (Bearer) token.
     * 
     * @throws  Exception
     */
    private function getOauthToken()
    {
        // access current profile settings
        $settings = $this->getSettings();

        if (empty($settings['_oauth']['access_token']) || empty($settings['_oauth']['expiry_ts'])) {
            // the token should be obtained from scratch
            return $this->renewOauthToken();
        }

        if ($settings['_oauth']['expiry_ts'] < time()) {
            // the token should be renewed
            return $this->renewOauthToken($refresh = true);
        }

        // return the supposingly active token
        return (string) $settings['_oauth']['access_token'];
    }

    /**
     * Makes an API request with TTLock to get and save the OAuth token for any HTTP request.
     * It can either get a new access token, or it can refresh an existing access token.
     * 
     * @param   bool    $refresh    True to refresh an existing token, false to obtain a new one.
     * 
     * @return  string              An active OAuth (Bearer) token.
     * 
     * @throws  Exception
     * 
     * @link    https://euopen.ttlock.com/document/doc?urlName=cloud%2Foauth2%2FgetAccessTokenEn.html
     * @link    https://euopen.ttlock.com/document/doc?urlName=cloud%2Foauth2%2FrefreshAccessTokenEn.html
     */
    private function renewOauthToken(bool $refresh = false)
    {
        // access current profile settings
        $settings = $this->getSettings();

        if (empty($settings['client_id']) || empty($settings['client_secret']) || empty($settings['username']) || empty($settings['password'])) {
            throw new Exception('Missing integration profile credentials (settings).', 500);
        }

        // build request data
        if ($refresh === true && !empty($settings['_oauth']['refresh_token'])) {
            // refresh an existing token
            $data = [
                'client_id' => $settings['client_id'],
                'client_secret' => $settings['client_secret'],
                'grant_type' => 'refresh_token',
                'refresh_token' => $settings['_oauth']['refresh_token'],
            ];
        } else {
            // get a new token
            $data = [
                'client_id' => $settings['client_id'],
                'client_secret' => $settings['client_secret'],
                'username' => $settings['username'],
                'password' => md5($settings['password']),
            ];
        }

        // exchange the settings to obtain the OAuth token details
        $response = (new JHttp)->post('https://euapi.ttlock.com/oauth2/token', http_build_query($data), ['ContentType' => 'application/x-www-form-urlencoded'], 10);

        // obtain the response data
        $responseData = (array) json_decode((string) $response->body, true);

        if ($response->code != 200) {
            // an error occurred
            throw new Exception($response->body ?: 'OAuth token error.', $response->code);
        }

        if (empty($responseData['access_token'])) {
            // invalid response
            throw new Exception($responseData['errmsg'] ?? $response->body ?: 'Generic response error.', 500);
        }

        // calculate and set the token expiration timestamp
        $responseData['expiry_ts'] = strtotime(sprintf('+%d seconds', (int) ($responseData['expires_in'] ?? 0)));

        // inject OAuth details within the current integration settings
        $settings['_oauth'] = $responseData;

        // update integration record settings
        $this->setProfileRecordProp('settings', $settings);

        // store integration record settings
        VBODooraccessFactory::getInstance()->saveIntegrationRecord($this, ['settings' => $this->getSettings()]);

        // return the current access token
        return $responseData['access_token'];
    }
}
