????

Your IP : 216.73.217.111


Current Path : /proc/self/root/home/arabianr/public_html/wp-content/plugins/simplybook/app/Http/
Upload File :
Current File : //proc/self/root/home/arabianr/public_html/wp-content/plugins/simplybook/app/Http/ApiClient.php

<?php

namespace SimplyBook\Http;

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

use Carbon\Carbon;
use SimplyBook\Traits\LegacyLoad;
use SimplyBook\Traits\LegacySave;
use SimplyBook\Traits\HasLogging;
use SimplyBook\Support\Helpers\Event;
use SimplyBook\Support\Helpers\Storage;
use SimplyBook\Http\DTO\ApiResponseDTO;
use SimplyBook\Exceptions\ApiException;
use SimplyBook\Traits\HasTokenManagement;
use SimplyBook\Traits\HasAllowlistControl;
use SimplyBook\Services\CallbackUrlService;
use SimplyBook\Exceptions\RestDataException;
use SimplyBook\Services\CreateAccountService;
use SimplyBook\Support\Builders\CompanyBuilder;
use SimplyBook\Services\Entities\CompanyInfoService;
use SimplyBook\Support\Helpers\Storages\EnvironmentConfig;

/**
 * @todo Refactor this to a proper Client (jira: NL14RSP2-6)
 */
class ApiClient
{
    use LegacyLoad;
    use LegacySave;
    use HasTokenManagement;
    use HasLogging;
    use HasAllowlistControl;

    protected EnvironmentConfig $env;

    protected CreateAccountService $createAccountService;

    protected CallbackUrlService $callbackUrlService;

    /**
     * Flag to use during onboarding. Will help us recognize if we are in the
     * middle of the onboarding process.
     */
    private bool $duringOnboardingFlag = false;

    /**
     * Key for the {@see authenticationFailedFlag} property.
     */
    protected string $authenticationFailedFlagKey = 'simplybook_authentication_failed_flag';

    /**
     * Flag to use when the authentication failed indefinitely. This is used to
     * prevent us retrying again and again. This flag is possibly true when a
     * refresh token is outdated AND the user has changed their password.
     */
    protected bool $authenticationFailedFlag = false;

    protected string $_commonCacheKey = '_v13';
    protected array $_avLanguages = [
        'en', 'fr', 'es', 'de', 'ru', 'pl', 'it', 'uk', 'zh', 'cn', 'ko', 'ja', 'pt', 'br', 'nl'
    ];

    /**
     * Construct is executed on plugins_loaded on purpose. This way even
     * visitors can refresh invalid tokens.
     *
     * @throws \LogicException For developers.
     */
    public function __construct(EnvironmentConfig $env, CreateAccountService $createAccountService, CallbackUrlService $callbackUrlService)
    {
        $this->env = $env;
        $this->createAccountService = $createAccountService;
        $this->callbackUrlService = $callbackUrlService;

        if (get_option($this->authenticationFailedFlagKey)) {
            $this->handleFailedAuthentication();
            return;
        }

        // Refresh admin token if needed
        if (!empty($this->getToken('admin')) && !$this->tokenIsValid('admin')) {
            $this->refresh_token('admin');
        }
    }

    /**
     * Helper method for easy access to the authentication failed flag. Can be
     * useful if somewhere in the App this value is needed. For example
     * {@see \SimplyBook\Features\TaskManagement\Tasks\FailedAuthenticationTask}
     */
    public function authenticationHasFailed(): bool
    {
        return $this->authenticationFailedFlag;
    }

    /**
     * Handle failed authentication. Sets the authentication failed flag to
     * true and dispatches the event on init.
     */
    public function handleFailedAuthentication(): void
    {
        $this->authenticationFailedFlag = true;

        // Dispatch after plugins_loaded so Event can be listened to
        add_action('init', function() {
            Event::dispatch(Event::AUTH_FAILED);
        });
    }

    /**
     * Clear the authentication failed flag. This is used when the user has
     * successfully authenticated again. Currently used after successfully
     * logging in with the sign in modal.
     */
    public function clearFailedAuthenticationFlag(): void
    {
        $this->authenticationFailedFlag = false;
        delete_option($this->authenticationFailedFlagKey);
    }

    /**
     * Set the during onboarding flag
     */
    public function setDuringOnboardingFlag(bool $flag): ApiClient
    {
        $this->duringOnboardingFlag = $flag;
        return $this;
    }

    /**
     * Check if an admin token exists, which indicates the company is registered,
     * and we can make authenticated API calls.
     *
     * Cache duration:
     * - When onboarding completed: 24 hours (stable state)
     * - During onboarding: 10 minutes (more frequent checks)
     * - When no token: 1 minute
     */
    public function company_registration_complete(): bool
    {
        $cacheName = 'company_registration_complete';
        $cacheValue = wp_cache_get($cacheName, 'simplybook', false, $found);

        if ($found) {
            return (bool) $cacheValue;
        }

        // Check if admin token exists
        if ( !$this->getToken('admin') ) {
            $companyRegistrationStartTime = get_option('simplybook_company_registration_start_time', 0);

            $oneHourAgo = Carbon::now()->subHour();
            $companyRegistrationStartedAt = Carbon::createFromTimestamp($companyRegistrationStartTime);

            // Registration was more than 1h ago. Clear and try again.
            if ($companyRegistrationStartedAt->isBefore($oneHourAgo)) {
                $this->delete_company_login();
            }

            wp_cache_set($cacheName, false, 'simplybook', MINUTE_IN_SECONDS);
            return false;
        }

        // If the token exists, and the onboarding is completed, we know
        // the company registration is complete, and we can cache for a longer
        // time.
        $isOnboardingCompleted = (get_option('simplybook_onboarding_completed', false) !== false);
        $cacheTime = MINUTE_IN_SECONDS * 10;
        if ($isOnboardingCompleted) {
            $cacheTime = DAY_IN_SECONDS;
        }

        wp_cache_set($cacheName, true, 'simplybook', $cacheTime);
        return true;
    }

    /**
     * Build the endpoint
     */
    public function endpoint(string $path, string $companyDomain = '', bool $secondVersion = true): string
    {
        $base = 'https://user-api' . ($secondVersion ? '-v2.' : '.');

        // Prevent fields config from being loaded before the init hook. In this
        // case we do not need to validate by default.
        $validateBasedOnDomainConfig = (did_action('init') > 0);

        $domain = $companyDomain ?: $this->get_domain($validateBasedOnDomainConfig);

        return $base . $domain . '/' . $path;
    }

    /**
     * Get a direct login to simplybook.me
     *
     * @return string
     */
    public function get_login_url(): string {
        if ( !$this->company_registration_complete() ) {
            return '';
        }
        //we can't cache this url, because it expires after use.
        //but we want to prevent using it too much, limit request to once per 20 minutes, which is the max of three times/hour.
        $login_url_request_count = get_transient('simplybook_login_url_request_count');
        if ( !$login_url_request_count ) {
            $login_url_request_count = 0;
        }

        $login_url_first_request_time = get_transient('simplybook_login_url_first_request_time');
        $expiration = HOUR_IN_SECONDS;
        if ( $login_url_request_count>=3 ) {
            return '';
        }

        $time_passed_since_first_request = time() - $login_url_first_request_time;
        $remaining_expiration = $expiration - $time_passed_since_first_request;
        set_transient('simplybook_login_url_request_count', $login_url_request_count + 1, $remaining_expiration);
        if ( $login_url_request_count===1 ) {
            set_transient('simplybook_login_url_first_request_time', time(), $remaining_expiration);
        }

        $response = $this->api_call("admin/auth/create-login-hash", [], 'POST');
        if (isset($response['login_url'])) {
            return esc_url_raw($response['login_url']);
        }

        return '';
    }

    /**
     * Method call the create-login-hash endpoint on the SimplyBook API.
     * @throws \Exception When the company registration is not complete or when
     * the response is not as expected.
     */
    public function createLoginHash(): array
    {
        if ( !$this->company_registration_complete() ) {
            throw new \Exception('Company registration is not complete');
        }

        $response = $this->api_call("admin/auth/create-login-hash", [], 'POST');
        if (!isset($response['login_url'])) {
            throw new \Exception('Login URL not found');
        }

        Event::dispatch(EVENT::NAVIGATE_TO_SIMPLYBOOK);
        return $response;
    }

    /**
     * Get headers for an API call
     *
     * @param bool $include_token // optional, default false
     * @param string $token_type
     *
     * @return array
     */
    protected function get_headers( bool $include_token = false, string $token_type = 'public' ): array {
        $token_type = in_array($token_type, ['public', 'admin']) ? $token_type : 'public';
        $headers = array(
            'Content-Type'  => 'application/json',
            'User-Agent' => $this->getRequestUserAgent(),
        );

        if ( $include_token ) {
            $token = $this->getToken($token_type);
            if ( empty($token) && $token_type === 'admin' ) {
                $this->refresh_token('admin');
            }
            $headers['X-Token'] = $token;
            $headers['X-Company-Login' ] = $this->get_company_login();
        }

        return $headers;
    }

    /**
     * Refresh the admin token
     */
    public function refresh_token(string $type = 'admin'): void
    {
        if ($type !== 'admin') {
            return;
        }

        if ($this->isRefreshLocked($type)) {
            return;
        }

        $refresh_token = $this->getToken($type, true);
        if (empty($refresh_token)) {
            $this->releaseRefreshLock($type);
            $this->automaticAuthenticationFallback($type);
            return;
        }

        if ($this->tokenIsValid($type)) {
            $this->releaseRefreshLock($type);
            return;
        }

        // Invalidate the one-time use token as we are about to use it for
        // refreshing the token. This prevents re-use.
        $this->updateToken('', $type, true);

        $this->refreshAdminToken($refresh_token);

        $this->releaseRefreshLock($type);
    }

    /**
     * Refresh admin token directly with SimplyBook
     */
    private function refreshAdminToken(string $refresh_token): void
    {
        $data = [
            'refresh_token' => $refresh_token,
            'company' => $this->get_company_login(),
        ];

        $request = wp_remote_post($this->endpoint('admin/auth/refresh-token'), [
            'headers' => $this->get_headers(false),
            'timeout' => 15,
            'sslverify' => true,
            'body' => json_encode($data),
        ]);

        $response_code = wp_remote_retrieve_response_code($request);

        if ($response_code === 401) {
            $this->automaticAuthenticationFallback('admin');
            return;
        }

        if (!is_wp_error($request)) {
            $request = json_decode(wp_remote_retrieve_body($request));

            if (isset($request->token) && isset($request->refresh_token)) {
                delete_option('simplybook_token_error');
                $this->updateToken($request->token, 'admin');
                $this->updateToken($request->refresh_token, 'admin', true);
                update_option('simplybook_refresh_company_token_expiration', time() + ($request->expires_in ?? 3600));
                Event::dispatch(Event::AUTH_SUCCEEDED);
            } else {
                $this->log("Error during admin token refresh");
            }
        } else {
            $this->log("Error during admin token refresh: " . $request->get_error_message());
        }
    }

    /**
     * Check if the refresh function is locked for this type. Method also
     * sets the lock for 10 seconds if it is not already set.
     */
    private function isRefreshLocked(string $type): bool
    {
        $lockKey = "simplybook_refresh_lock_{$type}";
        if (get_transient($lockKey)) {
            return true;
        }

        set_transient($lockKey, true, 10);
        return false;
    }

    /**
     * Release the refresh lock for this type.
     */
    private function releaseRefreshLock(string $type): void
    {
        $lockKey = "simplybook_refresh_lock_{$type}";
        delete_transient($lockKey);
    }

    /**
     * Method is used as a fallback mechanism when the refresh token is invalid.
     * This can happen when the user changes their password and the refresh
     * token is invalidated. In this case we need to re-authenticate the
     * user. Currently used when refreshing a token results in a 401
     * error on when decrypting an existing token fails.
     */
    private function automaticAuthenticationFallback(string $type): void
    {
        // Company login can be empty for fresh accounts
        if ($this->authenticationFailedFlag || empty($this->get_company_login(false))) {
            $this->releaseRefreshLock($type);
            return; // Dont even try (again).
        }

        $validateBasedOnDomainConfig = did_action('init');
        $domain = $this->get_domain((bool) $validateBasedOnDomainConfig);

        $companyData = $this->get_company();
        $sanitizedCompany = (new CompanyBuilder())->buildFromArray($companyData);

        try {
            $response = $this->authenticateExistingUser(
                $domain,
                $this->get_company_login(),
                $sanitizedCompany->userLogin,
                $this->decryptString($sanitizedCompany->password)
            );
        } catch (\Exception $e) {
            Event::dispatch(Event::AUTH_FAILED);
            // Their password probably changed. Stop trying to refresh.
            update_option($this->authenticationFailedFlagKey, true);
            $this->authenticationFailedFlag = true;
            $this->log('Error during token refresh: ' . $e->getMessage());
            return;
        }

        $responseStorage = new Storage($response);
        $this->setDuringOnboardingFlag(true); // Allows saving stale fields
        $this->saveAuthenticationData(
            $responseStorage->getString('token'),
            $responseStorage->getString('refresh_token'),
            $domain,
            $this->get_company_login(),
            $responseStorage->getInt('company_id'),
        );

        Event::dispatch(Event::AUTH_SUCCEEDED);

        $this->setDuringOnboardingFlag(false); // Revert previous action
        $this->releaseRefreshLock($type);
    }

    /**
     * Get locale, based on current user's preference, with fallback to site locale, and fallback to 'en' if not existing in available languages
     *
     * @return string
     */
    public function get_locale(): string {
        $available_languages = $this->_avLanguages;
        $user_locale = get_user_locale();
        $user_locale = substr($user_locale, 0, 2);
        if ( in_array( $user_locale, $available_languages ) ) {
            return $user_locale;
        }

        $site_locale = get_locale();
        $site_locale = substr($site_locale, 0, 2);
        if ( in_array( $site_locale, $available_languages ) ) {
            return $site_locale;
        }

        return 'en';
    }

    /**
     * Get company login and generate one if it does not exist
     * @return string
     */
    public function get_company_login(bool $create = true): string
    {
        $login = get_option('simplybook_company_login', '');
        if ( !empty($login) ) {
            return $login;
        }

        if ($create === false) {
            return ''; // Abort
        }

        //generate a random integer of 10 digits
        //we don't use random characters because of forbidden words.
        $random_int = random_int(1000000000, 9999999999);
        $login = 'rsp'.$random_int;
        update_option('simplybook_company_login', $login, false );
        return $login;
    }

    /**
     * Clear the company login, used when the company registration is never completed, possibly when the callback has failed.
     *
     * @return void
     */
    public function delete_company_login(): void {
        delete_option('simplybook_company_login');
    }



    /**
     * Check if authorization is valid and complete
     */
    public function isAuthenticated(): bool
    {
        //check if we have a token
        if (!$this->tokenIsValid('admin')) {
            $this->refresh_token('admin');
        }

        // Check if the flag is set
        if ($this->authenticationFailedFlag) {
            return false;
        }

        //check if we have a company
        if (!$this->company_registration_complete()) {
            return false;
        }

        return true;
    }

    public function reset_registration(): void
    {
        $this->delete_company_login();
        $this->clearTokens();
        delete_option('simplybook_completed_step');
    }

    /**
     * Registers a company with the API
     * @internal method can be recursive a maximum of 3 times in one minute
     * @throws ApiException
     */
    public function register_company(CompanyBuilder $company, string $captchaToken = ''): ApiResponseDTO
    {
        if ($this->adminAccessAllowed() === false) {
            throw (new ApiException(
                __('You are not authorized to do this.', 'simplybook')
            ))->setResponseCode(403);
        }

        if (get_transient('simply_book_attempt_count') > 3) {
            throw (new ApiException(
                __('Too many attempts to register company, please try again in a minute.', 'simplybook')
            ))->setResponseCode(429);
        }

        if ($company->isValid() === false) {
            throw (new ApiException(
                __('Please fill in all required fields to create an account.', 'simplybook')
            ))->setResponseCode(422);
        }

        $userAgent = $this->getRequestUserAgent();

        try {
            $this->createAccountService->createInstallationId($userAgent, false);
        } catch (\Exception $e) {
            throw (new ApiException(
                // User-friendly message during company creation flow
                __('Account creation failed, could not verify installation.', 'simplybook')
            ))->setData([
                // Remember specific createInstallationId exception message
                'message' => $e->getMessage(),
            ])->setResponseCode(500);
        }

        $companyLogin = $this->get_company_login();
        $callbackUrl = $this->callbackUrlService->getFullCallbackUrl();

        $rawResponse = $this->createAccountService->registerCompany(
            $companyLogin,
            $company->email,
            $this->decryptString($company->password),
            $company->marketingConsent,
            $callbackUrl,
            $captchaToken,
            $company->category,
            $userAgent,
        );

        $response = (object) $rawResponse['body'];

        // Response returns success
        if (isset($response->success) && $response->success) {
            return new ApiResponseDTO(true, __('Company successfully registered.', 'simplybook'), 200, []);
        }

        // We generate a company_login dynamically, but because SimplyBook has
        // very strict checks this company_login might be invalid. In this case
        // we delete the company_login and try again.
        if (
            isset($response->data->company_login) &&
            (
                in_array('The field contains illegal words', $response->data->company_login)
                || in_array('login_reserved', $response->data->company_login)
            )
        ) {
            delete_option('simplybook_company_login');
            return $this->register_company($company, $captchaToken);
        }

        throw (new ApiException(
            __('Unknown error encountered while registering your company. Please try again.', 'simplybook')
        ))->setData([
            'message' => $response->message ?? '',
            'data' => isset($response->data) ? (is_object($response->data) ? get_object_vars($response->data) : $response->data) : null,
        ])->setResponseCode(500);
    }

    /**
     * Get a timezone string
     *
     * @return string
     */
    protected function get_timezone_string(): string {
        $gmt_offset = get_option('gmt_offset');
        $timezone_string = get_option('timezone_string');
        if ($timezone_string) {
            return $timezone_string;
        } else {
            $timezone = timezone_name_from_abbr('', $gmt_offset * 3600, 0);
            if ($timezone === false) {
                // Fallback
                $timezone = 'Europe/Dublin';
            }

            return $timezone;
        }
    }

    /**
     * Get all subscription data
     */
    public function get_subscription_data(): array
    {
        if ($this->company_registration_complete() === false) {
            return [];
        }

        $cacheName = 'simplybook_subscription_data';
        $cacheValue = wp_cache_get($cacheName, 'simplybook', false, $found);

        if ($found && is_array($cacheValue)) {
            return $cacheValue;
        }

        $response = $this->api_call('admin/tariff/current', [], 'GET');

        wp_cache_set($cacheName, $response, 'simplybook', MINUTE_IN_SECONDS);
        return $response;
    }

    /**
     * Get all statistics
     */
    public function get_statistics(): array
    {
        if ($this->company_registration_complete() === false) {
            return [];
        }

        $cacheName = 'simplybook_statistics';
        $cacheValue = wp_cache_get($cacheName, 'simplybook', false, $found);

        if ($found && is_array($cacheValue)) {
            return $cacheValue;
        }

        $response = $this->api_call('admin/statistic', [], 'GET');
        if (empty($response)) {
            return [];
        }

        wp_cache_set($cacheName, $response, 'simplybook', MINUTE_IN_SECONDS);
        return $response;
    }

    /**
     * Get list of plugins with is_active and is_turned_on information
     * @return array
     */
    public function get_plugins(): array
    {
        if ( !$this->company_registration_complete() ){
            return [];
        }

        $cacheName = 'simplybook_special_feature_plugins';
        $cacheValue = wp_cache_get($cacheName, 'simplybook', false, $found);

        if ($found && is_array($cacheValue)) {
            return $cacheValue;
        }

        $response = $this->api_call('admin/plugins', [], 'GET');
        $plugins = $response['data'] ?? [];

        Event::dispatch(Event::SPECIAL_FEATURES_LOADED, $plugins);

        wp_cache_set($cacheName, $plugins, 'simplybook', MINUTE_IN_SECONDS);
        return $plugins;
    }

    /**
     * Check if a specific plugin is active
     *
     * @param string $plugin
     *
     * @return bool
     */

    public function is_plugin_active( string $plugin ): bool {
        $plugins = $this->get_plugins();
        //check if the plugin with id = $plugin has is_active = true
        foreach ( $plugins as $p ) {
            if ( $p['id'] === $plugin && $p['is_active'] ) {
                return true;
            }
        }
        return false;
    }

    /**
     * Do an API request to simplybook
     *
     * @param string $path
     * @param array $data
     * @param string $type
     * @param int $attempt
     *
     * @return array
     */
    public function api_call( string $path, array $data = [], string $type='POST', int $attempt = 1 ): array
    {
        if ($this->authenticationFailedFlag) {
            return []; // Prevent us even trying.
        }

        //for all requests to /admin/ endpoints, use the company token. Otherwise use the common token.
        $token_type = str_contains( $path, 'admin' ) ? 'admin' : 'public';

        if ( !$this->tokenIsValid($token_type) ) {
            //try to refresh
            $this->refresh_token($token_type);
            //still not valid
            if ( !$this->tokenIsValid($token_type) ) {
                $this->log("Token not valid, cannot make API call");
                return [];
            }
        }

        if ( $type === 'POST' ) {
            $response_body = wp_remote_post( $this->endpoint( $path ), array(
                'headers'   => $this->get_headers( true, $token_type ),
                'timeout'   => 15,
                'sslverify' => true,
                'body'      => json_encode( $data ),
            ) );
        } else {
            //replace %5B with [ and %5D with ]
            $args = [
                'headers' => $this->get_headers( true, $token_type ),
                $data,
            ];
            $response_body = wp_remote_get($this->endpoint( $path ), $args );
        }

        if (is_wp_error( $response_body ) ) {
            $message = "WP_Error during api call for path: $path. Error: " . $response_body->get_error_message();
            $this->log($message);

            update_option('simplybook_api_status', [
                'status' => 'error',
                'time' => time(),
                'error' => esc_sql($message),
            ]);

            return [];
        }

        $response_code = wp_remote_retrieve_response_code( $response_body );
        $response = json_decode( wp_remote_retrieve_body( $response_body ), true );

        if ( $response_code === 200 ) {
            update_option('simplybook_api_status', [
                'status' => 'success',
                'time' => time(),
            ]);
            return $response;
        }

        $message = '';
        if ( isset($response['message'])) {
            $message = $response['message'];
        } elseif (isset($response->message)){
            $message = $response->message;
        }

        if ( $attempt === 1 && str_contains( $message, 'Token Expired')) {
            //invalid token, refresh.
            $this->refresh_token($token_type);
            return $this->api_call( $path, $data, $type, $attempt + 1 );
        }

        $this->log("Error during $path retrieval: ".$message);

        /* phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r */
        $msg = "response code: " . $response_code . ", response body: ".print_r($response_body,true);

        $this->_log($msg);
        update_option('simplybook_api_status', array(
            'status' => 'error',
            'error' => esc_sql($msg),
            'time' => time(),
        ) );

        return [];
    }

    /**
     * Check if we have a valid API Connection
     */
    public function checkApiConnection(): bool
    {
        $response = wp_remote_get($this->endpoint('admin'));

        // if response 401 and valid json - api is working
        if (wp_remote_retrieve_response_code($response) == 401) {
            $result = wp_remote_retrieve_body($response);
            $result = json_decode($result, true);
            if ($result && isset($result['code']) && $result['code'] == 401) {
                return true;
            }
        }

        return false;
    }

    /**
     * @todo - maybe this can be an Entity in the future?
     */
    public function getCategories(bool $onlyValues = false): array
    {
        $cacheKey = 'sb_plugin_categories' . $this->_commonCacheKey;
        if (($result = get_transient($cacheKey)) !== false) {
            return $result['data'];
        }

        $response = $this->api_call('admin/categories', [], 'GET');
        $result = $response['data'] ?? [];

        return $onlyValues ? array_values($result) : $result;
    }

    /**
     * @todo - maybe this can be an Entity in the future?
     */
    public function getLocations(bool $onlyValues = false): array
    {
        $cacheKey = 'sb_plugin_locations' . $this->_commonCacheKey;
        if (($result = get_transient($cacheKey)) !== false) {
            return $result['data'];
        }

        $response = $this->api_call('admin/locations', [], 'GET');
        $result = $response['data'] ?? [];

        return $onlyValues ? array_values($result) : $result;
    }

    /**
     * @todo - maybe this can be an Entity in the future?
     */
    public function getSpecialFeatureList(): array
    {
        $cacheKey = 'sb_plugin_plugins' . $this->_commonCacheKey;
        if (($result = get_transient($cacheKey)) !== false) {
            return $result['data'];
        }

        $response = $this->api_call('admin/plugins', [], 'GET');
        return $response['data'] ?? [];
    }

    /**
     * Method is used to check if the special feature related to the plugin key is
     * enabled or not.
     * @uses wp_cache_set(), wp_cache_get()
     */
    public function isSpecialFeatureEnabled(string $featureKey): bool
    {
        $cacheName = 'simplybook-feature-enabled-' . trim($featureKey);
        $cacheValue = wp_cache_get($cacheName, 'simplybook', false, $found);

        if ($found) {
            return (bool) $cacheValue;
        }

        $features = $this->getSpecialFeatureList();
        if (empty($features)) {
            wp_cache_set($cacheName, false, 'simplybook', MINUTE_IN_SECONDS);
            return false;
        }

        $isActive = false;
        foreach ($features as $feature) {
            if (!isset($feature['key']) || ($feature['key'] !== $featureKey)) {
                continue;
            }

            $isActive = (bool) $feature['is_active'];
            break;
        }

        wp_cache_set($cacheName, $isActive, 'simplybook', MINUTE_IN_SECONDS);
        return $isActive;
    }

    /**
     * @param mixed $error
     */
    protected function _log($error): void
    {
        // Return if WP_DEBUG is not enabled
        if ( !defined('WP_DEBUG') || !WP_DEBUG ) {
            return;
        }

        /* phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace */
        $fileTrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
        $last4 = array_slice($fileTrace, 0, 4);

        if(!is_string($error)){
            @ob_start();
            /* phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_dump */
            var_dump($error);
            $error = @ob_get_clean();
        }

        /* phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date */
        $error = date('Y-m-d H:i:s') . ' ' . $error . "\n";
        $error .= "\n\n" . implode("\n", array_map(function ($item) {
                return $item['file'] . ':' . $item['line'];
            }, $last4));
        $error .= "\n----------------------\n\n\n";

        /* phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log */
        error_log($error);
    }

    /**
     * Authenticate an existing user with the API by company login, user login
     * and password. If successful, the token is stored in the options.
     *
     * @return array Includes at least keys: 'token', 'refresh_token' & 'domain'
     * @throws \Exception|RestDataException
     */
    public function authenticateExistingUser(string $companyDomain, string $companyLogin, string $userLogin, string $userPassword): array
    {
        $payload = json_encode([
            'company' => $companyLogin,
            'login' => $userLogin,
            'password' => $userPassword,
        ]);

        $endpoint = $this->endpoint('admin/auth', $companyDomain);
        $response = wp_safe_remote_post($endpoint, [
            'headers' => $this->get_headers(),
            'timeout' => 15,
            'sslverify' => true,
            'body' => $payload,
        ]);

        if (is_wp_error($response)) {
            $errorMessage = $response->get_error_message();
            $userMessage = __('Authentication failed, please try again.', 'simplybook');

            if (stripos($errorMessage, 'A valid URL was not provided') !== false) {
                $userMessage = __('Please enter a valid domain.', 'simplybook');
            }

            throw (new RestDataException($userMessage))->setResponseCode(400)->setData([
                'error_code' => $response->get_error_code(),
                'error_message' => $errorMessage,
            ]);
        }

        $responseCode = wp_remote_retrieve_response_code($response);
        if ($responseCode != 200) {
            $this->throwSpecificLoginErrorResponse($responseCode, $response);
        }

        $responseBody = json_decode(wp_remote_retrieve_body($response), true);
        if (!is_array($responseBody) || !isset($responseBody['token'], $responseBody['refresh_token'], $responseBody['domain'])) {
            throw (new RestDataException(
                __('Login failed! Please try again later.', 'simplybook')
            ))->setResponseCode(500)->setData([
                'response_code' => $responseCode,
                'response_message' => __('Invalid response from SimplyBook.me', 'simplybook'),
            ]);
        }

        if (isset($responseBody['require2fa'], $responseBody['auth_session_id']) && ($responseBody['require2fa'] === true)) {
            throw (new RestDataException('Two FA Required'))
                ->setResponseCode(200)
                ->setData([
                    'require2fa' => true,
                    'auth_session_id' => $responseBody['auth_session_id'],
                    'company_login' => $companyLogin,
                    'user_login' => $userLogin,
                    'domain' => $companyDomain,
                    'allowed2fa_providers' => $this->get2FaProvidersWithLabel(($responseBody['allowed2fa_providers'] ?? ['ga'])),
                ]);
        }

        return $responseBody;
    }

    /**
     * Process two-factor authentication with the API. If successful, the token is stored in the options.
     * @throws \Exception|RestDataException
     */
    public function processTwoFaAuthenticationRequest(string $companyDomain, string $companyLogin, string $sessionId, string $twoFaType, string $twoFaCode): array
    {
        $payload = json_encode([
            'company' => $companyLogin,
            'session_id' => $sessionId,
            'code' => $twoFaCode,
            'type' => $twoFaType,
        ]);

        $endpoint = $this->endpoint('admin/auth/2fa', $companyDomain);
        $response = wp_safe_remote_post($endpoint, [
            'headers' => $this->get_headers(),
            'timeout' => 15,
            'sslverify' => true,
            'body' => $payload,
        ]);

        if (is_wp_error($response)) {
            throw new \Exception($response->get_error_code() . " ". $response->get_error_message());
        }

        $responseCode = wp_remote_retrieve_response_code($response);
        if ($responseCode != 200) {
            $this->throwSpecificLoginErrorResponse($responseCode, $response, true);
        }

        $responseBody = json_decode(wp_remote_retrieve_body($response), true);
        if (!is_array($responseBody) || !isset($responseBody['token'])) {
            throw (new RestDataException(
                __('Two factor authentication failed! Please try again later.', 'simplybook')
            ))->setData([
                'response_code' => $responseCode,
                'response_message' => __('Invalid 2FA response from SimplyBook.me', 'simplybook'),
            ]);
        }

        return $responseBody;
    }

    /**
     * Handles api related login errors based on the response code and if it is
     * a 2FA call. When there is no specific case throw a RestDataException with
     * a more generic message.
     *
     * Codes:
     * 400 = Wrong login or 2FA code
     * 403 = Too many attempts
     * 404 = SB generated a 404 page with the given company login
     * Else generic failed attempt message
     *
     * @throws RestDataException
     */
    public function throwSpecificLoginErrorResponse(int $responseCode, ?array $response = [], bool $isTwoFactorAuth = false): void
    {
        $response = (array) $response; // Ensure we have an array
        $responseBody = json_decode(wp_remote_retrieve_body($response), true);

        $responseMessage = __('No error received from remote.', 'simplybook');
        if (is_array($responseBody) && !empty($responseBody['message'])) {
            $responseMessage = $responseBody['message'];
        }

        switch ($responseCode) {
            case 400:
                $message = __('Invalid login or password, please try again.', 'simplybook');
                if ($isTwoFactorAuth) {
                    $message = __('Incorrect 2FA authentication code, please try again.', 'simplybook');
                }
                break;
            case 403:
                $message = __('Too many login attempts. Verify your credentials and try again in a few minutes.', 'simplybook');
                break;
            case 404:
                $message = __("Could not find a company associated with that company login.", 'simplybook');
                break;
            default:
                $message = __('Authentication failed, please verify your credentials.', 'simplybook');
        }

        $exception = new RestDataException($message);
        $exception->setData([
            'response_code' => $responseCode,
            'response_message' => $responseMessage,
        ]);

        // 2Fa uses request() on client side thus needs a 200 response code.
        // Default is 500 to end up in the catch() function.
        $exception->setResponseCode($isTwoFactorAuth ? 200 : 500);

        throw $exception;
    }

    /**
     * Request to send an SMS code to the user for two-factor authentication.
     * @throws \Exception
     */
    public function requestSmsForUser(string $companyDomain, string $companyLogin, string $sessionId): bool
    {
        $endpoint = add_query_arg([
            'company' => $companyLogin,
            'session_id' => $sessionId,
        ], $this->endpoint('/admin/auth/sms', $companyDomain));

        $response = wp_safe_remote_get($endpoint, [
            'headers' => $this->get_headers(),
            'timeout' => 15,
            'sslverify' => true,
        ]);

        if (is_wp_error($response)) {
            throw new \Exception($response->get_error_message());
        }

        $responseBody = json_decode(wp_remote_retrieve_body($response), true);
        $responseCode = wp_remote_retrieve_response_code($response);
        if ($responseCode != 200) {
            throw new \Exception($responseBody['message'] ?? 'SMS request failed');
        }

        return true; // code send.
    }

    /**
     * Save the authentication data given as parameters. This method is used
     * after a successful authentication process. For example after
     * {@see authenticateExistingUser} & {@see processTwoFaAuthenticationRequest}.
     * This is used in {@see OnboardingController}
     */
    public function saveAuthenticationData(string $token, string $refreshToken, string $companyDomain, string $companyLogin, int $companyId, string $tokenType = 'admin'): void
    {
        $this->updateToken($token, $tokenType);
        $this->updateToken($refreshToken, $tokenType, true );

        $this->update_option('domain', $companyDomain, $this->duringOnboardingFlag, [
            'type' => 'hidden',
        ]);
        $this->update_option('company_id', $companyId, $this->duringOnboardingFlag, [
            'type' => 'hidden',
        ]);

        update_option('simplybook_refresh_company_token_expiration', time() + 3600);

        update_option('simplybook_company_login', $companyLogin);
        update_option('simplybook_company_registration_start_time', time());
    }

    /**
     * Return given providers with their labels. Can be used to parse the
     * 'allowed2fa_providers' key in a response from the API.
     */
    private function get2FaProvidersWithLabel(array $providerKeys): array
    {
        $providerLabels = [
            'ga'  => __('Google Authenticator', 'simplybook'),
            'sms' => __('SMS', 'simplybook'),
        ];

        $allowedProviders = [];
        foreach ($providerKeys as $provider) {
            $allowedProviders[$provider] = ($providerLabels[$provider] ??
                __('Unknown 2FA provider', 'simplybook'));
        }

        return $allowedProviders;
    }

    /**
     * Get the list of themes available for the company
     * @throws \Exception
     */
    public function getThemeList(): array
    {
        if ($this->authenticationFailedFlag) {
            return []; // Prevent us even trying.
        }

        $cacheName = 'simplybook_theme_list';
        $cacheValue = wp_cache_get($cacheName, 'simplybook', false, $found);

        if ($found && is_array($cacheValue)) {
            return $cacheValue;
        }

        $fallback = [
            'created_at_utc' => Carbon::now('UTC')->subDays(3)->toDateTimeString(),
            'themes' => [],
        ];

        $cachedOption = get_option('simplybook_cached_theme_list', $fallback);
        $cachedOptionCreatedAt = Carbon::parse($cachedOption['created_at_utc']);
        $cachedOptionIsValid = $cachedOptionCreatedAt->isAfter(
            Carbon::now('UTC')->subDays(2) // Cache is valid for 2 days
        );

        if ($cachedOptionIsValid) {
            return $cachedOption;
        }

        $response = $this->post('public', json_encode([
            'jsonrpc' => '2.0',
            'method' => 'getThemeList',
            'id' => 1,
        ]));

        $data['created_at_utc'] = Carbon::now('UTC')->toDateTimeString();
        $data['themes'] = $response['result'] ?? [];

        update_option('simplybook_cached_theme_list', $data);
        wp_cache_add($cacheName, $data, 'simplybook', (2 * DAY_IN_SECONDS));
        return $data;
    }

    /**
     * Get the timeline setting options that are available for the company
     */
    public function getTimelineList(): array
    {
        if ($this->authenticationFailedFlag) {
            return []; // Prevent us even trying.
        }

        $cacheName = 'simplybook_timeline_list';
        $cacheValue = wp_cache_get($cacheName, 'simplybook', false, $found);

        if ($found && is_array($cacheValue)) {
            return $cacheValue;
        }

        $fallback = [
            'created_at_utc' => Carbon::now('UTC')->subDays(3)->toDateTimeString(),
            'list' => [],
        ];

        $cachedOption = get_option('simplybook_cached_timeline_list', $fallback);
        $createdAtUtc = ($cachedOption['created_at_utc'] ?? $fallback['created_at_utc']);

        $cachedOptionCreatedAt = Carbon::parse($createdAtUtc);
        $cachedOptionIsValid = is_array($cachedOption) && $cachedOptionCreatedAt->isAfter(
            Carbon::now('UTC')->subDays(2) // Cache is valid for 2 days
        );

        if ($cachedOptionIsValid) {
            return $cachedOption;
        }

        $response = $this->post('public', json_encode([
            'jsonrpc' => '2.0',
            'method' => 'getTimelineList',
            'id' => 1,
        ]));

        $data['created_at_utc'] = Carbon::now('UTC')->toDateTimeString();
        $data['list'] = $response['result'] ?? [];

        update_option('simplybook_cached_timeline_list', $data);
        wp_cache_add($cacheName, $data, 'simplybook', (2 * DAY_IN_SECONDS));
        return $data;
    }

    /**
     * Get the company info without caching. Caching should be done by the
     * consumer of this method, in this case {@see CompanyInfoService}.
     */
    public function getCompanyInfo(): array
    {
        if ($this->authenticationFailedFlag) {
            return []; // Prevent us even trying.
        }

        try {
            $response = $this->get('admin/company/info');
        } catch (\Exception $e) {
            return [];
        }

        return $response;
    }

    /**
     * EXTENDIFY_PARTNER_ID will contain the required value if WordPress is
     * configured using Extendify. Otherwise, use default 'wp'.
     */
    private function getReferrer(): string
    {
        return (defined('EXTENDIFY_PARTNER_ID') ? constant('EXTENDIFY_PARTNER_ID') : 'wp');
    }

    /**
     * Get the user agent for the API requests.
     *
     * @example format SimplyBookPlugin/3.2.1 (WordPress/6.5.3; PHP/7.4.33; ref: wp; +https://example.com)
     * @example format SimplyBookPlugin/3.2.1 (WordPress/6.5.3; PHP/7.4.33; ref: EXTENDIFY_PARTNER_ID; +https://example.com)
     */
    private function getRequestUserAgent(): string
    {
        return "SimplyBookPlugin/" . $this->env->getString('plugin.version') . " (WordPress/" . get_bloginfo('version') . "; PHP/" . phpversion() . "; ref: " . $this->getReferrer() . "; +" . site_url() . ")";
    }

    /**
     * Helper method to easily do a GET request to a specific endpoint on the
     * SimplyBook.me API.
     * @throws \Exception
     */
    public function get(string $endpoint): array
    {
        if ($this->company_registration_complete() === false) {
            throw new \Exception('Company registration is not complete.');
        }

        $cacheValue = $this->getRequestCache($endpoint);
        if (!empty($cacheValue) && is_array($cacheValue)) {
            return $cacheValue;
        }

        $response = $this->request('GET', $endpoint);

        $this->setRequestCache($endpoint, $response);

        return $response;
    }

    /**
     * Helper method to easily do a PUT request to a specific endpoint on the
     * SimplyBook.me API.
     * @throws RestDataException
     */
    public function put(string $endpoint, string $payload): array
    {
        return $this->request('PUT', $endpoint, $payload);
    }

    /**
     * Helper method to easily do a POST request to a specific endpoint on the
     * SimplyBook.me API.
     * @throws RestDataException
     */
    public function post(string $endpoint, string $payload): array
    {
        return $this->request('POST', $endpoint, $payload);
    }

    /**
     * Helper method to easily do a DELETE request to a specific endpoint on the
     * SimplyBook.me API.
     * @throws RestDataException
     */
    public function delete(string $endpoint): array
    {
        return $this->request('DELETE', $endpoint);
    }

    /**
     * Helper method to easily do a request to a specific endpoint on the
     * SimplyBook.me API.
     * @throws RestDataException
     */
    public function request(string $method, string $endpoint, string $payload = ''): array
    {
        $requestType = str_contains($endpoint, 'admin') ? 'admin' : 'public';

        $requestArgs = [
            'method' => $method,
            'headers' => $this->get_headers(true, $requestType),
            'timeout' => 15,
            'sslverify' => true,
        ];

        if (!empty($payload)) {
            $requestArgs['body'] = $payload;
        }

        // For JSON RPC endpoints (endpoint is exactly 'public'), use v1 API
        $useV2 = ($endpoint !== 'public');

        $response = wp_safe_remote_request(
            $this->endpoint($endpoint, '', $useV2),
            $requestArgs
        );

        // Ensure we get fresh data next time we do a request to this endpoint.
        $this->clearRequestCache($endpoint);

        if (is_wp_error($response)) {
            throw (new RestDataException($response->get_error_message()))
                ->setResponseCode($response->get_error_code())
                ->setData($response->get_error_data());
        }

        $responseCode = wp_remote_retrieve_response_code($response);
        $responseMessage = wp_remote_retrieve_response_message($response);
        $responseBody = wp_remote_retrieve_body($response);

        $responseData = json_decode($responseBody, true);
        $jsonError = json_last_error();

        if ($jsonError !== JSON_ERROR_NONE) {
            $responseData = [];
        }

        if ($responseCode < 200 || $responseCode >= 300) {
            throw (new RestDataException($responseMessage))
                ->setResponseCode($responseCode)
                ->setData($responseData);
        }

        return $responseData;
    }

    /**
     * Clear the request cache for a specific endpoint. This is used to ensure
     * we get fresh data from the API.
     * @uses wp_cache_delete
     */
    private function clearRequestCache(string $endpoint): void
    {
        wp_cache_delete($this->requestKey($endpoint), 'simplybook');
    }

    /**
     * Set the request cache for a specific endpoint. This is used to cache the
     * response data for a specific endpoint.
     * @uses wp_cache_set
     */
    private function setRequestCache(string $endpoint, array $data): void
    {
        wp_cache_set($this->requestKey($endpoint), $data, 'simplybook', MINUTE_IN_SECONDS);
    }

    /**
     * Get the request cache for a specific endpoint. This is used to retrieve
     * cached data for a specific endpoint.
     * @uses wp_cache_get
     * @return false|mixed
     */
    private function getRequestCache(string $endpoint)
    {
        return wp_cache_get($this->requestKey($endpoint), 'simplybook');
    }

    /**
     * Generate a unique cache key for a specific endpoint. This is used to
     * store and retrieve cached data for a specific endpoint.
     */
    private function requestKey(string $endpoint): string
    {
        return 'simplybook/' . $endpoint;
    }
}