<?php
/**
 * Class for creating a Backup Codes two-factor authentication provider.
 *
 * Based on WordPress.org Two-Factor plugin patterns.
 *
 * @package Switchboard
 */

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

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

    /**
     * The user meta key for backup codes.
     *
     * @var string
     */
    const BACKUP_CODES_META_KEY = '_switchboard_2fa_backup_codes';

    /**
     * The default number of backup codes to generate.
     *
     * @var int
     */
    const NUMBER_OF_CODES = 10;

    /**
     * Class constructor.
     *
     * @since 1.0.0
     */
    protected function __construct() {
        add_action('switchboard_2fa_user_options_' . __CLASS__, array($this, 'user_options'));
        add_action('admin_notices', array($this, 'admin_notices'));
        add_action('wp_ajax_switchboard_2fa_generate_backup_codes', array($this, 'ajax_generate_codes'));

        parent::__construct();
    }

    /**
     * Returns the name of the provider.
     *
     * @since 1.0.0
     * @return string
     */
    public function get_label() {
        return _x('Recovery Codes', '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 a recovery code', 'wpswitchboard');
    }

    /**
     * Displays an admin notice when backup codes have run out.
     *
     * @since 1.0.0
     */
    public function admin_notices() {
        $user = wp_get_current_user();

        if (!class_exists('Switchboard2fa')) {
            return;
        }

        $instance = Switchboard2fa::get_instance();
        if (!$instance) {
            return;
        }

        // Return if the provider is not enabled.
        $enabled_providers = $instance->get_enabled_providers_for_user($user->ID);
        if (!in_array(__CLASS__, $enabled_providers, true)) {
            return;
        }

        // Return if we are not out of codes.
        if ($this->is_available_for_user($user)) {
            return;
        }
        ?>
        <div class="error">
            <p>
                <span>
                    <?php
                    echo wp_kses(
                        sprintf(
                            /* translators: %s: URL for code regeneration */
                            __('Two-Factor: You are out of recovery codes and need to <a href="%s">regenerate!</a>', 'wpswitchboard'),
                            esc_url(get_edit_user_link($user->ID) . '#switchboard-2fa-backup-codes')
                        ),
                        array('a' => array('href' => true))
                    );
                    ?>
                </span>
            </p>
        </div>
        <?php
    }

    /**
     * Whether this Two Factor provider is configured and codes are 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) {
        // Does this user have available codes?
        if (0 < self::codes_remaining_for_user($user)) {
            return true;
        }
        return false;
    }

    /**
     * Get the backup code length for a user.
     *
     * @since 1.0.0
     * @param WP_User $user User object.
     * @return int Number of characters.
     */
    private function get_backup_code_length($user) {
        /**
         * Filter the character count of the backup codes.
         *
         * @param int     $code_length Length of the backup code.
         * @param WP_User $user        User object.
         */
        $code_length = (int) apply_filters('switchboard_2fa_backup_code_length', 8, $user);

        return $code_length;
    }

    /**
     * Returns the number of unused codes for the specified user.
     *
     * @since 1.0.0
     * @param WP_User $user WP_User object of the logged-in user.
     * @return int The number of unused codes remaining.
     */
    public static function codes_remaining_for_user($user) {
        $backup_codes = get_user_meta($user->ID, self::BACKUP_CODES_META_KEY, true);
        if (is_array($backup_codes) && !empty($backup_codes)) {
            return count($backup_codes);
        }
        return 0;
    }

    /**
     * Generates backup codes & updates the user meta.
     *
     * @since 1.0.0
     * @param WP_User $user WP_User object of the logged-in user.
     * @param array   $args Optional arguments for assigning new codes.
     * @return array Array of unhashed codes.
     */
    public function generate_codes($user, $args = array()) {
        $codes = array();
        $codes_hashed = array();

        // Check for arguments.
        if (isset($args['number'])) {
            $num_codes = (int) $args['number'];
        } else {
            $num_codes = self::NUMBER_OF_CODES;
        }

        // Append or replace (default).
        if (isset($args['method']) && 'append' === $args['method']) {
            $codes_hashed = (array) get_user_meta($user->ID, self::BACKUP_CODES_META_KEY, true);
        }

        $code_length = $this->get_backup_code_length($user);

        for ($i = 0; $i < $num_codes; $i++) {
            $code = $this->get_code($code_length);
            $codes_hashed[] = wp_hash_password($code);
            $codes[] = $code;
            unset($code);
        }

        update_user_meta($user->ID, self::BACKUP_CODES_META_KEY, $codes_hashed);

        // Return unhashed codes for display.
        return $codes;
    }

    /**
     * AJAX handler for generating backup codes.
     *
     * @since 1.0.0
     */
    public function ajax_generate_codes() {
        check_ajax_referer('switchboard_2fa_backup_codes', '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')));
        }

        // Hardcode these, the user shouldn't be able to choose them.
        $args = array(
            'number' => self::NUMBER_OF_CODES,
            'method' => 'replace',
        );

        // Setup the return data.
        $codes = $this->generate_codes($user, $args);
        $count = self::codes_remaining_for_user($user);
        $title = sprintf(
            /* translators: %s: the site's domain */
            __('Two-Factor Recovery Codes for %s', 'wpswitchboard'),
            home_url('/')
        );

        // Generate download content.
        $download_link = 'data:application/text;charset=utf-8,';
        $download_link .= rawurlencode("{$title}\r\n\r\n");

        $i = 1;
        foreach ($codes as $code) {
            $download_link .= rawurlencode("{$i}. {$code}\r\n");
            ++$i;
        }

        /* translators: %s: count */
        $count_text = sprintf(
            _n(
                '%s unused code remaining, each recovery code can only be used once.',
                '%s unused codes remaining, each recovery code can only be used once.',
                $count,
                'wpswitchboard'
            ),
            $count
        );

        // 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__);
            }
        }

        wp_send_json_success(array(
            'codes' => $codes,
            'download_link' => $download_link,
            'remaining' => $count,
            'count_text' => esc_html($count_text),
        ));
    }

    /**
     * Inserts markup at the end of the user profile field for this provider.
     *
     * @since 1.0.0
     * @param WP_User $user WP_User object of the logged-in user.
     */
    public function user_options($user) {
        wp_enqueue_script('jquery');

        $count = self::codes_remaining_for_user($user);
        $nonce = wp_create_nonce('switchboard_2fa_backup_codes');
        ?>
        <div id="switchboard-2fa-backup-codes">
            <p class="two-factor-backup-codes-count">
                <?php
                echo esc_html(
                    sprintf(
                        /* translators: %s: count */
                        _n(
                            '%s unused code remaining, each recovery code can only be used once.',
                            '%s unused codes remaining, each recovery code can only be used once.',
                            $count,
                            'wpswitchboard'
                        ),
                        $count
                    )
                );
                ?>
            </p>
            <p>
                <button type="button" class="button button-secondary" id="switchboard-2fa-backup-codes-generate">
                    <?php esc_html_e('Generate new recovery codes', 'wpswitchboard'); ?>
                </button>
                <em><?php esc_html_e('This invalidates all currently stored codes.', 'wpswitchboard'); ?></em>
            </p>

            <div class="two-factor-backup-codes-wrapper" style="display:none;">
                <ol class="two-factor-backup-codes-list"></ol>
                <p class="description">
                    <?php esc_html_e('Write these down! Once you navigate away from this page, you will not be able to view these codes again.', 'wpswitchboard'); ?>
                </p>
                <p>
                    <a class="button button-secondary" href="javascript:void(0);" id="switchboard-2fa-backup-codes-download" download="switchboard-recovery-codes.txt">
                        <?php esc_html_e('Download Codes', 'wpswitchboard'); ?>
                    </a>
                    <button type="button" class="button button-secondary" id="switchboard-2fa-backup-codes-copy">
                        <?php esc_html_e('Copy to Clipboard', 'wpswitchboard'); ?>
                    </button>
                </p>
            </div>
        </div>

        <script type="text/javascript">
            (function($) {
                $('#switchboard-2fa-backup-codes-generate').on('click', function() {
                    var $btn = $(this);
                    $btn.prop('disabled', true).text(<?php echo wp_json_encode(__('Generating...', 'wpswitchboard')); ?>);

                    $.ajax({
                        url: ajaxurl,
                        type: 'POST',
                        data: {
                            action: 'switchboard_2fa_generate_backup_codes',
                            nonce: <?php echo wp_json_encode($nonce); ?>,
                            user_id: <?php echo wp_json_encode($user->ID); ?>,
                            enable_provider: true
                        },
                        success: function(response) {
                            if (response.success) {
                                var $wrapper = $('.two-factor-backup-codes-wrapper');
                                var $codesList = $('.two-factor-backup-codes-list');

                                $wrapper.show();
                                $codesList.html('');

                                // Append the codes.
                                for (var i = 0; i < response.data.codes.length; i++) {
                                    $codesList.append('<li>' + response.data.codes[i] + '</li>');
                                }

                                // Update counter.
                                $('.two-factor-backup-codes-count').html(response.data.count_text);

                                // Update download link.
                                $('#switchboard-2fa-backup-codes-download').attr('href', response.data.download_link);

                                // Store codes for copy functionality.
                                $('#switchboard-2fa-backup-codes').data('codes', response.data.codes.join('\n'));

                                // Enable the checkbox.
                                $('#enabled-Switchboard_2FA_Backup_Codes').prop('checked', true).trigger('change');
                            }

                            $btn.prop('disabled', false).text(<?php echo wp_json_encode(__('Generate new recovery codes', 'wpswitchboard')); ?>);
                        },
                        error: function() {
                            $btn.prop('disabled', false).text(<?php echo wp_json_encode(__('Generate new recovery codes', 'wpswitchboard')); ?>);
                        }
                    });
                });

                // Copy to clipboard functionality.
                $('#switchboard-2fa-backup-codes-copy').on('click', function() {
                    var codes = $('#switchboard-2fa-backup-codes').data('codes');
                    if (codes && navigator.clipboard) {
                        navigator.clipboard.writeText(codes).then(function() {
                            var $btn = $('#switchboard-2fa-backup-codes-copy');
                            var originalText = $btn.text();
                            $btn.text(<?php echo wp_json_encode(__('Copied!', 'wpswitchboard')); ?>);
                            setTimeout(function() {
                                $btn.text(originalText);
                            }, 2000);
                        });
                    }
                });
            })(jQuery);
        </script>
        <?php
    }

    /**
     * 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';

        $code_length = $this->get_backup_code_length($user);
        $code_placeholder = str_repeat('X', $code_length);

        ?>
        <p class="two-factor-prompt">
            <?php esc_html_e('Enter a recovery code.', 'wpswitchboard'); ?>
        </p>
        <p>
            <label for="authcode"><?php esc_html_e('Recovery Code:', 'wpswitchboard'); ?></label>
            <input type="text" inputmode="numeric" name="switchboard-2fa-backup-code" id="authcode"
                   class="input authcode" value="" size="20" pattern="[0-9 ]*" autocomplete="off"
                   placeholder="<?php echo esc_attr($code_placeholder); ?>"
                   data-digits="<?php echo esc_attr($code_length); ?>" />
        </p>
        <?php
        submit_button(__('Verify', 'wpswitchboard'));
    }

    /**
     * Validates the users input token.
     *
     * @since 1.0.0
     * @param WP_User $user WP_User object of the logged-in user.
     * @return boolean|WP_Error True on success, false or WP_Error on failure.
     */
    public function validate_authentication($user) {
        $backup_code = $this->sanitize_code_from_request('switchboard-2fa-backup-code');
        if (!$backup_code) {
            return new WP_Error(
                'empty_code',
                __('Please enter a recovery code.', 'wpswitchboard')
            );
        }

        $result = $this->validate_code($user, $backup_code);
        if (!$result) {
            $remaining = self::codes_remaining_for_user($user);
            if ($remaining === 0) {
                return new WP_Error(
                    'no_codes_remaining',
                    __('Invalid recovery code. You have no recovery codes remaining. Please use another authentication method.', 'wpswitchboard')
                );
            }
            return new WP_Error(
                'invalid_backup_code',
                sprintf(
                    /* translators: %d: number of codes remaining */
                    _n(
                        'Invalid recovery code. You have %d code remaining.',
                        'Invalid recovery code. You have %d codes remaining.',
                        $remaining,
                        'wpswitchboard'
                    ),
                    $remaining
                )
            );
        }

        return true;
    }

    /**
     * Validates a backup code.
     *
     * Backup Codes are single use and are deleted upon a successful validation.
     *
     * @since 1.0.0
     * @param WP_User $user WP_User object of the logged-in user.
     * @param string  $code The backup code.
     * @return boolean
     */
    public function validate_code($user, $code) {
        $backup_codes = get_user_meta($user->ID, self::BACKUP_CODES_META_KEY, true);

        if (is_array($backup_codes) && !empty($backup_codes)) {
            foreach ($backup_codes as $code_index => $code_hashed) {
                if (wp_check_password($code, $code_hashed, $user->ID)) {
                    $this->delete_code($user, $code_hashed);
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * Deletes a backup code.
     *
     * @since 1.0.0
     * @param WP_User $user        WP_User object of the logged-in user.
     * @param string  $code_hashed The hashed backup code.
     */
    public function delete_code($user, $code_hashed) {
        $backup_codes = get_user_meta($user->ID, self::BACKUP_CODES_META_KEY, true);

        // Delete the current code from the list since it's been used.
        $backup_codes = array_flip($backup_codes);
        unset($backup_codes[$code_hashed]);
        $backup_codes = array_values(array_flip($backup_codes));

        // Update the backup code master list.
        update_user_meta($user->ID, self::BACKUP_CODES_META_KEY, $backup_codes);
    }

    /**
     * Delete all backup codes for a user.
     *
     * @since 1.0.0
     * @param int $user_id User ID.
     * @return boolean
     */
    public function delete_all_codes($user_id) {
        return delete_user_meta($user_id, self::BACKUP_CODES_META_KEY);
    }

    /**
     * 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::BACKUP_CODES_META_KEY,
        );
    }
}
