<?php
/**
 * TOTP operations for authenticator addon.
 *
 * @package Polanger_Dashboard_Tools
 */

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

class PDT_Authenticator_TOTP {

  /**
   * Ensure current user has a TOTP secret.
   *
   * @param int $user_id User ID.
   * @return string
   */
  public function get_or_create_secret( $user_id ) {
    $secret = $this->get_stored_secret( $user_id );
    if ( ! empty( $secret ) ) {
      return (string) $secret;
    }

    $secret = $this->generate_base32_secret();
    $this->store_secret( $user_id, $secret );
    update_user_meta( $user_id, 'pdt_auth_totp_created_at', time() );

    return $secret;
  }

  /**
   * Check whether user completed TOTP enrollment.
   *
   * @param int $user_id User ID.
   * @return bool
   */
  public function is_user_enrolled( $user_id ) {
    return ! empty( get_user_meta( $user_id, 'pdt_auth_totp_verified', true ) ) && '' !== $this->get_stored_secret( $user_id );
  }

  /**
   * Mark user as enrolled after setup verification.
   *
   * @param int $user_id User ID.
   * @return void
   */
  public function mark_user_enrolled( $user_id ) {
    update_user_meta( $user_id, 'pdt_auth_totp_verified', 1 );
  }

  /**
   * Create a pending secret for rotation.
   * Old secret remains active until pending is verified.
   *
   * @param int $user_id User ID.
   * @return string The new pending secret.
   */
  public function create_pending_secret( $user_id ) {
    $secret = $this->generate_base32_secret();
    $encrypted = $this->encrypt_secret( $secret );
    if ( '' !== $encrypted ) {
      update_user_meta( $user_id, 'pdt_auth_totp_pending_secret', $encrypted );
      update_user_meta( $user_id, 'pdt_auth_totp_pending_created_at', time() );
    }
    return $secret;
  }

  /**
   * Check if user has a pending secret awaiting verification.
   *
   * @param int $user_id User ID.
   * @return bool
   */
  public function has_pending_secret( $user_id ) {
    $pending = get_user_meta( $user_id, 'pdt_auth_totp_pending_secret', true );
    return ! empty( $pending );
  }

  /**
   * Get the pending secret for display/verification.
   *
   * @param int $user_id User ID.
   * @return string
   */
  public function get_pending_secret( $user_id ) {
    $stored = get_user_meta( $user_id, 'pdt_auth_totp_pending_secret', true );
    if ( ! is_string( $stored ) || '' === $stored ) {
      return '';
    }

    if ( 0 === strpos( $stored, 'enc:' ) ) {
      return $this->decrypt_secret( $stored );
    }

    return strtoupper( preg_replace( '/[^A-Z2-7]/', '', $stored ) );
  }

  /**
   * Activate pending secret after successful verification.
   * Replaces active secret with pending, clears pending.
   *
   * @param int $user_id User ID.
   * @return bool
   */
  public function activate_pending_secret( $user_id ) {
    $pending = get_user_meta( $user_id, 'pdt_auth_totp_pending_secret', true );
    if ( empty( $pending ) ) {
      return false;
    }

    // Move pending to active
    update_user_meta( $user_id, 'pdt_auth_totp_secret', $pending );
    update_user_meta( $user_id, 'pdt_auth_totp_created_at', time() );
    
    // Clear pending
    delete_user_meta( $user_id, 'pdt_auth_totp_pending_secret' );
    delete_user_meta( $user_id, 'pdt_auth_totp_pending_created_at' );
    
    // Reset replay protection for new secret
    delete_user_meta( $user_id, '_pdt_totp_last_used_slice' );
    
    // Keep user enrolled
    update_user_meta( $user_id, 'pdt_auth_totp_verified', 1 );
    
    return true;
  }

  /**
   * Cancel pending secret rotation.
   *
   * @param int $user_id User ID.
   * @return void
   */
  public function cancel_pending_secret( $user_id ) {
    delete_user_meta( $user_id, 'pdt_auth_totp_pending_secret' );
    delete_user_meta( $user_id, 'pdt_auth_totp_pending_created_at' );
  }

  /**
   * Verify code against pending secret only.
   * Used during secret rotation verification.
   *
   * @param int    $user_id User ID.
   * @param string $submitted Submitted 6-digit code.
   * @return bool
   */
  public function verify_pending_code( $user_id, $submitted ) {
    $submitted = preg_replace( '/\s+/', '', (string) $submitted );
    if ( ! preg_match( '/^\d{6}$/', $submitted ) ) {
      return false;
    }

    $secret = $this->get_pending_secret( $user_id );
    if ( empty( $secret ) ) {
      return false;
    }

    $window = 1;
    $time_slice = (int) floor( time() / 30 );

    for ( $offset = -$window; $offset <= $window; $offset++ ) {
      $candidate_slice = $time_slice + $offset;
      $expected = $this->generate_totp( $secret, $candidate_slice );
      if ( hash_equals( (string) $expected, (string) $submitted ) ) {
        return true;
      }
    }

    return false;
  }

  /**
   * Legacy method for backwards compatibility.
   * Creates pending secret instead of immediate rotation.
   *
   * @param int $user_id User ID.
   * @return string
   * @deprecated Use create_pending_secret() instead.
   */
  public function regenerate_secret( $user_id ) {
    return $this->create_pending_secret( $user_id );
  }

  /**
   * Disable authenticator setup for user.
   *
   * @param int $user_id User ID.
   * @return void
   */
  public function disable_for_user( $user_id ) {
    delete_user_meta( $user_id, 'pdt_auth_totp_secret' );
    delete_user_meta( $user_id, 'pdt_auth_totp_verified' );
    delete_user_meta( $user_id, '_pdt_totp_last_used_slice' );
  }

  /**
   * Get provisioning URI for authenticator apps.
   *
   * @param int $user_id User ID.
   * @return string
   */
  public function get_provisioning_uri( $user_id ) {
    $user = get_userdata( $user_id );
    if ( ! $user ) {
      return '';
    }

    $secret = $this->get_or_create_secret( $user_id );
    $issuer = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES );
    $label = $issuer . ':' . $user->user_login;

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

  /**
   * Verify TOTP code for a user.
   *
   * @param int    $user_id User ID.
   * @param string $submitted Submitted 6-digit code.
   * @return bool
   */
  public function verify_code( $user_id, $submitted ) {
    $submitted = preg_replace( '/\s+/', '', (string) $submitted );
    if ( ! preg_match( '/^\d{6}$/', $submitted ) ) {
      return false;
    }

    $secret = $this->get_stored_secret( $user_id );
    if ( empty( $secret ) ) {
      return false;
    }

    $window = 1;
    $time_slice = (int) floor( time() / 30 );
    $last_used_slice = (int) get_user_meta( $user_id, '_pdt_totp_last_used_slice', true );

    for ( $offset = -$window; $offset <= $window; $offset++ ) {
      $candidate_slice = $time_slice + $offset;
      $expected = $this->generate_totp( $secret, $candidate_slice );
      if ( hash_equals( (string) $expected, (string) $submitted ) ) {
        // Prevent replay: deny same-or-older successfully used time slices.
        if ( $candidate_slice <= $last_used_slice ) {
          return false;
        }
        update_user_meta( $user_id, '_pdt_totp_last_used_slice', $candidate_slice );
        return true;
      }
    }

    return false;
  }

  /**
   * Generate random base32 secret.
   *
   * @param int $length Length.
   * @return string
   */
  private function generate_base32_secret( $length = 32 ) {
    $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
    $secret = '';
    for ( $i = 0; $i < $length; $i++ ) {
      $secret .= $alphabet[ random_int( 0, 31 ) ];
    }
    return $secret;
  }

  /**
   * Generate a TOTP code from base32 secret.
   *
   * @param string $base32_secret Secret.
   * @param int    $time_slice Time slice.
   * @return string
   */
  private function generate_totp( $base32_secret, $time_slice ) {
    $secret = $this->base32_decode( $base32_secret );
    if ( '' === $secret ) {
      return '000000';
    }

    $time = pack( 'N*', 0 ) . pack( 'N*', $time_slice );
    $hash = hash_hmac( 'sha1', $time, $secret, true );
    $offset = ord( substr( $hash, -1 ) ) & 0x0F;
    $value = unpack( 'N', substr( $hash, $offset, 4 ) );
    $value = $value[1] & 0x7FFFFFFF;
    $modulo = $value % 1000000;

    return str_pad( (string) $modulo, 6, '0', STR_PAD_LEFT );
  }

  /**
   * Decode base32 into binary string.
   *
   * @param string $base32 Base32 input.
   * @return string
   */
  private function base32_decode( $base32 ) {
    $base32 = strtoupper( preg_replace( '/[^A-Z2-7]/', '', (string) $base32 ) );
    $map = array(
      'A' => 0,  'B' => 1,  'C' => 2,  'D' => 3,
      'E' => 4,  'F' => 5,  'G' => 6,  'H' => 7,
      'I' => 8,  'J' => 9,  'K' => 10, 'L' => 11,
      'M' => 12, 'N' => 13, 'O' => 14, 'P' => 15,
      'Q' => 16, 'R' => 17, 'S' => 18, 'T' => 19,
      'U' => 20, 'V' => 21, 'W' => 22, 'X' => 23,
      'Y' => 24, 'Z' => 25, '2' => 26, '3' => 27,
      '4' => 28, '5' => 29, '6' => 30, '7' => 31,
    );

    $binary = '';
    $buffer = 0;
    $bits_left = 0;
    $length = strlen( $base32 );
    for ( $i = 0; $i < $length; $i++ ) {
      $char = $base32[ $i ];
      if ( ! isset( $map[ $char ] ) ) {
        continue;
      }
      $buffer = ( $buffer << 5 ) | $map[ $char ];
      $bits_left += 5;
      if ( $bits_left >= 8 ) {
        $bits_left -= 8;
        $binary .= chr( ( $buffer >> $bits_left ) & 0xFF );
      }
    }

    return $binary;
  }

  /**
   * Load and decrypt stored TOTP secret (with legacy migration).
   *
   * @param int $user_id User ID.
   * @return string
   */
  private function get_stored_secret( $user_id ) {
    $stored = get_user_meta( $user_id, 'pdt_auth_totp_secret', true );
    if ( ! is_string( $stored ) || '' === $stored ) {
      return '';
    }

    if ( 0 === strpos( $stored, 'enc:' ) ) {
      return $this->decrypt_secret( $stored );
    }

    // Legacy plaintext migration.
    $legacy_secret = strtoupper( preg_replace( '/[^A-Z2-7]/', '', $stored ) );
    if ( '' === $legacy_secret ) {
      return '';
    }
    $this->store_secret( $user_id, $legacy_secret );
    return $legacy_secret;
  }

  /**
   * Encrypt and store secret.
   *
   * @param int    $user_id User ID.
   * @param string $secret Secret.
   * @return void
   */
  private function store_secret( $user_id, $secret ) {
    $encrypted = $this->encrypt_secret( $secret );
    if ( '' === $encrypted ) {
      return;
    }
    update_user_meta( $user_id, 'pdt_auth_totp_secret', $encrypted );
  }

  /**
   * Get active cryptographic key material.
   *
   * @return string
   */
  private function get_secret_key() {
    return hash( 'sha256', wp_salt( 'auth' ) . wp_salt( 'secure_auth' ), true );
  }

  /**
   * XOR input bytes with HMAC-derived keystream.
   *
   * @param string $input Input bytes.
   * @param string $key Key bytes.
   * @param string $nonce Nonce bytes.
   * @return string
   */
  private function xor_keystream( $input, $key, $nonce ) {
    $output = '';
    $counter = 0;
    $offset = 0;
    $length = strlen( $input );

    while ( $offset < $length ) {
      $block = hash_hmac( 'sha256', $nonce . pack( 'N', $counter ), $key, true );
      $counter++;
      $chunk = substr( $input, $offset, 32 );
      $output .= $chunk ^ substr( $block, 0, strlen( $chunk ) );
      $offset += strlen( $chunk );
    }

    return $output;
  }

  /**
   * Encode encrypted payload.
   *
   * @param string $version Version tag.
   * @param string $iv IV/nonce bytes.
   * @param string $ciphertext Ciphertext bytes.
   * @param string $mac HMAC bytes.
   * @return string
   */
  private function encode_payload( $version, $iv, $ciphertext, $mac ) {
    return 'enc:' . $version . ':' . base64_encode( $iv . $mac . $ciphertext );
  }

  /**
   * Decode encrypted payload.
   *
   * @param string $payload Stored payload.
   * @return array|null
   */
  private function decode_payload( $payload ) {
    if ( ! preg_match( '/^enc:(v[0-9]+):/', $payload, $matches ) ) {
      return null;
    }

    $version = $matches[1];
    $encoded = substr( $payload, strlen( 'enc:' . $version . ':' ) );
    $raw = base64_decode( $encoded, true );
    if ( false === $raw || strlen( $raw ) < 49 ) {
      return null;
    }

    return array(
      'version'    => $version,
      'iv'         => substr( $raw, 0, 16 ),
      'mac'        => substr( $raw, 16, 32 ),
      'ciphertext' => substr( $raw, 48 ),
    );
  }

  /**
   * Encrypt secret at rest.
   *
   * @param string $secret Plain secret.
   * @return string
   */
  private function encrypt_secret( $secret ) {
    if ( ! function_exists( 'random_bytes' ) ) {
      return '';
    }

    $key = $this->get_secret_key();
    $iv = random_bytes( 16 );

    if ( function_exists( 'openssl_encrypt' ) ) {
      $ciphertext = openssl_encrypt( $secret, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv );
      if ( false === $ciphertext ) {
        return '';
      }
      $mac = hash_hmac( 'sha256', $iv . $ciphertext, $key, true );
      return $this->encode_payload( 'v1', $iv, $ciphertext, $mac );
    }

    $ciphertext = $this->xor_keystream( $secret, $key, $iv );
    $mac = hash_hmac( 'sha256', $iv . $ciphertext, $key, true );
    return $this->encode_payload( 'v2', $iv, $ciphertext, $mac );
  }

  /**
   * Decrypt stored secret.
   *
   * @param string $payload Encrypted payload.
   * @return string
   */
  private function decrypt_secret( $payload ) {
    $parts = $this->decode_payload( $payload );
    if ( ! is_array( $parts ) ) {
      return '';
    }

    $iv = $parts['iv'];
    $mac = $parts['mac'];
    $ciphertext = $parts['ciphertext'];
    $key = $this->get_secret_key();
    $expected_mac = hash_hmac( 'sha256', $iv . $ciphertext, $key, true );
    if ( ! hash_equals( $expected_mac, $mac ) ) {
      return '';
    }

    if ( 'v1' === $parts['version'] ) {
      if ( ! function_exists( 'openssl_decrypt' ) ) {
        return '';
      }
      $secret = openssl_decrypt( $ciphertext, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv );
      if ( ! is_string( $secret ) ) {
        return '';
      }
      return strtoupper( preg_replace( '/[^A-Z2-7]/', '', $secret ) );
    }

    if ( 'v2' === $parts['version'] ) {
      $secret = $this->xor_keystream( $ciphertext, $key, $iv );
      return strtoupper( preg_replace( '/[^A-Z2-7]/', '', $secret ) );
    }

    return '';
  }
}
