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

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

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

    /**
     * The user meta key for the email token.
     *
     * @var string
     */
    const TOKEN_META_KEY = '_switchboard_2fa_email_token';

    /**
     * The user meta key for the email token timestamp.
     *
     * @var string
     */
    const TOKEN_META_KEY_TIMESTAMP = '_switchboard_2fa_email_token_timestamp';

    /**
     * Name of the input field used for code resend.
     *
     * @var string
     */
    const INPUT_NAME_RESEND_CODE = 'switchboard-2fa-email-code-resend';

    /**
     * The user meta key for email send count (rate limiting).
     *
     * @var string
     */
    const EMAIL_RATE_LIMIT_KEY = '_switchboard_2fa_email_rate_limit';

    /**
     * Maximum emails allowed per rate limit window.
     *
     * @var int
     */
    const MAX_EMAILS_PER_WINDOW = 5;

    /**
     * Rate limit window in seconds (5 minutes).
     *
     * @var int
     */
    const RATE_LIMIT_WINDOW = 300;

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

    /**
     * Returns the name of the provider.
     *
     * @since 1.0.0
     * @return string
     */
    public function get_label() {
        return _x('Email', '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 __('Send a code to your email', 'wpswitchboard');
    }

    /**
     * Get the email token length.
     *
     * @since 1.0.0
     * @return int Email token string length.
     */
    private function get_token_length() {
        /**
         * Filter the number of characters in the email token.
         *
         * @param int $token_length Number of characters in the email token.
         */
        $token_length = (int) apply_filters('switchboard_2fa_email_token_length', 8);

        return $token_length;
    }

    /**
     * Generate the user token.
     *
     * @since 1.0.0
     * @param int $user_id User ID.
     * @return string The unhashed token.
     */
    public function generate_token($user_id) {
        $token = $this->get_code($this->get_token_length());

        update_user_meta($user_id, self::TOKEN_META_KEY_TIMESTAMP, time());
        update_user_meta($user_id, self::TOKEN_META_KEY, wp_hash($token));

        return $token;
    }

    /**
     * Check if user has a valid token already.
     *
     * @since 1.0.0
     * @param int $user_id User ID.
     * @return boolean If user has a valid email token.
     */
    public function user_has_token($user_id) {
        $hashed_token = $this->get_user_token($user_id);

        if (!empty($hashed_token)) {
            return true;
        }

        return false;
    }

    /**
     * Has the user token validity timestamp expired.
     *
     * @since 1.0.0
     * @param int $user_id User ID.
     * @return boolean
     */
    public function user_token_has_expired($user_id) {
        $token_lifetime = $this->user_token_lifetime($user_id);
        $token_ttl = $this->user_token_ttl($user_id);

        // Invalid token lifetime is considered an expired token.
        if (is_int($token_lifetime) && $token_lifetime <= $token_ttl) {
            return false;
        }

        return true;
    }

    /**
     * Get the lifetime of a user token in seconds.
     *
     * @since 1.0.0
     * @param int $user_id User ID.
     * @return int|null Return null if the lifetime can't be measured.
     */
    public function user_token_lifetime($user_id) {
        $timestamp = intval(get_user_meta($user_id, self::TOKEN_META_KEY_TIMESTAMP, true));

        if (!empty($timestamp)) {
            return time() - $timestamp;
        }

        return null;
    }

    /**
     * Return the token time-to-live for a user.
     *
     * @since 1.0.0
     * @param int $user_id User ID.
     * @return int TTL in seconds.
     */
    public function user_token_ttl($user_id) {
        $token_ttl = 15 * MINUTE_IN_SECONDS;

        /**
         * Filter the number of seconds the token is considered valid after generation.
         *
         * @param int $token_ttl Token time-to-live in seconds.
         * @param int $user_id   User ID.
         */
        return (int) apply_filters('switchboard_2fa_email_token_ttl', $token_ttl, $user_id);
    }

    /**
     * Get the authentication token for the user.
     *
     * @since 1.0.0
     * @param int $user_id User ID.
     * @return string|false User token or false if no token found.
     */
    public function get_user_token($user_id) {
        $hashed_token = get_user_meta($user_id, self::TOKEN_META_KEY, true);

        if (!empty($hashed_token) && is_string($hashed_token)) {
            return $hashed_token;
        }

        return false;
    }

    /**
     * Validate the user token.
     *
     * @since 1.0.0
     * @param int    $user_id User ID.
     * @param string $token   User token (unhashed).
     * @return boolean|WP_Error True on success, false or WP_Error on failure.
     */
    public function validate_token($user_id, $token) {
        $hashed_token = $this->get_user_token($user_id);

        // Check if token exists.
        if (empty($hashed_token)) {
            return new WP_Error(
                'no_token',
                __('No verification code found. Please request a new code.', 'wpswitchboard')
            );
        }

        // Check if token has expired BEFORE validating - give user a clear message.
        if ($this->user_token_has_expired($user_id)) {
            $this->delete_token($user_id);
            return new WP_Error(
                'token_expired',
                __('Your verification code has expired. Please request a new code.', 'wpswitchboard')
            );
        }

        // Validate the token.
        if (!hash_equals(wp_hash($token), $hashed_token)) {
            return new WP_Error(
                'invalid_token',
                __('Invalid verification code. Please check the code and try again.', 'wpswitchboard')
            );
        }

        // Ensure the token can be used only once.
        $this->delete_token($user_id);

        return true;
    }

    /**
     * Delete the user token.
     *
     * @since 1.0.0
     * @param int $user_id User ID.
     */
    public function delete_token($user_id) {
        delete_user_meta($user_id, self::TOKEN_META_KEY);
        delete_user_meta($user_id, self::TOKEN_META_KEY_TIMESTAMP);
    }

    /**
     * Check if the user is rate limited for email sends.
     *
     * @since 1.0.0
     * @param int $user_id User ID.
     * @return bool Whether the user is rate limited.
     */
    public function is_user_rate_limited($user_id) {
        $rate_data = get_user_meta($user_id, self::EMAIL_RATE_LIMIT_KEY, true);

        if (empty($rate_data) || !is_array($rate_data)) {
            return false;
        }

        $window_start = isset($rate_data['window_start']) ? (int) $rate_data['window_start'] : 0;
        $count = isset($rate_data['count']) ? (int) $rate_data['count'] : 0;

        // Check if we're still in the rate limit window.
        if (time() - $window_start > self::RATE_LIMIT_WINDOW) {
            // Window expired, reset.
            return false;
        }

        /**
         * Filter the maximum number of email codes per rate limit window.
         *
         * @param int $max_emails Maximum emails allowed.
         * @param int $user_id    User ID.
         */
        $max_emails = apply_filters('switchboard_2fa_email_max_per_window', self::MAX_EMAILS_PER_WINDOW, $user_id);

        return $count >= $max_emails;
    }

    /**
     * Get remaining time until rate limit resets.
     *
     * @since 1.0.0
     * @param int $user_id User ID.
     * @return int Seconds until rate limit resets.
     */
    public function get_rate_limit_remaining_time($user_id) {
        $rate_data = get_user_meta($user_id, self::EMAIL_RATE_LIMIT_KEY, true);

        if (empty($rate_data) || !is_array($rate_data)) {
            return 0;
        }

        $window_start = isset($rate_data['window_start']) ? (int) $rate_data['window_start'] : 0;
        $elapsed = time() - $window_start;

        if ($elapsed > self::RATE_LIMIT_WINDOW) {
            return 0;
        }

        return self::RATE_LIMIT_WINDOW - $elapsed;
    }

    /**
     * Record an email send for rate limiting.
     *
     * @since 1.0.0
     * @param int $user_id User ID.
     */
    private function record_email_send($user_id) {
        $rate_data = get_user_meta($user_id, self::EMAIL_RATE_LIMIT_KEY, true);

        if (empty($rate_data) || !is_array($rate_data)) {
            $rate_data = array(
                'window_start' => time(),
                'count' => 0,
            );
        }

        // Check if window has expired.
        if (time() - $rate_data['window_start'] > self::RATE_LIMIT_WINDOW) {
            $rate_data = array(
                'window_start' => time(),
                'count' => 0,
            );
        }

        $rate_data['count']++;
        update_user_meta($user_id, self::EMAIL_RATE_LIMIT_KEY, $rate_data);
    }

    /**
     * Generate and email the user token.
     *
     * @since 1.0.0
     * @param WP_User $user WP_User object of the logged-in user.
     * @return bool|WP_Error Whether the email contents were sent successfully, or WP_Error if rate limited or failed.
     */
    public function generate_and_email_token($user) {
        // Check rate limiting.
        if ($this->is_user_rate_limited($user->ID)) {
            $remaining = $this->get_rate_limit_remaining_time($user->ID);
            return new WP_Error(
                'rate_limited',
                sprintf(
                    /* translators: %d: seconds remaining */
                    __('Too many email requests. Please wait %d seconds before requesting another code.', 'wpswitchboard'),
                    $remaining
                ),
                array('remaining_seconds' => $remaining)
            );
        }

        $token = $this->generate_token($user->ID);

        /* translators: %s: site name */
        $subject = wp_strip_all_tags(
            sprintf(
                __('Your login confirmation code for %s', 'wpswitchboard'),
                wp_specialchars_decode(get_option('blogname'), ENT_QUOTES)
            )
        );

        /* translators: %s: token */
        $message = wp_strip_all_tags(sprintf(__('Enter %s to log in.', 'wpswitchboard'), $token));

        /**
         * Filter the token email subject.
         *
         * @param string $subject The email subject line.
         * @param int    $user_id The ID of the user.
         */
        $subject = apply_filters('switchboard_2fa_token_email_subject', $subject, $user->ID);

        /**
         * Filter the token email message.
         *
         * @param string $message The email message.
         * @param string $token   The token.
         * @param int    $user_id The ID of the user.
         */
        $message = apply_filters('switchboard_2fa_token_email_message', $message, $token, $user->ID);

        $result = wp_mail($user->user_email, $subject, $message);

        if ($result) {
            $this->record_email_send($user->ID);
        } else {
            // Log the email failure for debugging.
            error_log(sprintf(
                '[Switchboard 2FA] Failed to send verification code email to user %d (%s)',
                $user->ID,
                $user->user_email
            ));

            // Delete the token since email failed - user shouldn't try to use a code they never received.
            $this->delete_token($user->ID);

            return new WP_Error(
                'email_failed',
                __('Failed to send verification email. Please try again or contact the site administrator.', 'wpswitchboard')
            );
        }

        return true;
    }

    /**
     * Get remaining seconds until token expires.
     *
     * @since 1.0.0
     * @param int $user_id User ID.
     * @return int Seconds remaining, 0 if expired or no token.
     */
    public function get_token_remaining_time($user_id) {
        $token_lifetime = $this->user_token_lifetime($user_id);
        $token_ttl = $this->user_token_ttl($user_id);

        if (!is_int($token_lifetime)) {
            return 0;
        }

        $remaining = $token_ttl - $token_lifetime;
        return max(0, $remaining);
    }

    /**
     * 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) {
        if (!$user) {
            return;
        }

        $error_message = '';
        $error_type = '';
        $email_sent = false;

        if (!$this->user_has_token($user->ID) || $this->user_token_has_expired($user->ID)) {
            $result = $this->generate_and_email_token($user);
            if (is_wp_error($result)) {
                $error_type = $result->get_error_code();
                $error_message = $result->get_error_message();
            } else {
                $email_sent = true;
            }
        }

        $token_length = $this->get_token_length();
        $token_placeholder = str_repeat('X', $token_length);

        // Get rate limit info for resend button.
        $is_rate_limited = $this->is_user_rate_limited($user->ID);
        $rate_limit_remaining = $is_rate_limited ? $this->get_rate_limit_remaining_time($user->ID) : 0;

        // Get token expiration time for display.
        $token_remaining = $this->get_token_remaining_time($user->ID);
        $token_ttl_minutes = round($this->user_token_ttl($user->ID) / 60);

        require_once ABSPATH . '/wp-admin/includes/template.php';
        ?>
        <?php if ($error_message) : ?>
        <p class="two-factor-prompt" style="color: #dc2626;">
            <?php echo esc_html($error_message); ?>
        </p>
        <?php if ($error_type === 'email_failed') : ?>
        <p class="two-factor-prompt" style="color: #64748b; font-size: 13px;">
            <?php esc_html_e('Check your spam folder or verify your email address is correct.', 'wpswitchboard'); ?>
        </p>
        <?php endif; ?>
        <?php else : ?>
        <p class="two-factor-prompt">
            <?php esc_html_e('A verification code has been sent to the email address associated with your account.', 'wpswitchboard'); ?>
        </p>
        <?php if ($token_remaining > 0) : ?>
        <p class="two-factor-prompt" style="color: #64748b; font-size: 13px;">
            <?php
            printf(
                /* translators: %s: time remaining */
                esc_html__('Code expires in %s.', 'wpswitchboard'),
                '<span id="switchboard-2fa-countdown" aria-live="polite" aria-atomic="true">' . esc_html($this->format_time_remaining($token_remaining)) . '</span>'
            );
            ?>
        </p>
        <?php endif; ?>
        <?php endif; ?>
        <p>
            <label for="authcode"><?php esc_html_e('Verification Code:', 'wpswitchboard'); ?></label>
            <input type="text" inputmode="numeric" name="switchboard-2fa-email-code" id="authcode"
                   class="input authcode" value="" size="20" pattern="[0-9 ]*" autocomplete="one-time-code"
                   placeholder="<?php echo esc_attr($token_placeholder); ?>"
                   data-digits="<?php echo esc_attr($token_length); ?>" />
            <?php submit_button(__('Verify', 'wpswitchboard')); ?>
        </p>
        <p class="two-factor-email-resend">
            <input type="submit" class="button" name="<?php echo esc_attr(self::INPUT_NAME_RESEND_CODE); ?>"
                   id="switchboard-2fa-resend-btn"
                   value="<?php esc_attr_e('Resend Code', 'wpswitchboard'); ?>"
                   <?php echo $is_rate_limited ? 'disabled' : ''; ?> />
            <?php if ($is_rate_limited) : ?>
            <span id="switchboard-2fa-resend-countdown" style="color: #64748b; font-size: 13px; margin-left: 8px;" aria-live="polite">
                <?php
                printf(
                    /* translators: %s: time remaining */
                    esc_html__('Available in %s', 'wpswitchboard'),
                    '<span class="countdown-time" aria-atomic="true">' . esc_html($this->format_time_remaining($rate_limit_remaining)) . '</span>'
                );
                ?>
            </span>
            <?php endif; ?>
        </p>
        <script type="text/javascript">
            (function(){
                // Focus on code input.
                setTimeout(function(){
                    try {
                        var d = document.getElementById('authcode');
                        d.value = '';
                        d.focus();
                    } catch(e){}
                }, 200);

                // Countdown timer for code expiration.
                var codeCountdown = document.getElementById('switchboard-2fa-countdown');
                var codeRemaining = <?php echo intval($token_remaining); ?>;
                if (codeCountdown && codeRemaining > 0) {
                    var codeTimer = setInterval(function() {
                        codeRemaining--;
                        if (codeRemaining <= 0) {
                            clearInterval(codeTimer);
                            codeCountdown.textContent = '<?php echo esc_js(__('expired', 'wpswitchboard')); ?>';
                            codeCountdown.style.color = '#dc2626';
                        } else {
                            codeCountdown.textContent = formatTime(codeRemaining);
                        }
                    }, 1000);
                }

                // Countdown timer for resend button.
                var resendBtn = document.getElementById('switchboard-2fa-resend-btn');
                var resendCountdown = document.querySelector('#switchboard-2fa-resend-countdown .countdown-time');
                var resendRemaining = <?php echo intval($rate_limit_remaining); ?>;
                if (resendBtn && resendRemaining > 0) {
                    var resendTimer = setInterval(function() {
                        resendRemaining--;
                        if (resendRemaining <= 0) {
                            clearInterval(resendTimer);
                            resendBtn.disabled = false;
                            var countdown = document.getElementById('switchboard-2fa-resend-countdown');
                            if (countdown) countdown.style.display = 'none';
                        } else if (resendCountdown) {
                            resendCountdown.textContent = formatTime(resendRemaining);
                        }
                    }, 1000);
                }

                function formatTime(seconds) {
                    var mins = Math.floor(seconds / 60);
                    var secs = seconds % 60;
                    if (mins > 0) {
                        return mins + ':' + (secs < 10 ? '0' : '') + secs;
                    }
                    return secs + 's';
                }
            })();
        </script>
        <?php
    }

    /**
     * Format remaining time for display.
     *
     * @since 1.0.0
     * @param int $seconds Seconds remaining.
     * @return string Formatted time string.
     */
    private function format_time_remaining($seconds) {
        if ($seconds >= 60) {
            $minutes = floor($seconds / 60);
            $secs = $seconds % 60;
            return sprintf('%d:%02d', $minutes, $secs);
        }
        return sprintf('%ds', $seconds);
    }

    /**
     * Send the email code if missing or requested. Stop the authentication
     * validation if a new token has been generated and sent.
     *
     * @since 1.0.0
     * @param WP_User $user WP_User object of the logged-in user.
     * @return boolean|WP_Error Returns true to stop authentication and re-render, WP_Error if rate limited.
     */
    public function pre_process_authentication($user) {
        if (isset($user->ID) && isset($_REQUEST[self::INPUT_NAME_RESEND_CODE])) {
            // Delete existing token so a new one is generated.
            $this->delete_token($user->ID);

            $result = $this->generate_and_email_token($user);

            // Return WP_Error if rate limited so the login form can display the error.
            if (is_wp_error($result)) {
                return $result;
            }

            return true;
        }

        return false;
    }

    /**
     * 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) {
        $code = $this->sanitize_code_from_request('switchboard-2fa-email-code');
        if (!isset($user->ID)) {
            return false;
        }

        if (!$code) {
            return new WP_Error(
                'empty_code',
                __('Please enter the verification code from your email.', 'wpswitchboard')
            );
        }

        return $this->validate_token($user->ID, $code);
    }

    /**
     * Whether this Two Factor provider is configured and 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) {
        // Email is always available if the user has an email address.
        return !empty($user->user_email);
    }

    /**
     * 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) {
        $email = $user->user_email;
        ?>
        <p>
            <?php
            echo esc_html(
                sprintf(
                    /* translators: %s: email address */
                    __('Authentication codes will be sent to %s.', 'wpswitchboard'),
                    $email
                )
            );
            ?>
        </p>
        <?php
    }

    /**
     * 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::TOKEN_META_KEY,
            self::TOKEN_META_KEY_TIMESTAMP,
            self::EMAIL_RATE_LIMIT_KEY,
        );
    }
}
