<?php
/**
 * Google Calendar connection and secure token storage.
 *
 * Credentials and OAuth tokens are stored in the WordPress database, encrypted
 * with authenticated encryption (libsodium) under a key derived from the site's
 * own salts — never in files inside the plugin directory. The previous generation of this plugin wrote credentials
 * and the live access token to /creds/*.json inside the plugin folder, which
 * left them reachable over the open web. Nothing sensitive is ever written to
 * a web-accessible path here.
 *
 * @package DrezSync
 */

defined( 'ABSPATH' ) || exit;

/**
 * Handles storing, retrieving, and refreshing the Google connection.
 */
class DrezSync_Connection {

	const OPTION_CREDENTIALS = 'drezsync_google_credentials';
	const OPTION_TOKEN       = 'drezsync_google_token';
	const CIPHER             = 'aes-256-cbc';
	const SCOPE              = 'https://www.googleapis.com/auth/calendar';

	/**
	 * Is a usable connection stored (credentials plus a token)?
	 *
	 * @return bool
	 */
	public function is_connected() {
		return (bool) $this->get_credentials() && (bool) $this->get_token();
	}

	/**
	 * Store the OAuth client credentials — the contents of the credentials.json
	 * the site owner downloads from Google Cloud.
	 *
	 * @param string $json Raw JSON string.
	 * @return bool True if valid credentials were saved.
	 */
	public function save_credentials( $json ) {
		$data = json_decode( (string) $json, true );
		if ( ! is_array( $data ) || ( ! isset( $data['web'] ) && ! isset( $data['installed'] ) ) ) {
			return false;
		}
		return update_option( self::OPTION_CREDENTIALS, $this->encrypt( wp_json_encode( $data ) ), false );
	}

	/**
	 * Retrieve the decrypted OAuth client credentials.
	 *
	 * @return array|null
	 */
	public function get_credentials() {
		$stored = get_option( self::OPTION_CREDENTIALS );
		if ( ! $stored ) {
			return null;
		}
		$json = $this->decrypt( $stored );
		$data = $json ? json_decode( $json, true ) : null;
		return is_array( $data ) ? $data : null;
	}

	/**
	 * Store the OAuth access/refresh token.
	 *
	 * @param array $token Token array from the Google client.
	 * @return bool
	 */
	public function save_token( array $token ) {
		return update_option( self::OPTION_TOKEN, $this->encrypt( wp_json_encode( $token ) ), false );
	}

	/**
	 * Retrieve the decrypted OAuth token.
	 *
	 * @return array|null
	 */
	public function get_token() {
		$stored = get_option( self::OPTION_TOKEN );
		if ( ! $stored ) {
			return null;
		}
		$json  = $this->decrypt( $stored );
		$token = $json ? json_decode( $json, true ) : null;
		return is_array( $token ) ? $token : null;
	}

	/**
	 * Disconnect: remove all stored credentials and tokens.
	 */
	public function disconnect() {
		delete_option( self::OPTION_CREDENTIALS );
		delete_option( self::OPTION_TOKEN );
	}

	/**
	 * Remove the Google authorization but keep the stored credentials.
	 *
	 * Best-effort: ask Google to revoke the token, then delete it locally. The
	 * credentials stay, so reconnecting is a single click rather than another
	 * paste of the whole credentials file.
	 */
	public function revoke() {
		try {
			$client = $this->get_client();
			if ( $client && $this->get_token() ) {
				$client->revokeToken();
			}
		} catch ( \Exception $e ) {
			// Best effort only; we remove the token locally regardless.
		}
		delete_option( self::OPTION_TOKEN );
	}

	/**
	 * Load the Google API client library.
	 *
	 * The client is a Composer dependency (google/apiclient), trimmed to the
	 * Calendar service, and is autoloaded from /vendor. Freemius normally loads
	 * the same autoloader earlier, so this is usually a no-op, but it is kept so
	 * the Google side never depends on that ordering.
	 *
	 * @return bool True if the library is available.
	 */
	private function load_library() {
		if ( class_exists( '\Google\Client' ) ) {
			return true;
		}
		$autoload = DREZSYNC_DIR . 'vendor/autoload.php';
		if ( file_exists( $autoload ) ) {
			require_once $autoload;
			return class_exists( '\Google\Client' );
		}
		return false;
	}

	/**
	 * Build a client for the initial OAuth handshake, before a token exists.
	 *
	 * @param string $redirect_uri Where Google returns the user after consent.
	 * @return \Google\Client|null
	 */
	public function get_oauth_client( $redirect_uri ) {
		if ( ! $this->load_library() ) {
			return null;
		}
		$credentials = $this->get_credentials();
		if ( ! $credentials ) {
			return null;
		}
		$client = new \Google\Client();
		$client->setAuthConfig( $credentials );
		$client->setScopes( array( self::SCOPE ) );
		$client->setAccessType( 'offline' );
		$client->setPrompt( 'consent' );
		$client->setRedirectUri( $redirect_uri );
		return $client;
	}

	/**
	 * Build an authorized Google client from the stored connection, refreshing
	 * and re-saving the token to the database when it has expired.
	 *
	 * Returns null until the Google API client library is bundled into /vendor
	 * (added later in Phase 1). Structuring it this way keeps the plugin from
	 * fatal-erroring before the library is present.
	 *
	 * @return \Google\Client|null
	 */
	public function get_client() {
		if ( ! $this->load_library() ) {
			return null;
		}

		$credentials = $this->get_credentials();
		if ( ! $credentials || ! class_exists( '\Google\Client' ) ) {
			return null;
		}

		$client = new \Google\Client();
		$client->setAuthConfig( $credentials );
		$client->setScopes( array( self::SCOPE ) );
		$client->setAccessType( 'offline' );

		$token = $this->get_token();
		if ( $token ) {
			$client->setAccessToken( $token );
		}

		if ( $client->isAccessTokenExpired() ) {
			$refresh = $client->getRefreshToken();
			if ( $refresh ) {
				$new = $client->fetchAccessTokenWithRefreshToken( $refresh );
				if ( empty( $new['error'] ) ) {
					// Google omits the refresh token on refresh — keep the existing one.
					if ( empty( $new['refresh_token'] ) ) {
						$new['refresh_token'] = $refresh;
					}
					$this->save_token( $new );
				}
			}
		}

		return $client;
	}

	/**
	 * Encryption key derived from the site's WordPress salts.
	 *
	 * @return string 32 raw bytes.
	 */
	private function key() {
		$material = ( defined( 'AUTH_KEY' ) ? AUTH_KEY : '' ) . ( defined( 'AUTH_SALT' ) ? AUTH_SALT : '' );
		if ( '' === $material ) {
			$material = wp_salt( 'auth' );
		}
		return hash( 'sha256', $material, true );
	}

	/**
	 * Encrypt a string for storage.
	 *
	 * Prefers authenticated encryption via libsodium (guaranteed on PHP 7.4+),
	 * which protects both confidentiality and integrity. Falls back to
	 * AES-256-CBC, then to base64 only if neither crypto extension is present.
	 *
	 * @param string $plaintext Value to encrypt.
	 * @return string
	 */
	private function encrypt( $plaintext ) {
		$key = $this->key();

		if ( function_exists( 'sodium_crypto_secretbox' ) ) {
			$nonce  = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
			$cipher = sodium_crypto_secretbox( $plaintext, $nonce, $key );
			return 'sm:' . base64_encode( $nonce . $cipher );
		}

		if ( function_exists( 'openssl_encrypt' ) ) {
			$iv         = random_bytes( openssl_cipher_iv_length( self::CIPHER ) );
			$ciphertext = openssl_encrypt( $plaintext, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv );
			return 'enc:' . base64_encode( $iv . $ciphertext );
		}

		// Last-resort fallback so a raw option read still isn't the secret.
		return 'b64:' . base64_encode( $plaintext );
	}

	/**
	 * Decrypt a stored string. Handles every format encrypt() may have written.
	 *
	 * @param string $stored Stored value.
	 * @return string Empty string on failure.
	 */
	private function decrypt( $stored ) {
		$key = $this->key();

		if ( 0 === strpos( $stored, 'sm:' ) && function_exists( 'sodium_crypto_secretbox_open' ) ) {
			$raw      = base64_decode( substr( $stored, 3 ) );
			$noncelen = SODIUM_CRYPTO_SECRETBOX_NONCEBYTES;
			if ( strlen( $raw ) <= $noncelen ) {
				return '';
			}
			$nonce  = substr( $raw, 0, $noncelen );
			$cipher = substr( $raw, $noncelen );
			$plain  = sodium_crypto_secretbox_open( $cipher, $nonce, $key );
			return false === $plain ? '' : $plain;
		}

		if ( 0 === strpos( $stored, 'enc:' ) && function_exists( 'openssl_decrypt' ) ) {
			$raw        = base64_decode( substr( $stored, 4 ) );
			$ivlen      = openssl_cipher_iv_length( self::CIPHER );
			$iv         = substr( $raw, 0, $ivlen );
			$ciphertext = substr( $raw, $ivlen );
			$plaintext  = openssl_decrypt( $ciphertext, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv );
			return false === $plaintext ? '' : $plaintext;
		}

		if ( 0 === strpos( $stored, 'b64:' ) ) {
			return (string) base64_decode( substr( $stored, 4 ) );
		}

		return '';
	}
}
