<?php
/**
 * Google Auth Service.
 *
 * Manages credentials and access-token minting for Google API clients
 * (PageSpeed Insights, Search Console, GA4, Indexing API).
 *
 * Two credential types are supported:
 *   1. PageSpeed API key — a simple string, used only for the public PSI v5 API.
 *   2. Service-account JSON — used for OAuth2-protected APIs (GSC, GA4, Indexing).
 *      The user grants the service account access to their GSC property /
 *      GA4 property and we mint short-lived access tokens via JWT signing.
 *
 * Both credential blobs are encrypted at rest with AES-256-CBC via
 * Antimanual\Encryption (LOGGED_IN_KEY/SALT-derived). Access tokens are cached
 * in WordPress transients keyed by the credential fingerprint and scope.
 *
 * @package Antimanual\Seo\Google
 * @since 3.7.0
 */

namespace Antimanual\Seo\Google;

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

use Antimanual\Encryption;

/**
 * Google credential and access-token manager.
 */
class GoogleAuthService {

	/** WP option key holding the encrypted PSI API key. */
	private const OPT_PSI_KEY = 'antimanual_google_psi_api_key';

	/** WP option key holding the encrypted service-account JSON. */
	private const OPT_SA_JSON = 'antimanual_google_sa_json';

	/** Transient key prefix for cached access tokens. */
	private const TOKEN_CACHE_PREFIX = 'atml_google_token_';

	/** OAuth2 token endpoint. */
	private const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';

	/** Singleton instance. */
	private static ?self $instance = null;

	/**
	 * Get the singleton instance.
	 *
	 * @return self
	 */
	public static function instance(): self {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Save the PageSpeed Insights API key.
	 *
	 * Empty string clears the stored key.
	 *
	 * @param string $key Plain-text API key.
	 * @return bool True on success.
	 */
	public function save_psi_key( string $key ): bool {
		$key = trim( $key );
		if ( '' === $key ) {
			return delete_option( self::OPT_PSI_KEY );
		}

		$encrypted = Encryption::encrypt( $key );
		if ( false === $encrypted || '' === $encrypted ) {
			return false;
		}

		return update_option( self::OPT_PSI_KEY, $encrypted, false );
	}

	/**
	 * Get the decrypted PSI API key.
	 *
	 * @return string Empty string if not set.
	 */
	public function get_psi_key(): string {
		$stored = get_option( self::OPT_PSI_KEY, '' );
		if ( ! is_string( $stored ) || '' === $stored ) {
			return '';
		}

		if ( Encryption::is_encrypted( $stored ) ) {
			$decrypted = Encryption::decrypt( $stored );
			return is_string( $decrypted ) ? $decrypted : '';
		}

		return $stored;
	}

	/**
	 * Whether a PSI key is configured.
	 *
	 * @return bool
	 */
	public function has_psi_key(): bool {
		return '' !== $this->get_psi_key();
	}

	/**
	 * Save the service-account JSON.
	 *
	 * Validates it parses and contains the required fields. Empty string clears.
	 *
	 * @param string $json Raw JSON string.
	 * @return true|\WP_Error True on success, error on invalid input.
	 */
	public function save_service_account_json( string $json ) {
		$json = trim( $json );

		if ( '' === $json ) {
			delete_option( self::OPT_SA_JSON );
			$this->clear_token_cache();
			return true;
		}

		$decoded = json_decode( $json, true );
		if ( ! is_array( $decoded ) ) {
			return new \WP_Error(
				'invalid_json',
				__( 'Service account JSON is not valid JSON.', 'antimanual' )
			);
		}

		$required = array( 'type', 'client_email', 'private_key', 'token_uri' );
		foreach ( $required as $field ) {
			if ( empty( $decoded[ $field ] ) || ! is_string( $decoded[ $field ] ) ) {
				return new \WP_Error(
					'missing_field',
					sprintf(
						/* translators: %s: missing JSON field name. */
						__( 'Service account JSON is missing the required field "%s".', 'antimanual' ),
						$field
					)
				);
			}
		}

		if ( 'service_account' !== $decoded['type'] ) {
			return new \WP_Error(
				'wrong_type',
				__( 'JSON does not appear to be a Google service account key (type must be "service_account").', 'antimanual' )
			);
		}

		// Re-encode to canonical JSON before storing so that any extra whitespace
		// or BOM characters do not break the JWT signing later.
		$canonical = wp_json_encode( $decoded );
		if ( false === $canonical ) {
			return new \WP_Error(
				'encode_failed',
				__( 'Failed to re-encode service account JSON.', 'antimanual' )
			);
		}

		$encrypted = Encryption::encrypt( $canonical );
		if ( false === $encrypted || '' === $encrypted ) {
			return new \WP_Error(
				'encrypt_failed',
				__( 'Failed to encrypt service account JSON.', 'antimanual' )
			);
		}

		update_option( self::OPT_SA_JSON, $encrypted, false );
		$this->clear_token_cache();

		return true;
	}

	/**
	 * Get the parsed service-account credentials.
	 *
	 * @return array|null Decoded credentials, or null if not configured.
	 */
	public function get_service_account(): ?array {
		$stored = get_option( self::OPT_SA_JSON, '' );
		if ( ! is_string( $stored ) || '' === $stored ) {
			return null;
		}

		$json = Encryption::is_encrypted( $stored ) ? Encryption::decrypt( $stored ) : $stored;
		if ( ! is_string( $json ) || '' === $json ) {
			return null;
		}

		$decoded = json_decode( $json, true );
		return is_array( $decoded ) ? $decoded : null;
	}

	/**
	 * Whether a service account is configured.
	 *
	 * @return bool
	 */
	public function has_service_account(): bool {
		return null !== $this->get_service_account();
	}

	/**
	 * Get the service-account email (used as a hint to the user when granting access).
	 *
	 * @return string
	 */
	public function get_service_account_email(): string {
		$sa = $this->get_service_account();
		return ( $sa && isset( $sa['client_email'] ) ) ? (string) $sa['client_email'] : '';
	}

	/**
	 * Mint an access token for the given OAuth2 scope.
	 *
	 * Tokens are cached for slightly less than their TTL (3600s → 3300s) so we
	 * always refresh before expiry. Returns the bearer token string or WP_Error.
	 *
	 * @param string $scope Space-separated OAuth2 scopes.
	 * @return string|\WP_Error
	 */
	public function get_access_token( string $scope ) {
		$sa = $this->get_service_account();
		if ( null === $sa ) {
			return new \WP_Error(
				'no_service_account',
				__( 'Google service account is not configured.', 'antimanual' )
			);
		}

		$cache_key = self::TOKEN_CACHE_PREFIX . md5( $sa['client_email'] . '|' . $scope );
		$cached    = get_transient( $cache_key );
		if ( is_string( $cached ) && '' !== $cached ) {
			return $cached;
		}

		$jwt = $this->build_jwt( $sa, $scope );
		if ( is_wp_error( $jwt ) ) {
			return $jwt;
		}

		$response = wp_remote_post(
			self::TOKEN_ENDPOINT,
			array(
				'timeout' => 30,
				'headers' => array(
					'Content-Type' => 'application/x-www-form-urlencoded',
				),
				'body'    => array(
					'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
					'assertion'  => $jwt,
				),
			)
		);

		if ( is_wp_error( $response ) ) {
			return $response;
		}

		$status = wp_remote_retrieve_response_code( $response );
		$body   = wp_remote_retrieve_body( $response );
		$data   = json_decode( $body, true );

		if ( 200 !== (int) $status || ! is_array( $data ) || empty( $data['access_token'] ) ) {
			$error_msg = is_array( $data ) && isset( $data['error_description'] )
				? (string) $data['error_description']
				: ( is_array( $data ) && isset( $data['error'] ) ? (string) $data['error'] : 'Unknown error' );

			return new \WP_Error(
				'token_failed',
				sprintf(
					/* translators: 1: HTTP status code, 2: API error message. */
					__( 'Google token request failed (%1$d): %2$s', 'antimanual' ),
					(int) $status,
					sanitize_text_field( $error_msg )
				)
			);
		}

		$token   = (string) $data['access_token'];
		$expires = isset( $data['expires_in'] ) ? max( 60, (int) $data['expires_in'] - 300 ) : 3300;

		set_transient( $cache_key, $token, $expires );

		return $token;
	}

	/**
	 * Build and sign the JWT used to request an access token.
	 *
	 * @param array  $sa    Service account credentials.
	 * @param string $scope Space-separated OAuth2 scopes.
	 * @return string|\WP_Error Signed JWT or error.
	 */
	private function build_jwt( array $sa, string $scope ) {
		if ( ! function_exists( 'openssl_sign' ) ) {
			return new \WP_Error(
				'no_openssl',
				__( 'OpenSSL is required to sign Google API requests.', 'antimanual' )
			);
		}

		$now    = time();
		$header = array(
			'alg' => 'RS256',
			'typ' => 'JWT',
		);
		$claims = array(
			'iss'   => $sa['client_email'],
			'scope' => $scope,
			'aud'   => $sa['token_uri'] ?? self::TOKEN_ENDPOINT,
			'iat'   => $now,
			'exp'   => $now + 3600,
		);

		$segments = array(
			self::base64url_encode( (string) wp_json_encode( $header ) ),
			self::base64url_encode( (string) wp_json_encode( $claims ) ),
		);
		$signing_input = implode( '.', $segments );

		$signature  = '';
		$signed     = openssl_sign( $signing_input, $signature, $sa['private_key'], OPENSSL_ALGO_SHA256 );
		if ( ! $signed ) {
			return new \WP_Error(
				'sign_failed',
				__( 'Failed to sign the Google authentication request.', 'antimanual' )
			);
		}

		$segments[] = self::base64url_encode( $signature );

		return implode( '.', $segments );
	}

	/**
	 * Base64url-encode a binary string per RFC 7519 §3.
	 *
	 * @param string $data Raw bytes.
	 * @return string
	 */
	private static function base64url_encode( string $data ): string {
		return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' );
	}

	/**
	 * Drop all cached access tokens (called whenever credentials change).
	 */
	public function clear_token_cache(): void {
		global $wpdb;
		$like = $wpdb->esc_like( '_transient_' . self::TOKEN_CACHE_PREFIX ) . '%';
		$wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $like ) );

		$timeout_like = $wpdb->esc_like( '_transient_timeout_' . self::TOKEN_CACHE_PREFIX ) . '%';
		$wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $timeout_like ) );
	}

	/**
	 * Build the public-facing status payload (used by the admin UI).
	 *
	 * Never exposes raw credentials — only masked fingerprints / booleans.
	 *
	 * @return array
	 */
	public function get_status(): array {
		$sa = $this->get_service_account();

		return array(
			'has_psi_key'         => $this->has_psi_key(),
			'psi_key_masked'      => Encryption::mask_api_key( $this->get_psi_key() ),
			'has_service_account' => null !== $sa,
			'service_account_email' => $sa['client_email'] ?? '',
			'project_id'          => $sa['project_id'] ?? '',
			'encryption_available' => Encryption::is_available(),
		);
	}
}
