<?php

namespace UltraguardPro\modules\authenticator;

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

use UltraguardPro\core\BaseController;
use UltraguardPro\core\Logger;
use UltraguardPro\Plugin;

class TwoFactorEngine extends BaseController {

    private const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
    private const METRICS_OPTION   = 'two_factor_metrics_daily';
    private const ENCRYPTION_OPTION = 'ultraguard_2fa_encryption_key';
    private const REENROL_NOTICE_OPTION = 'ultraguard_2fa_reenrol_required';
    private const QR_SIZE = 200;
    private const QR_QUIET_ZONE = 4;

    public function __construct( ?Plugin $plugin = null ) {
        parent::__construct( $plugin );
    }

    /**
     * Generate a new base32 TOTP secret.
     */
    public function generate_secret(): string {
        $secret = '';
        for ( $i = 0; $i < 16; $i++ ) {
            $secret .= self::BASE32_ALPHABET[ random_int( 0, strlen( self::BASE32_ALPHABET ) - 1 ) ];
        }

        return $secret;
    }

    /**
     * Generate a server-side QR image as a PNG data URI.
     *
     * The TOTP secret never leaves the server. If QR generation is unavailable,
     * the caller receives an empty string and can still display the raw secret.
     */
    public function generate_qr_datauri( string $secret, string $user_login, string $issuer ): string {
        if ( ! function_exists( 'imagecreatetruecolor' ) || ! function_exists( 'imagepng' ) ) {
            Logger::warning( '[TwoFactorEngine] GD is unavailable; QR code generation skipped.' );
            return '';
        }

        $barcode = $this->build_qr_matrix( $this->build_otpauth_uri( $secret, $user_login, $issuer ) );
        if ( empty( $barcode['bcode'] ) || empty( $barcode['num_rows'] ) ) {
            Logger::warning( '[TwoFactorEngine] QR matrix generation failed.' );
            return '';
        }

        $image = imagecreatetruecolor( self::QR_SIZE, self::QR_SIZE );
        if ( false === $image ) {
            Logger::warning( '[TwoFactorEngine] Could not allocate QR image resource.' );
            return '';
        }

        $white = imagecolorallocate( $image, 255, 255, 255 );
        $black = imagecolorallocate( $image, 0, 0, 0 );

        imagefill( $image, 0, 0, $white );

        $modules      = (int) $barcode['num_rows'];
        $module_px    = max( 1, (int) floor( self::QR_SIZE / ( $modules + ( self::QR_QUIET_ZONE * 2 ) ) ) );
        $render_size  = ( $modules + ( self::QR_QUIET_ZONE * 2 ) ) * $module_px;
        $offset       = (int) floor( ( self::QR_SIZE - $render_size ) / 2 );

        foreach ( $barcode['bcode'] as $row_index => $row ) {
            foreach ( $row as $col_index => $cell ) {
                if ( empty( $cell ) ) {
                    continue;
                }

                $x1 = $offset + ( $col_index + self::QR_QUIET_ZONE ) * $module_px;
                $y1 = $offset + ( $row_index + self::QR_QUIET_ZONE ) * $module_px;
                $x2 = $x1 + $module_px - 1;
                $y2 = $y1 + $module_px - 1;
                imagefilledrectangle( $image, $x1, $y1, $x2, $y2, $black );
            }
        }

        ob_start();
        imagepng( $image );
        $png = ob_get_clean();
        imagedestroy( $image );

        if ( ! is_string( $png ) || '' === $png ) {
            Logger::warning( '[TwoFactorEngine] QR PNG encoding failed.' );
            return '';
        }

        return 'data:image/png;base64,' . base64_encode( $png );
    }

    /**
     * Verify a 6-digit TOTP code within the standard +/-1 timestep window.
     */
    public function verify_code( string $secret, string $code, int $last_used_timestamp = 0 ): bool {
        $code = preg_replace( '/\D+/', '', $code );
        if ( strlen( $code ) !== 6 ) {
            return false;
        }

        $step = (int) floor( time() / 30 );
        for ( $offset = -1; $offset <= 1; $offset++ ) {
            $counter = $step + $offset;
            if ( $counter <= 0 ) {
                continue;
            }

            $otp = $this->hotp( $secret, $counter );
            if ( ! hash_equals( $otp, $code ) ) {
                continue;
            }

            if ( $last_used_timestamp > 0 && (int) floor( $last_used_timestamp / 30 ) === $counter ) {
                return false;
            }

            return true;
        }

        return false;
    }

    /**
     * Generate a set of one-time backup codes.
     */
    public function generate_backup_codes( int $count = 8 ): array {
        $count = max( 1, min( 20, $count ) );
        $codes = [];

        while ( count( $codes ) < $count ) {
            $codes[] = strtoupper( substr( bin2hex( random_bytes( 6 ) ), 0, 8 ) );
            $codes   = array_values( array_unique( $codes ) );
        }

        return $codes;
    }

    /**
     * Attempt to consume a user backup code through the credentials table.
     */
    public function verify_backup_code( int $user_id, string $code ): bool {
        $table = $this->getTable( 'totp_credentials' );
        if ( ! $table || ! method_exists( $table, 'consume_backup_code' ) ) {
            return false;
        }

        return (bool) $table->consume_backup_code( $user_id, $code );
    }

    /**
     * Encrypt a TOTP secret with the stable UltraGuard encryption key.
     */
    public function encrypt_secret( string $secret ): string {
        $method = 'aes-256-cbc';
        $key    = $this->get_encryption_key();
        $iv_len = openssl_cipher_iv_length( $method );
        $iv     = random_bytes( $iv_len );
        $raw    = openssl_encrypt( $secret, $method, $key, OPENSSL_RAW_DATA, $iv );

        if ( false === $raw ) {
            return $secret;
        }

        return base64_encode( $iv . $raw );
    }

    /**
     * Decrypt a stored TOTP secret with the stable UltraGuard encryption key.
     */
    public function decrypt_secret( string $encrypted ): string {
        $decoded = base64_decode( $encrypted, true );
        if ( false === $decoded ) {
            return $encrypted;
        }

        $method = 'aes-256-cbc';
        $key    = $this->get_encryption_key();
        $iv_len = openssl_cipher_iv_length( $method );
        $iv     = substr( $decoded, 0, $iv_len );
        $raw    = substr( $decoded, $iv_len );
        $value  = openssl_decrypt( $raw, $method, $key, OPENSSL_RAW_DATA, $iv );

        return false === $value ? '' : (string) $value;
    }

    /**
     * Hash backup codes with bcrypt for safe at-rest storage.
     */
    public function hash_backup_codes( array $codes ): array {
        return array_map(
            static function ( string $code ): string {
                return (string) password_hash( strtoupper( sanitize_text_field( $code ) ), PASSWORD_BCRYPT );
            },
            $codes
        );
    }

    /**
     * Return daily success/failure metrics for the requested time window.
     */
    public function get_metrics( int $days = 30 ): array {
        $days    = max( 1, min( 90, $days ) );
        $metrics = get_option( self::METRICS_OPTION, [] );
        $labels  = [];
        $success = [];
        $failure = [];

        for ( $i = $days - 1; $i >= 0; $i-- ) {
            $date      = gmdate( 'Y-m-d', strtotime( '-' . $i . ' days' ) );
            $labels[]  = gmdate( 'M j', strtotime( $date . ' 00:00:00 UTC' ) );
            $success[] = (int) ( $metrics[ $date ]['success'] ?? 0 );
            $failure[] = (int) ( $metrics[ $date ]['failure'] ?? 0 );
        }

        return [
            'labels'  => $labels,
            'success' => $success,
            'failure' => $failure,
        ];
    }

    /**
     * Return the number of failed 2FA attempts recorded today.
     */
    public function count_blocked_today(): int {
        $metrics = get_option( self::METRICS_OPTION, [] );
        $today   = gmdate( 'Y-m-d' );
        return (int) ( $metrics[ $today ]['failure'] ?? 0 );
    }

    /**
     * Increment the daily success/failure metrics counter.
     */
    public function record_metric( string $type ): void {
        $type    = $type === 'success' ? 'success' : 'failure';
        $date    = gmdate( 'Y-m-d' );
        $metrics = get_option( self::METRICS_OPTION, [] );

        if ( ! isset( $metrics[ $date ] ) || ! is_array( $metrics[ $date ] ) ) {
            $metrics[ $date ] = [ 'success' => 0, 'failure' => 0 ];
        }

        $metrics[ $date ][ $type ]++;

        if ( count( $metrics ) > 45 ) {
            ksort( $metrics );
            $metrics = array_slice( $metrics, -45, null, true );
        }

        update_option( self::METRICS_OPTION, $metrics, false );
    }

    /**
     * Return the stable encryption key used for stored TOTP secrets.
     */
    private function get_encryption_key(): string {
        $key = get_option( self::ENCRYPTION_OPTION );
        $raw = is_string( $key ) ? base64_decode( $key, true ) : false;

        if ( is_string( $raw ) && strlen( $raw ) === 32 ) {
            return $raw;
        }

        if ( $this->has_existing_credentials() ) {
            update_option( self::REENROL_NOTICE_OPTION, 1, false );
        }

        $key = base64_encode( random_bytes( 32 ) );
        if ( ! add_option( self::ENCRYPTION_OPTION, $key, '', false ) ) {
            update_option( self::ENCRYPTION_OPTION, $key, false );
        }

        $raw = base64_decode( $key, true );
        return is_string( $raw ) && strlen( $raw ) === 32 ? $raw : random_bytes( 32 );
    }

    /**
     * Build the otpauth URI used by authenticator applications.
     */
    private function build_otpauth_uri( string $secret, string $user_login, string $issuer ): string {
        $label = rawurlencode( $issuer . ':' . $user_login );

        return sprintf(
            'otpauth://totp/%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30',
            $label,
            rawurlencode( $secret ),
            rawurlencode( $issuer )
        );
    }

    /**
     * Build a QR matrix using the bundled TCPDF QR encoder.
     *
     * @return array<string,mixed>
     */
    private function build_qr_matrix( string $payload ): array {
        $qr_file = dirname( __DIR__ ) . '/compliance_reports/vendor/TCPDF-main/include/barcodes/qrcode.php';
        if ( ! file_exists( $qr_file ) ) {
            Logger::warning( '[TwoFactorEngine] Bundled QR encoder not found.', [ 'path' => $qr_file ] );
            return [];
        }

        require_once $qr_file;

        if ( ! class_exists( '\QRcode' ) ) {
            Logger::warning( '[TwoFactorEngine] Bundled QR encoder class missing after include.' );
            return [];
        }

        try {
            $qr = new \QRcode( $payload, 'M' );
            return is_object( $qr ) && method_exists( $qr, 'getBarcodeArray' )
                ? (array) $qr->getBarcodeArray()
                : [];
        } catch ( \Throwable $throwable ) {
            Logger::warning( '[TwoFactorEngine] QR encoder threw an exception.', [
                'message' => $throwable->getMessage(),
            ] );
            return [];
        }
    }

    /**
     * Detect whether enrolled TOTP credentials already exist.
     */
    private function has_existing_credentials(): bool {
        $table = $this->getTable( 'totp_credentials' );

        return $table && method_exists( $table, 'count' ) && (int) $table->count() > 0;
    }

    /**
     * Generate an HOTP value for a given counter.
     */
    private function hotp( string $secret, int $counter ): string {
        $binary_secret = $this->base32_decode( $secret );
        if ( '' === $binary_secret ) {
            return '';
        }

        $bin_counter = pack( 'N*', 0 ) . pack( 'N*', $counter );
        $hash        = hash_hmac( 'sha1', $bin_counter, $binary_secret, true );
        $offset      = ord( substr( $hash, -1 ) ) & 0x0F;
        $segment     = substr( $hash, $offset, 4 );
        $value       = unpack( 'N', $segment )[1] & 0x7FFFFFFF;

        return str_pad( (string) ( $value % 1000000 ), 6, '0', STR_PAD_LEFT );
    }

    /**
     * Decode a base32-encoded TOTP secret into binary.
     */
    private function base32_decode( string $secret ): string {
        $secret = strtoupper( preg_replace( '/[^A-Z2-7]/', '', $secret ) );
        if ( '' === $secret ) {
            return '';
        }

        $bits   = '';
        $output = '';

        foreach ( str_split( $secret ) as $char ) {
            $pos = strpos( self::BASE32_ALPHABET, $char );
            if ( false === $pos ) {
                return '';
            }
            $bits .= str_pad( decbin( $pos ), 5, '0', STR_PAD_LEFT );
        }

        foreach ( str_split( $bits, 8 ) as $chunk ) {
            if ( strlen( $chunk ) === 8 ) {
                $output .= chr( bindec( $chunk ) );
            }
        }

        return $output;
    }
}
