<?php
/**
 * Class for creating a Time Based One-Time Password provider.
 *
 * Based on WordPress.org Two-Factor plugin patterns.
 *
 * @package Switchboard
 */

// Prevent direct access.
if (!defined('ABSPATH')) {
    exit;
}

/**
 * Class Switchboard_2FA_TOTP
 *
 * @since 1.0.0
 */
class Switchboard_2FA_TOTP extends Switchboard_2FA_Provider {

    /**
     * The user meta key for the TOTP Secret key.
     *
     * @var string
     */
    const SECRET_META_KEY = '_switchboard_2fa_totp_key';

    /**
     * The user meta key for the last successful TOTP token timestamp logged in with.
     *
     * @var string
     */
    const LAST_SUCCESSFUL_LOGIN_META_KEY = '_switchboard_2fa_totp_last_successful_login';

    const DEFAULT_KEY_BIT_SIZE        = 160;
    const DEFAULT_CRYPTO              = 'sha1';
    const DEFAULT_DIGIT_COUNT         = 6;
    const DEFAULT_TIME_STEP_SEC       = 30;
    const DEFAULT_TIME_STEP_ALLOWANCE = 4;

    /**
     * Characters used in base32 encoding.
     *
     * @var string
     */
    private static $base_32_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

    /**
     * Class constructor.
     *
     * @since 1.0.0
     */
    protected function __construct() {
        add_action('switchboard_2fa_user_options_' . __CLASS__, array($this, 'user_options'));
        add_action('wp_ajax_switchboard_2fa_totp_setup', array($this, 'ajax_setup_totp'));
        add_action('wp_ajax_switchboard_2fa_totp_delete', array($this, 'ajax_delete_totp'));

        parent::__construct();
    }

    /**
     * Returns the name of the provider.
     *
     * @since 1.0.0
     * @return string
     */
    public function get_label() {
        return _x('Authenticator App', 'Provider Label', 'wpswitchboard');
    }

    /**
     * Returns the "continue with" text provider for the login screen.
     *
     * @since 1.0.0
     * @return string
     */
    public function get_alternative_provider_label() {
        return __('Use your authenticator app for time-based one-time passwords (TOTP)', 'wpswitchboard');
    }

    /**
     * AJAX handler for setting up TOTP.
     *
     * @since 1.0.0
     */
    public function ajax_setup_totp() {
        check_ajax_referer('switchboard_2fa_totp', 'nonce');

        $user_id = isset($_POST['user_id']) ? intval($_POST['user_id']) : get_current_user_id();

        // Check permissions.
        if ($user_id !== get_current_user_id() && !current_user_can('edit_users')) {
            wp_send_json_error(array('message' => __('Permission denied.', 'wpswitchboard')));
        }

        $user = get_user_by('id', $user_id);
        if (!$user) {
            wp_send_json_error(array('message' => __('Invalid user.', 'wpswitchboard')));
        }

        $key = isset($_POST['key']) ? sanitize_text_field($_POST['key']) : '';
        $code = isset($_POST['code']) ? preg_replace('/\s+/', '', sanitize_text_field($_POST['code'])) : '';

        if (!$this->is_valid_key($key)) {
            wp_send_json_error(array('message' => __('Invalid Two Factor Authentication secret key.', 'wpswitchboard')));
        }

        if (!$this->is_valid_authcode($key, $code)) {
            wp_send_json_error(array('message' => __('Invalid Two Factor Authentication code.', 'wpswitchboard')));
        }

        if (!$this->set_user_totp_key($user_id, $key)) {
            wp_send_json_error(array('message' => __('Unable to save Two Factor Authentication code. Please re-scan the QR code and enter the code provided by your application.', 'wpswitchboard')));
        }

        // Enable the provider if requested.
        $enable = isset($_POST['enable_provider']) && $_POST['enable_provider'];
        if ($enable) {
            $instance = Switchboard2fa::get_instance();
            if ($instance) {
                $instance->enable_provider_for_user($user_id, __CLASS__);
            }
        }

        ob_start();
        $this->user_options($user);
        $html = ob_get_clean();

        wp_send_json_success(array(
            'message' => __('Authenticator app configured successfully.', 'wpswitchboard'),
            'html' => $html,
        ));
    }

    /**
     * AJAX handler for deleting TOTP.
     *
     * @since 1.0.0
     */
    public function ajax_delete_totp() {
        check_ajax_referer('switchboard_2fa_totp', 'nonce');

        $user_id = isset($_POST['user_id']) ? intval($_POST['user_id']) : get_current_user_id();

        // Check permissions.
        if ($user_id !== get_current_user_id() && !current_user_can('edit_users')) {
            wp_send_json_error(array('message' => __('Permission denied.', 'wpswitchboard')));
        }

        $user = get_user_by('id', $user_id);
        if (!$user) {
            wp_send_json_error(array('message' => __('Invalid user.', 'wpswitchboard')));
        }

        $this->delete_user_totp_key($user_id);

        // Disable the provider.
        $instance = Switchboard2fa::get_instance();
        if ($instance) {
            $instance->disable_provider_for_user($user_id, __CLASS__);
        }

        ob_start();
        $this->user_options($user);
        $html = ob_get_clean();

        wp_send_json_success(array(
            'message' => __('Authenticator app removed.', 'wpswitchboard'),
            'html' => $html,
        ));
    }

    /**
     * Generates a URL that can be used to create a QR code.
     *
     * @since 1.0.0
     * @param WP_User $user       The user to generate a URL for.
     * @param string  $secret_key The secret key.
     * @return string
     */
    public static function generate_qr_code_url($user, $secret_key) {
        $issuer = get_bloginfo('name', 'display');

        /**
         * Filter the Issuer for the TOTP.
         *
         * @param string $issuer The issuer for TOTP.
         */
        $issuer = apply_filters('switchboard_2fa_totp_issuer', $issuer);

        /**
         * Filter the Label for the TOTP.
         *
         * @param string  $totp_title The label for the TOTP.
         * @param WP_User $user       The User object.
         * @param string  $issuer     The issuer of the TOTP.
         */
        $totp_title = apply_filters('switchboard_2fa_totp_title', $issuer . ':' . $user->user_login, $user, $issuer);

        $totp_url = add_query_arg(
            array(
                'secret' => rawurlencode($secret_key),
                'issuer' => rawurlencode($issuer),
            ),
            'otpauth://totp/' . rawurlencode($totp_title)
        );

        /**
         * Filter the TOTP generated URL.
         *
         * @param string  $totp_url The TOTP URL.
         * @param WP_User $user     The user object.
         */
        $totp_url = apply_filters('switchboard_2fa_totp_url', $totp_url, $user);
        $totp_url = esc_url_raw($totp_url, array('otpauth'));

        return $totp_url;
    }

    /**
     * Display TOTP options on the user settings page.
     *
     * @since 1.0.0
     * @param WP_User $user The current user being edited.
     */
    public function user_options($user) {
        if (!isset($user->ID)) {
            return;
        }

        $key = $this->get_user_totp_key($user->ID);
        $nonce = wp_create_nonce('switchboard_2fa_totp');
        ?>
        <div id="switchboard-2fa-totp-options">
        <?php if (empty($key)) :
            $key = $this->generate_key();
            $totp_url = $this->generate_qr_code_url($user, $key);
            ?>
            <p>
                <?php esc_html_e('Please scan the QR code or manually copy the shared secret key from below to your Authenticator app:', 'wpswitchboard'); ?>
            </p>
            <p id="switchboard-2fa-qr-code" style="min-width: 205px; min-height: 205px;">
                <a href="<?php echo esc_url($totp_url); ?>">
                    <?php esc_html_e('Loading...', 'wpswitchboard'); ?>
                    <img src="<?php echo esc_url(admin_url('images/spinner.gif')); ?>" alt="" />
                </a>
            </p>

            <script>
                (function(){
                    var retryCount = 0;
                    var maxRetries = 50; // Max 5 seconds (50 * 100ms)
                    var qr_generator = function() {
                        if (typeof qrcode === 'undefined') {
                            retryCount++;
                            if (retryCount < maxRetries) {
                                setTimeout(qr_generator, 100);
                            } else {
                                // QR library failed to load - show manual entry message
                                var el = document.querySelector('#switchboard-2fa-qr-code a');
                                if (el) {
                                    el.innerHTML = <?php echo wp_json_encode(__('QR code could not be loaded. Please enter the secret key manually.', 'wpswitchboard')); ?>;
                                }
                            }
                            return;
                        }
                        var qr = qrcode(0, 'L');
                        qr.addData(<?php echo wp_json_encode($totp_url); ?>);
                        qr.make();
                        var el = document.querySelector('#switchboard-2fa-qr-code a');
                        if (el) {
                            el.innerHTML = qr.createSvgTag(5);
                            var svg = el.querySelector('svg');
                            if (svg) {
                                svg.setAttribute('role', 'img');
                                svg.setAttribute('aria-label', <?php echo wp_json_encode(__('Authenticator App QR Code', 'wpswitchboard')); ?>);
                            }
                        }
                    };
                    if (document.readyState === 'complete') {
                        qr_generator();
                    } else {
                        window.addEventListener('DOMContentLoaded', qr_generator);
                    }
                })();
            </script>

            <p>
                <?php esc_html_e('Shared secret key:', 'wpswitchboard'); ?> <code><?php echo esc_html($key); ?></code>
            </p>
            <hr />
            <p>
                <?php esc_html_e('Enter the 6-digit code from your Authenticator app to complete setup:', 'wpswitchboard'); ?>
            </p>
            <p>
                <input type="hidden" id="switchboard-2fa-totp-key" value="<?php echo esc_attr($key); ?>" />
                <label for="switchboard-2fa-totp-authcode">
                    <?php esc_html_e('Authentication Code:', 'wpswitchboard'); ?>
                    <input type="text" inputmode="numeric" name="switchboard-2fa-totp-authcode" id="switchboard-2fa-totp-authcode"
                           class="input" value="" size="20" pattern="[0-9 ]*" placeholder="<?php echo esc_attr(sprintf(__('eg. %s', 'wpswitchboard'), '123456')); ?>" autocomplete="off" />
                </label>
                <button type="button" class="button totp-submit" id="switchboard-2fa-totp-submit">
                    <?php esc_attr_e('Verify', 'wpswitchboard'); ?>
                </button>
            </p>
            <div id="switchboard-2fa-totp-error" style="display: none; color: #dc3232; margin-top: 10px;"></div>

            <script>
                (function($){
                    $('#enabled-Switchboard_2FA_TOTP').on('click', function(e) {
                        if (e.target.checked) {
                            $('#switchboard-2fa-totp-authcode').focus();
                        }
                    });

                    $('#switchboard-2fa-totp-submit').on('click', function(e) {
                        e.preventDefault();
                        var $btn = $(this);
                        var key = $('#switchboard-2fa-totp-key').val();
                        var code = $('#switchboard-2fa-totp-authcode').val();

                        $btn.prop('disabled', true).text(<?php echo wp_json_encode(__('Verifying...', 'wpswitchboard')); ?>);
                        $('#switchboard-2fa-totp-error').hide();

                        $.ajax({
                            url: ajaxurl,
                            type: 'POST',
                            data: {
                                action: 'switchboard_2fa_totp_setup',
                                nonce: <?php echo wp_json_encode($nonce); ?>,
                                user_id: <?php echo wp_json_encode($user->ID); ?>,
                                key: key,
                                code: code,
                                enable_provider: true
                            },
                            success: function(response) {
                                if (response.success) {
                                    $('#enabled-Switchboard_2FA_TOTP').prop('checked', true).trigger('change');
                                    $('#switchboard-2fa-totp-options').html(response.data.html);
                                    // Show success message - settings are auto-saved via AJAX
                                    var successMsg = $('<div class="notice notice-success" style="padding: 10px; margin: 10px 0; background: #d4edda; border-left: 4px solid #00a32a; color: #155724;"><strong>✓ ' + response.data.message + '</strong></div>');
                                    $('#switchboard-2fa-totp-options').prepend(successMsg);
                                } else {
                                    $('#switchboard-2fa-totp-error').text(response.data.message).show();
                                    $('#enabled-Switchboard_2FA_TOTP').prop('checked', false).trigger('change');
                                    $btn.prop('disabled', false).text(<?php echo wp_json_encode(__('Verify', 'wpswitchboard')); ?>);
                                }
                            },
                            error: function() {
                                $('#switchboard-2fa-totp-error').text(<?php echo wp_json_encode(__('An error occurred. Please try again.', 'wpswitchboard')); ?>).show();
                                $btn.prop('disabled', false).text(<?php echo wp_json_encode(__('Verify', 'wpswitchboard')); ?>);
                            }
                        });
                    });
                })(jQuery);
            </script>

        <?php else : ?>
            <p class="success" style="color: #00a32a;">
                <?php esc_html_e('An authenticator app is currently configured. You will need to re-scan the QR code on all devices if reset.', 'wpswitchboard'); ?>
            </p>
            <p>
                <button type="button" class="button button-secondary" id="switchboard-2fa-totp-reset">
                    <?php esc_html_e('Reset authenticator app', 'wpswitchboard'); ?>
                </button>
            </p>
            <script>
                (function($) {
                    $('#switchboard-2fa-totp-reset').on('click', function(e) {
                        e.preventDefault();
                        var $btn = $(this);
                        $btn.prop('disabled', true).text(<?php echo wp_json_encode(__('Resetting...', 'wpswitchboard')); ?>);

                        $.ajax({
                            url: ajaxurl,
                            type: 'POST',
                            data: {
                                action: 'switchboard_2fa_totp_delete',
                                nonce: <?php echo wp_json_encode($nonce); ?>,
                                user_id: <?php echo wp_json_encode($user->ID); ?>
                            },
                            success: function(response) {
                                if (response.success) {
                                    $('#enabled-Switchboard_2FA_TOTP').prop('checked', false);
                                    $('#switchboard-2fa-totp-options').html(response.data.html);
                                }
                            }
                        });
                    });
                })(jQuery);
            </script>
        <?php endif; ?>
        </div>
        <?php
    }

    /**
     * Get the TOTP secret key for a user.
     *
     * @since 1.0.0
     * @param int $user_id User ID.
     * @return string
     */
    public function get_user_totp_key($user_id) {
        return (string) get_user_meta($user_id, self::SECRET_META_KEY, true);
    }

    /**
     * Set the TOTP secret key for a user.
     *
     * @since 1.0.0
     * @param int    $user_id User ID.
     * @param string $key     TOTP secret key.
     * @return boolean
     */
    public function set_user_totp_key($user_id, $key) {
        return update_user_meta($user_id, self::SECRET_META_KEY, $key);
    }

    /**
     * Delete the TOTP secret key for a user.
     *
     * @since 1.0.0
     * @param int $user_id User ID.
     * @return boolean
     */
    public function delete_user_totp_key($user_id) {
        delete_user_meta($user_id, self::LAST_SUCCESSFUL_LOGIN_META_KEY);
        return delete_user_meta($user_id, self::SECRET_META_KEY);
    }

    /**
     * Check if the TOTP secret key has a proper format.
     *
     * @since 1.0.0
     * @param string $key TOTP secret key.
     * @return boolean
     */
    public function is_valid_key($key) {
        $check = sprintf('/^[%s]+$/', self::$base_32_chars);

        if (1 === preg_match($check, $key)) {
            return true;
        }

        return false;
    }

    /**
     * Validates authentication.
     *
     * @since 1.0.0
     * @param WP_User $user WP_User object of the logged-in user.
     * @return bool|WP_Error True on success, false or WP_Error on failure.
     */
    public function validate_authentication($user) {
        $code = $this->sanitize_code_from_request('authcode', self::DEFAULT_DIGIT_COUNT);
        if (!$code) {
            return new WP_Error(
                'empty_code',
                __('Please enter the code from your authenticator app.', 'wpswitchboard')
            );
        }

        $result = $this->validate_code_for_user($user, $code);
        if (!$result) {
            return new WP_Error(
                'invalid_totp',
                __('Invalid authentication code. Please check that the time on your authenticator app is synchronized.', 'wpswitchboard')
            );
        }

        return true;
    }

    /**
     * Validates an authentication code for a given user.
     *
     * @since 1.0.0
     * @param WP_User $user WP_User object of the logged-in user.
     * @param string  $code The TOTP token to validate.
     * @return bool
     */
    public function validate_code_for_user($user, $code) {
        $valid_timestamp = $this->get_authcode_valid_ticktime(
            $this->get_user_totp_key($user->ID),
            $code
        );

        if (!$valid_timestamp) {
            return false;
        }

        $last_totp_login = (int) get_user_meta($user->ID, self::LAST_SUCCESSFUL_LOGIN_META_KEY, true);

        // The TOTP authentication is not valid, if we've seen the same or newer code.
        if ($last_totp_login && $last_totp_login >= $valid_timestamp) {
            return false;
        }

        update_user_meta($user->ID, self::LAST_SUCCESSFUL_LOGIN_META_KEY, $valid_timestamp);

        return true;
    }

    /**
     * Checks if a given code is valid for a given key.
     *
     * @since 1.0.0
     * @param string $key      The shared secret key to use.
     * @param string $authcode The code to test.
     * @return bool
     */
    public static function is_valid_authcode($key, $authcode) {
        return (bool) self::get_authcode_valid_ticktime($key, $authcode);
    }

    /**
     * Checks if a given code is valid for a given key, allowing for time drift.
     *
     * @since 1.0.0
     * @param string $key      The shared secret key to use.
     * @param string $authcode The code to test.
     * @return false|int Returns the timestamp of the authcode on success, false otherwise.
     */
    public static function get_authcode_valid_ticktime($key, $authcode) {
        /**
         * Filter the maximum ticks to allow when checking valid codes.
         *
         * @param int $max_ticks Max ticks of time correction to allow. Default 4.
         */
        $max_ticks = apply_filters('switchboard_2fa_totp_time_step_allowance', self::DEFAULT_TIME_STEP_ALLOWANCE);

        $ticks = range(-$max_ticks, $max_ticks);
        usort($ticks, array(__CLASS__, 'abssort'));

        $time = floor(time() / self::DEFAULT_TIME_STEP_SEC);

        foreach ($ticks as $offset) {
            $log_time = $time + $offset;
            if (hash_equals(self::calc_totp($key, $log_time), $authcode)) {
                return $log_time * self::DEFAULT_TIME_STEP_SEC;
            }
        }

        return false;
    }

    /**
     * Generates key.
     *
     * @since 1.0.0
     * @param int $bitsize Number of bits to use for key.
     * @return string
     */
    public static function generate_key($bitsize = self::DEFAULT_KEY_BIT_SIZE) {
        $bytes = ceil($bitsize / 8);
        $secret = wp_generate_password($bytes, true, true);

        return self::base32_encode($secret);
    }

    /**
     * Pack 64-bit value.
     *
     * @since 1.0.0
     * @param string $value The value to be packed.
     * @return string
     */
    public static function pack64($value) {
        if (PHP_INT_SIZE >= 8) {
            if (version_compare(PHP_VERSION, '5.6.3', '>=') && PHP_INT_SIZE >= 8) {
                return pack('J', $value);
            }
            $highmap = 0xffffffff << 32;
            $higher = ($value & $highmap) >> 32;
        } else {
            $higher = 0;
        }

        $lowmap = 0xffffffff;
        $lower = $value & $lowmap;

        return pack('NN', $higher, $lower);
    }

    /**
     * Calculate a valid code given the shared secret key.
     *
     * @since 1.0.0
     * @param string $key        The shared secret key.
     * @param mixed  $step_count The time step used to calculate the code.
     * @param int    $digits     The number of digits in the returned code.
     * @param string $hash       The hash algorithm used to calculate the code.
     * @param int    $time_step  The size of the time step.
     * @return string
     */
    public static function calc_totp($key, $step_count = false, $digits = self::DEFAULT_DIGIT_COUNT, $hash = self::DEFAULT_CRYPTO, $time_step = self::DEFAULT_TIME_STEP_SEC) {
        $secret = self::base32_decode($key);

        if (false === $step_count) {
            $step_count = floor(time() / $time_step);
        }

        $timestamp = self::pack64($step_count);

        $hash = hash_hmac($hash, $timestamp, $secret, true);

        $offset = ord($hash[19]) & 0xf;

        $code = (
            ((ord($hash[$offset + 0]) & 0x7f) << 24) |
            ((ord($hash[$offset + 1]) & 0xff) << 16) |
            ((ord($hash[$offset + 2]) & 0xff) << 8) |
            (ord($hash[$offset + 3]) & 0xff)
        ) % pow(10, $digits);

        return str_pad($code, $digits, '0', STR_PAD_LEFT);
    }

    /**
     * Whether this provider is available for the user specified.
     *
     * @since 1.0.0
     * @param WP_User $user WP_User object of the logged-in user.
     * @return boolean
     */
    public function is_available_for_user($user) {
        $key = $this->get_user_totp_key($user->ID);
        return !empty($key);
    }

    /**
     * Prints the form that prompts the user to authenticate.
     *
     * @since 1.0.0
     * @param WP_User $user WP_User object of the logged-in user.
     */
    public function authentication_page($user) {
        require_once ABSPATH . '/wp-admin/includes/template.php';
        ?>
        <p class="two-factor-prompt">
            <?php esc_html_e('Enter the code generated by your authenticator app.', 'wpswitchboard'); ?>
        </p>
        <p>
            <label for="authcode"><?php esc_html_e('Authentication Code:', 'wpswitchboard'); ?></label>
            <input type="text" inputmode="numeric" autocomplete="one-time-code" name="authcode" id="authcode"
                   class="input authcode" value="" size="20" pattern="[0-9 ]*" placeholder="123 456"
                   data-digits="<?php echo esc_attr(self::DEFAULT_DIGIT_COUNT); ?>" />
        </p>
        <script type="text/javascript">
            setTimeout(function(){
                try {
                    document.getElementById('authcode').focus();
                } catch(e){}
            }, 200);
        </script>
        <?php
        submit_button(__('Verify', 'wpswitchboard'));
    }

    /**
     * Returns a base32 encoded string.
     *
     * @since 1.0.0
     * @param string $string String to be encoded.
     * @return string
     */
    public static function base32_encode($string) {
        if (empty($string)) {
            return '';
        }

        $binary_string = '';

        foreach (str_split($string) as $character) {
            $binary_string .= str_pad(base_convert(ord($character), 10, 2), 8, '0', STR_PAD_LEFT);
        }

        $five_bit_sections = str_split($binary_string, 5);
        $base32_string = '';

        foreach ($five_bit_sections as $five_bit_section) {
            $base32_string .= self::$base_32_chars[base_convert(str_pad($five_bit_section, 5, '0'), 2, 10)];
        }

        return $base32_string;
    }

    /**
     * Decode a base32 string and return a binary representation.
     *
     * @since 1.0.0
     * @param string $base32_string The base 32 string to decode.
     * @return string
     */
    public static function base32_decode($base32_string) {
        $base32_string = strtoupper($base32_string);

        if (!preg_match('/^[' . self::$base_32_chars . ']+$/', $base32_string, $match)) {
            return '';
        }

        $l = strlen($base32_string);
        $n = 0;
        $j = 0;
        $binary = '';

        for ($i = 0; $i < $l; $i++) {
            $n = $n << 5;
            $n = $n + strpos(self::$base_32_chars, $base32_string[$i]);
            $j += 5;

            if ($j >= 8) {
                $j -= 8;
                $binary .= chr(($n & (0xFF << $j)) >> $j);
            }
        }

        return $binary;
    }

    /**
     * Used with usort to sort an array by distance from 0.
     *
     * @since 1.0.0
     * @param int $a First array element.
     * @param int $b Second array element.
     * @return int
     */
    private static function abssort($a, $b) {
        $a = abs($a);
        $b = abs($b);
        if ($a === $b) {
            return 0;
        }
        return ($a < $b) ? -1 : 1;
    }

    /**
     * Return user meta keys to delete during plugin uninstall.
     *
     * @since 1.0.0
     * @return array
     */
    public static function uninstall_user_meta_keys() {
        return array(
            self::SECRET_META_KEY,
            self::LAST_SUCCESSFUL_LOGIN_META_KEY,
        );
    }
}
