[Back]
<?php
class OsOTPHelper {

	private static int $max_verification_attempts = 10;  // Max attempts per OTP
    private static int $max_generation_attempts_per_hour = 60;    // Max OTP generations per hour
    public static int $otp_expires_in_minutes = 10;    // Max OTP generations per hour
    public static int $verification_expires_in_minutes = 30;

    public static function create_verification_token($contact_value, $contact_type, $via = 'otp') : string {
        $payload_data = [
            'contact_value' => $contact_value,
            'contact_type' => $contact_type,
            'verified_via' => $via,
            'exp' => time() + (self::$verification_expires_in_minutes * 60),
            'iat' => time()
        ];

        $payload = base64_encode(json_encode($payload_data));
        $signature = hash_hmac('sha256', $payload, self::get_secret());

        return $payload . '.' . $signature;
    }

	public static function get_secret(){
		return wp_salt('secure_auth');
	}

    public static function validate_verification_token($token) : array {
        if (empty($token)) {
            return ['valid' => false, 'error' => 'Token required'];
        }

        $parts = explode('.', $token);
        if (count($parts) !== 2) {
            return ['valid' => false, 'error' => 'Malformed token'];
        }

        [$payload, $signature] = $parts;

        // Verify signature
        $expected_signature = hash_hmac('sha256', $payload, self::get_secret());
        if (!hash_equals($expected_signature, $signature)) {
            return ['valid' => false, 'error' => 'Invalid signature'];
        }

        // Decode payload
        $data = json_decode(base64_decode($payload), true);
        if (!$data) {
            return ['valid' => false, 'error' => 'Invalid payload'];
        }

        // Check expiration
        if (time() > $data['exp']) {
            return ['valid' => false, 'error' => 'Token expired'];
        }

        return ['valid' => true, 'data' => $data];
    }


    public static function generateAndSendOTP($contact_value, $contact_type, $delivery_method) {
        if (!self::isValidCombination($contact_type, $delivery_method)) {
			return new WP_Error('otp_generation_error', 'Invalid delivery method for contact type');
        }

        if (!self::checkRateLimit($contact_value)) {
			return new WP_Error('otp_generation_error', 'Too many attempts. Please try again later.');
        }

		if($contact_type == 'email'){
			if(!OsUtilHelper::is_valid_email($contact_value)){
				return new WP_Error('otp_generation_error', 'Invalid email address');
			}
		}

        // Cancel old active OTPs for this contact
        self::cancelOldOTPs($contact_value);

        // Generate new OTP
        $otp_code = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
        $otp_hash = wp_hash_password($otp_code);
        $expires_at = OsTimeHelper::custom_datetime_utc_in_db_format(sprintf('+%d minutes', self::$otp_expires_in_minutes));

	    $otp = new OsOTPModel();
		$otp->contact_value = $contact_value;
		$otp->contact_type = $contact_type;
		$otp->delivery_method = $delivery_method;
		$otp->otp_hash = $otp_hash;
		$otp->expires_at = $expires_at;
		$otp->status = LATEPOINT_CUSTOMER_OTP_CODE_STATUS_ACTIVE;
		$otp->attempts = 0;
		if(!$otp->save()){
			return new WP_Error('otp_generation_error', $otp->get_error_messages());
		}

        // Send OTP
        return self::sendOTP($otp_code, $otp);
    }


	public static function otp_input_box_html( string $contact_type, string $contact_value, string $delivery_method ) : string {
		$message = '';
        $message.= '<div class="latepoint-customer-otp-input-wrapper os-customer-wrapped-box">';
            $message.= '<div class="latepoint-customer-otp-close"><i class="latepoint-icon latepoint-icon-common-01"></i></div>';
            $message.= '<div class="latepoint-customer-box-title">'.esc_html__('Verify your email', 'latepoint').'</div>';
            $message.= '<div class="latepoint-customer-box-desc">'.sprintf(esc_html__('Enter the code we sent to %s', 'latepoint'), $contact_value).'</div>';
            $message.= '<div class="latepoint-customer-otp-input-code-wrapper">';
                $message.= OsFormHelper::otp_code_field('otp[otp_code]');
            $message.= '</div>';
            $message.= '<a tabindex="0" class="latepoint-btn latepoint-btn-block latepoint-btn-primary latepoint-verify-otp-button" data-route="'.OsRouterHelper::build_route_name('auth', 'verify_otp').'"><span>'.__('Verify', 'latepoint').'</span></a>';
            $message.= '<div class="latepoint-customer-otp-sub-wrapper">';
                $message.= '<div class="latepoint-customer-otp-sub">'.sprintf(esc_html__('The code will expire in %s minutes', 'latepoint'), OsOTPHelper::$otp_expires_in_minutes).'</div>';
                $message.= '<a tabindex="0" href="#" class="latepoint-customer-otp-resend" data-otp-request-route="'.OsRouterHelper::build_route_name('auth', 'request_otp').'">'.esc_html__('Resend code', 'latepoint').'</a>';
            $message.= '</div>';
			$message.= wp_nonce_field('otp_verify_otp_nonce', 'otp[verify_nonce]', true, false);
			$message.= OsFormHelper::hidden_field('otp[contact_type]', $contact_type);
			$message.= OsFormHelper::hidden_field('otp[contact_value]', $contact_value);
			$message.= OsFormHelper::hidden_field('otp[delivery_method]', $delivery_method);
        $message.= '</div>';
		return $message;
	}

	public static function is_customer_contact_verified(OsCustomerModel $customer, string $contact_value, string $contact_type) : bool{
		$verified_contact_values = json_decode($customer->get_meta_by_key('verified_contact_values', ''), true);
		if($verified_contact_values){
	        return in_array($contact_value, $verified_contact_values[$contact_type]);
		}else{
			return false;
		}
	}


	public static function add_verified_contact_for_customer_from_verification_token(OsCustomerModel $customer, string $verification_token) : void {
		$verification_info = OsOTPHelper::validate_verification_token($verification_token);
        if($verification_info['valid'] && !empty($verification_info['data']['contact_value'])){
			self::add_verified_contact_for_customer($customer, $verification_info['data']['contact_value'], $verification_info['data']['contact_type']);
        }
	}

	public static function add_verified_contact_for_customer(OsCustomerModel $customer, string $contact_value, string $contact_type){
		if(!$customer->is_new_record() && !empty($contact_value) && in_array($contact_type, self::valid_contact_types_for_customer())){
			if(!self::is_customer_contact_verified($customer, $contact_value, $contact_type)){
				$verified_contact_values = json_decode($customer->get_meta_by_key('verified_contact_values', ''), true);
	            $verified_contact_values[$contact_type][] = $contact_value;
	            $customer->save_meta_by_key('verified_contact_values', wp_json_encode($verified_contact_values));
	        }
		}
	}

    public static function verifyOTP($otp_code, $contact_value, $contact_type = 'email', $delivery_method = 'email') {
        // Expire old OTPs first
        self::expireExpiredOTPs();

		$otp = new OsOTPModel();
		$active_otp = $otp->where([
			'contact_value' => $contact_value,
			'contact_type' => $contact_type,
			'delivery_method' => $delivery_method,
			'status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_ACTIVE,
			'attempts <' => self::$max_verification_attempts
		])->set_limit(1)->get_results_as_models();

        if (empty($active_otp)) {
            return new WP_Error('otp_generation_error', 'Invalid Code');
        }

        if (wp_check_password($otp_code, $active_otp->otp_hash)) {
            // Mark this OTP as used
            $active_otp->update_attributes(
                [
                    'status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_USED,
                    'used_at' => OsTimeHelper::now_datetime_in_format( LATEPOINT_DATETIME_DB_FORMAT )
                ]
            );

            // Cancel other active OTPs for this contact
	        $other_otps = new OsOTPModel();
			$other_otps = $other_otps->where(['contact_value' => $contact_value, 'status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_ACTIVE])->get_results_as_models();
			if($other_otps){
				foreach($other_otps as $otp){
					$otp->update_attributes(['status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_CANCELLED]);
				}
			}

            return [
                'status' => LATEPOINT_STATUS_SUCCESS,
                'contact_value' => $contact_value
            ];
        }

		$active_otp->update_attributes(['attempts' => $active_otp->attempts + 1]);

        return new WP_Error('otp_generation_error', 'Invalid Code');
    }

    private static function sendOTP(string $otp_code, OsOTPModel $otp) : array {

		$result = [
			'status' => LATEPOINT_STATUS_ERROR,
			'message' => __('OTP was not sent.', 'latepoint'),
			'to' => $otp->contact_value,
			'delivery_method' => $otp->delivery_method,
			'contact_type' => $otp->contact_type,
			'processed_datetime' => '',
			'extra_data' => [
				'activity_data' => []
			],
			'errors' => [],
		];
		switch($otp->delivery_method) {
			case 'email':
				$subject = __('Your OTP Code', 'latepoint');
				$content = sprintf(esc_html__('Your OTP code is: %s', 'latepoint'), $otp_code);
				$send_result = OsNotificationsHelper::send($otp->delivery_method, ['to' => $otp->contact_value, 'subject' => $subject, 'content' => $content]);
				if($send_result['status'] == LATEPOINT_STATUS_SUCCESS){
					$result['processed_datetime'] = OsTimeHelper::now_datetime_in_db_format();
					$result['status'] = LATEPOINT_STATUS_SUCCESS;
				}
				break;
			case 'sms':
				$subject = __('Your OTP Code', 'latepoint');
				$content = sprintf(esc_html__('Your OTP code is: %s', 'latepoint'), $otp_code);
				$send_result = OsNotificationsHelper::send($otp->delivery_method, ['to' => $otp->contact_value, 'subject' => $subject, 'content' => $content]);
				if($send_result['status'] == LATEPOINT_STATUS_SUCCESS){
					$result['processed_datetime'] = OsTimeHelper::now_datetime_in_db_format();
					$result['status'] = LATEPOINT_STATUS_SUCCESS;
				}
				break;
		}


		/**
	     * Result of sending an OTP code
		 *
	     * @since 5.2.0
	     * @hook latepoint_notifications_send_otp_code
	     *
	     * @param {array} $result The array of data describing the result of operation
	     * @param {string} $otp_code
	     * @param {OsOTPModel} $otp
	     *
	     * @returns {array} The filtered array of data describing the result of operation
	     */
	    $result = apply_filters('latepoint_notifications_send_otp_code', $result, $otp_code, $otp);

		return $result;
    }

	public static function valid_contact_types_for_customer() : array{
		$contact_types = ['email', 'phone'];
		/**
	     * List of valid contact types for customers
		 *
	     * @since 5.2.0
	     * @hook latepoint_valid_contact_types_for_customer
	     *
	     * @param {array} $contact_types The array of contact types
	     *
	     * @returns {array} The filtered array of contact types
	     */
	    $result = apply_filters('latepoint_valid_contact_types_for_customer', $contact_types);

		return $result;
	}

    private static function cancelOldOTPs($contact_value) {
		$old_otps = new OsOTPModel();
		$old_otps = $old_otps->where(['contact_value' => $contact_value, 'status' => 'active'])->get_results_as_models();
		if($old_otps){
			foreach($old_otps as $otp){
				$otp->update_attributes(['status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_CANCELLED]);
			}
		}
    }

    private static function expireExpiredOTPs() {
		$otps = new OsOTPModel();
		$expired_otps = $otps->where([
			'status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_ACTIVE,
			'expires_at <' => OsTimeHelper::now_datetime_utc_in_db_format()
		])->get_results_as_models();
		if($expired_otps){
			foreach($expired_otps as $otp){
				$otp->update_attributes(['status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_EXPIRED]);
			}
		}
    }

    private static function isValidCombination($contact_type, $delivery_method) {
        $valid_combinations = [
            'email' => ['email'],
            'phone' => ['sms', 'whatsapp']
        ];
		/**
		 * Delivery methods for contact types
		 *
		 * @since 5.2.0
		 * @hook latepoint_otp_delivery_methods_for_contact_types
		 *
		 * @param {array} $methods available delivery methods
		 * @returns {array} The filtered array of available delivery methods
		 */
		$valid_combinations = apply_filters( 'latepoint_otp_delivery_methods_for_contact_types', $valid_combinations );

        return in_array($delivery_method, $valid_combinations[$contact_type] ?? []);
    }

    private static function checkRateLimit($contact_value) : bool {

		$otps = new OsOTPModel();
		$recent_attempts = $otps->where([
			'contact_value' => $contact_value,
			'created_at >' => OsTimeHelper::custom_datetime_utc_in_db_format('-1 hour')])->count();


        return $recent_attempts < self::$max_generation_attempts_per_hour;
    }


    // Cleanup old records
    public static function scheduledCleanup() {
		$otps = new OsOTPModel();
		$otps->delete_where([
			'created_at <' => OsTimeHelper::custom_datetime_utc_in_db_format('-30 days'),
			'status' => [LATEPOINT_CUSTOMER_OTP_CODE_STATUS_USED, LATEPOINT_CUSTOMER_OTP_CODE_STATUS_EXPIRED, LATEPOINT_CUSTOMER_OTP_CODE_STATUS_CANCELLED]]);
    }

	public static function is_otp_enabled_for_contact_type( string $contact_type, string $delivery_method ) : bool {
		$is_enabled = false;
		if($contact_type == 'email' && $delivery_method == 'email'){
			$is_enabled = true;
		}

		/**
		 * Determines if OTP is enabled for a selected contact type and delivery method
		 *
		 * @since 5.2.0
		 * @hook latepoint_is_otp_enabled_for_contact_type
		 *
		 * @param {bool} $is_enabled if otp delivery is enabled for a supplied contact and delivery method
		 * @param {string} $contact_type a contact type for OTP
		 * @param {string} $delivery_method a delivery method for OTP
		 *
		 * @returns {bool} Filtered value of whether OTP is enabled for this delivery method
		 */
		return apply_filters('latepoint_is_otp_enabled_for_contact_type', $is_enabled, $contact_type, $delivery_method);
	}
}