<?php
/**
 * Google OAuth 2.0 lifecycle manager — no SDK, native WordPress HTTP API only.
 *
 * Stores credentials in wp_options:
 *   cpanalyzer_oauth_client_id        — OAuth 2.0 client ID
 *   cpanalyzer_oauth_client_secret    — OAuth 2.0 client secret
 *   cpanalyzer_oauth_access_token     — current access token
 *   cpanalyzer_oauth_refresh_token    — long-lived refresh token
 *   cpanalyzer_oauth_token_expires    — Unix timestamp when access token expires
 *   cpanalyzer_oauth_connection_error — last fatal auth failure (array), empty when healthy
 *
 * @package ContentPerformanceAnalyzer
 * @since   2.0.0
 */

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

if ( class_exists( 'CPAP_Google_OAuth' ) ) {
	return;
}

class CPAP_Google_OAuth {

	// -----------------------------------------------------------------------
	// Constants
	// -----------------------------------------------------------------------

	const AUTH_ENDPOINT  = 'https://accounts.google.com/o/oauth2/v2/auth';
	const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
	const REVOKE_ENDPOINT = 'https://oauth2.googleapis.com/revoke';
	const STATE_META_KEY = 'cpanalyzer_oauth_state';
	const CONNECTION_ERROR_OPTION = 'cpanalyzer_oauth_connection_error';

	/** Hosted one-click flow (OAuth proxy) — no Google Cloud setup needed. */
	const PROXY_BASE = 'https://auth.cpanalyzer.com';
	const MODE_OPTION = 'cpanalyzer_oauth_mode';
	const HOSTED_STATE_TRANSIENT = 'cpanalyzer_hosted_state';
	const HOSTED_STATE_META_KEY = 'cpanalyzer_hosted_state';

	/**
	 * Google token-endpoint error codes that mean the stored refresh token is
	 * permanently unusable — a silent retry can never succeed, the user must
	 * reconnect. Transient failures (HTTP 5xx, network errors) are NOT listed.
	 */
	const FATAL_AUTH_ERRORS = array(
		'invalid_grant',
		'invalid_client',
		'unauthorized_client',
		'access_denied',
	);

	/** Scopes required by GA4 Data API and Search Console API. */
	const SCOPES = array(
		'https://www.googleapis.com/auth/analytics.readonly',
		'https://www.googleapis.com/auth/webmasters.readonly',
	);

	/**
	 * Number of seconds before expiry at which a silent refresh is triggered.
	 * 300 s = 5 minutes, giving plenty of runway for the in-flight request.
	 */
	const REFRESH_WINDOW = 300;

	// -----------------------------------------------------------------------
	// Public API
	// -----------------------------------------------------------------------

	/**
	 * Check whether the plugin has a usable OAuth connection
	 * (a refresh token is the durable credential; an access token is ephemeral).
	 *
	 * @return bool
	 */
	public function is_connected() {
		$refresh_token = get_option( 'cpanalyzer_oauth_refresh_token', '' );
		if ( ! empty( $refresh_token ) ) {
			return true;
		}

		$access_token = (string) get_option( 'cpanalyzer_oauth_access_token', '' );
		$expires      = (int) get_option( 'cpanalyzer_oauth_token_expires', 0 );

		return ! empty( $access_token ) && time() < max( $expires, 0 );
	}

	/**
	 * Return the email address the plugin is connected as, or empty string.
	 *
	 * @return string
	 */
	public function get_connected_email() {
		return (string) get_option( 'cpanalyzer_oauth_connected_email', '' );
	}

	/**
	 * Build the Google authorization URL that starts the OAuth handshake.
	 * Redirect the user to this URL.
	 *
	 * @return string
	 */
	public function get_authorization_url() {
		$client_id    = $this->get_client_id();
		$redirect_uri = $this->get_redirect_uri();

		// Store a random state token to validate the callback.
		$state = wp_generate_password( 24, false );
		set_transient( 'cpanalyzer_oauth_state', $state, 600 ); // 10-minute window
		$current_user_id = get_current_user_id();
		if ( $current_user_id > 0 ) {
			update_user_meta( $current_user_id, self::STATE_META_KEY, $state );
		}

		$params = array(
			'client_id'             => $client_id,
			'redirect_uri'          => $redirect_uri,
			'response_type'         => 'code',
			'scope'                 => implode( ' ', self::SCOPES ),
			'access_type'           => 'offline',   // required to receive a refresh_token
			'prompt'                => 'consent',    // force consent so we always get refresh_token
			'include_granted_scopes'=> 'true',
			'state'                 => $state,
		);

		return self::AUTH_ENDPOINT . '?' . http_build_query( $params );
	}

	/**
	 * Exchange an authorization code (from the OAuth callback) for tokens.
	 * Persists the tokens in wp_options.
	 *
	 * @param  string $code        The `code` query param from Google's redirect.
	 * @param  string $state       The `state` query param — must match stored value.
	 * @return true|\WP_Error      True on success, WP_Error on failure.
	 */
	public function exchange_code_for_tokens( $code, $state ) {
		// Validate state to prevent CSRF.
		$stored_state = get_transient( 'cpanalyzer_oauth_state' );
		delete_transient( 'cpanalyzer_oauth_state' );
		$current_user_id = get_current_user_id();
		$user_state      = $current_user_id > 0 ? (string) get_user_meta( $current_user_id, self::STATE_META_KEY, true ) : '';
		if ( $current_user_id > 0 ) {
			delete_user_meta( $current_user_id, self::STATE_META_KEY );
		}

		$transient_match = ! empty( $stored_state ) && hash_equals( $stored_state, $state );
		$user_meta_match = ! empty( $user_state ) && hash_equals( $user_state, $state );

		if ( ! $transient_match && ! $user_meta_match ) {
			return new WP_Error( 'oauth_state_mismatch', __( 'OAuth state mismatch. Please try connecting again.', 'content-performance-analyzer' ) );
		}

		$response = wp_remote_post(
			self::TOKEN_ENDPOINT,
			array(
				'timeout' => 20,
				'body'    => array(
					'code'          => sanitize_text_field( $code ),
					'client_id'     => $this->get_client_id(),
					'client_secret' => $this->get_client_secret(),
					'redirect_uri'  => $this->get_redirect_uri(),
					'grant_type'    => 'authorization_code',
				),
			)
		);

		return $this->handle_token_response( $response, 'exchange' );
	}

	/**
	 * Return a valid access token, performing a silent refresh if the token is
	 * within REFRESH_WINDOW seconds of expiring (or already expired).
	 *
	 * @return string  Access token, or empty string if not connected / refresh failed.
	 */
	public function get_valid_access_token() {
		$expires      = (int) get_option( 'cpanalyzer_oauth_token_expires', 0 );
		$access_token = (string) get_option( 'cpanalyzer_oauth_access_token', '' );

		// If token is still fresh, return it immediately.
		if ( ! empty( $access_token ) && time() < ( $expires - self::REFRESH_WINDOW ) ) {
			return $access_token;
		}

		if ( ! $this->is_connected() ) {
			return '';
		}

		$refresh_token = (string) get_option( 'cpanalyzer_oauth_refresh_token', '' );
		if ( empty( $refresh_token ) ) {
			return ! empty( $access_token ) && time() < max( $expires, 0 ) ? $access_token : '';
		}

		// Token is stale — silently refresh it.
		$result = $this->refresh_access_token();
		if ( is_wp_error( $result ) ) {
			$this->log( 'Token refresh failed: ' . $result->get_error_message() );
			return '';
		}

		return (string) get_option( 'cpanalyzer_oauth_access_token', '' );
	}

	/**
	 * Revoke all stored tokens and clear wp_options credentials.
	 *
	 * @return true|\WP_Error
	 */
	public function revoke() {
		$access_token  = get_option( 'cpanalyzer_oauth_access_token', '' );
		$refresh_token = get_option( 'cpanalyzer_oauth_refresh_token', '' );

		// Attempt to revoke both tokens from Google (best-effort).
		$token_to_revoke = ! empty( $refresh_token ) ? $refresh_token : $access_token;
		if ( ! empty( $token_to_revoke ) ) {
			wp_remote_post(
				self::REVOKE_ENDPOINT,
				array(
					'timeout' => 10,
					'body'    => array( 'token' => $token_to_revoke ),
				)
			);
		}

		// Clear all stored tokens regardless of the HTTP result.
		delete_option( 'cpanalyzer_oauth_access_token' );
		delete_option( 'cpanalyzer_oauth_refresh_token' );
		delete_option( 'cpanalyzer_oauth_token_expires' );
		delete_option( 'cpanalyzer_oauth_connected_email' );
		delete_option( self::CONNECTION_ERROR_OPTION );
		delete_option( self::MODE_OPTION );

		return true;
	}

	/**
	 * Return the last recorded fatal connection failure, or an empty array when
	 * the connection is healthy (or has never been established).
	 *
	 * @return array{ code?: string, message?: string, time?: int }
	 */
	public function get_connection_error() {
		$error = get_option( self::CONNECTION_ERROR_OPTION, array() );

		if ( ! is_array( $error ) || empty( $error['code'] ) ) {
			return array();
		}

		// Stale flag with no credentials behind it — nothing to reconnect.
		if ( ! $this->is_connected() ) {
			delete_option( self::CONNECTION_ERROR_OPTION );
			return array();
		}

		return $error;
	}

	/**
	 * Get the OAuth redirect URI (the WordPress admin callback URL).
	 *
	 * @return string
	 */
	public function get_redirect_uri() {
		return admin_url( 'admin.php?page=cpanalyzer-settings&cpanalyzer_oauth_callback=1' );
	}

	// -----------------------------------------------------------------------
	// Hosted (one-click) flow via the OAuth proxy
	// -----------------------------------------------------------------------

	/**
	 * Base URL of the hosted OAuth proxy. Filterable so staging/self-hosted
	 * proxies can be used.
	 *
	 * @return string
	 */
	public function get_proxy_base() {
		return untrailingslashit( (string) apply_filters( 'cpanalyzer_oauth_proxy_base', self::PROXY_BASE ) );
	}

	/**
	 * Whether this site connected through the hosted proxy (vs its own
	 * OAuth client). Determines how token refreshes are performed.
	 *
	 * @return bool
	 */
	public function is_hosted_mode() {
		return 'hosted' === get_option( self::MODE_OPTION, '' );
	}

	/**
	 * Build the proxy /auth/start URL that begins the one-click flow.
	 * Stores a one-time local state token so the return leg can be verified.
	 *
	 * @return string
	 */
	public function get_hosted_start_url() {
		$state = wp_generate_password( 24, false );
		set_transient( self::HOSTED_STATE_TRANSIENT, $state, 600 );
		$current_user_id = get_current_user_id();
		if ( $current_user_id > 0 ) {
			update_user_meta( $current_user_id, self::HOSTED_STATE_META_KEY, $state );
		}

		$return_url = admin_url( 'admin.php?page=cpanalyzer-settings&cpanalyzer_hosted_callback=1&cpa_state=' . rawurlencode( $state ) );

		return $this->get_proxy_base() . '/auth/start?site=' . rawurlencode( $return_url );
	}

	/**
	 * Validate the one-time state token on the hosted return leg.
	 *
	 * @param  string $state Value of the cpa_state query param.
	 * @return bool
	 */
	public function verify_hosted_state( $state ) {
		$stored = (string) get_transient( self::HOSTED_STATE_TRANSIENT );
		delete_transient( self::HOSTED_STATE_TRANSIENT );

		$current_user_id = get_current_user_id();
		$user_state      = $current_user_id > 0 ? (string) get_user_meta( $current_user_id, self::HOSTED_STATE_META_KEY, true ) : '';
		if ( $current_user_id > 0 ) {
			delete_user_meta( $current_user_id, self::HOSTED_STATE_META_KEY );
		}

		$transient_match = '' !== $stored && is_string( $state ) && hash_equals( $stored, $state );
		$user_meta_match = '' !== $user_state && is_string( $state ) && hash_equals( $user_state, $state );

		return $transient_match || $user_meta_match;
	}

	/**
	 * Get this installation's token for the hosted PageSpeed service,
	 * registering with the proxy on first use. The token gates access to
	 * the shared PageSpeed API key and is individually revocable
	 * server-side. Returns an empty string when registration fails.
	 *
	 * @return string
	 */
	public function get_installation_token() {
		$token = (string) get_option( 'cpanalyzer_install_token', '' );
		if ( '' !== $token ) {
			return $token;
		}

		// With the hosted AI provider selected, configuration gates call
		// this during admin page render — so a failed registration must
		// back off instead of retrying on every page load, or an
		// unreachable Worker turns each wp-admin page into a 15-second
		// wait. The next attempt happens after the transient expires.
		if ( get_transient( 'cpanalyzer_install_token_backoff' ) ) {
			return '';
		}

		$response = wp_remote_post(
			$this->get_proxy_base() . '/api/v1/register',
			array(
				'timeout' => 15,
				'headers' => array( 'Content-Type' => 'application/json' ),
				'body'    => wp_json_encode( array( 'site' => home_url( '/' ) ) ),
			)
		);

		if ( is_wp_error( $response ) || 201 !== (int) wp_remote_retrieve_response_code( $response ) ) {
			set_transient( 'cpanalyzer_install_token_backoff', 1, 5 * MINUTE_IN_SECONDS );
			return '';
		}

		$body = json_decode( wp_remote_retrieve_body( $response ), true );
		if ( empty( $body['token'] ) || ! is_string( $body['token'] ) ) {
			set_transient( 'cpanalyzer_install_token_backoff', 1, 5 * MINUTE_IN_SECONDS );
			return '';
		}

		$token = sanitize_text_field( $body['token'] );
		update_option( 'cpanalyzer_install_token', $token, false );

		return $token;
	}

	/**
	 * Drop the stored installation token (e.g. after the server rejects
	 * it) so the next request re-registers cleanly. Also clears the
	 * registration backoff so a deliberate reset can retry immediately.
	 */
	public function reset_installation_token() {
		delete_option( 'cpanalyzer_install_token' );
		delete_transient( 'cpanalyzer_install_token_backoff' );
	}

	/**
	 * Collect the token bundle from the proxy using the one-time handoff
	 * code, and persist the connection in hosted mode.
	 *
	 * @param  string $code One-time cpa_code from the proxy redirect.
	 * @return true|\WP_Error
	 */
	public function exchange_hosted_code( $code ) {
		$response = wp_remote_post(
			$this->get_proxy_base() . '/token/exchange',
			array(
				'timeout' => 20,
				'headers' => array( 'Content-Type' => 'application/json' ),
				'body'    => wp_json_encode( array( 'code' => $code ) ),
			)
		);

		if ( is_wp_error( $response ) ) {
			return new WP_Error( 'http_error', $response->get_error_message() );
		}

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

		if ( 200 !== $status || empty( $body['access_token'] ) || empty( $body['refresh_token'] ) ) {
			$message = isset( $body['error_description'] )
				? sanitize_text_field( $body['error_description'] )
				: __( 'Could not complete the Google connection. Please try again.', 'content-performance-analyzer' );
			return new WP_Error( 'hosted_exchange_failed', $message );
		}

		update_option( 'cpanalyzer_oauth_access_token', sanitize_text_field( $body['access_token'] ) );
		update_option( 'cpanalyzer_oauth_refresh_token', sanitize_text_field( $body['refresh_token'] ) );
		$expires_in = isset( $body['expires_in'] ) ? (int) $body['expires_in'] : 3600;
		update_option( 'cpanalyzer_oauth_token_expires', time() + $expires_in );
		if ( ! empty( $body['email'] ) ) {
			update_option( 'cpanalyzer_oauth_connected_email', sanitize_email( $body['email'] ) );
		}
		update_option( self::MODE_OPTION, 'hosted' );
		delete_option( self::CONNECTION_ERROR_OPTION );

		return true;
	}

	/**
	 * Quick connectivity test: fetch the current user's info from Google's
	 * tokeninfo endpoint with the current access token.
	 *
	 * @return array{ success: bool, email: string, message: string }
	 */
	public function test_connection() {
		$token = $this->get_valid_access_token();
		if ( empty( $token ) ) {
			return array(
				'success' => false,
				'email'   => '',
				'message' => __( 'No valid access token. Please reconnect Google Account.', 'content-performance-analyzer' ),
			);
		}

		$response = wp_remote_get(
			'https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=' . rawurlencode( $token ),
			array( 'timeout' => 10 )
		);

		if ( is_wp_error( $response ) ) {
			return array(
				'success' => false,
				'email'   => '',
				'message' => $response->get_error_message(),
			);
		}

		$body = json_decode( wp_remote_retrieve_body( $response ), true );
		$code = (int) wp_remote_retrieve_response_code( $response );

		if ( $code !== 200 || empty( $body['email'] ) ) {
			$error = isset( $body['error_description'] ) ? $body['error_description'] : __( 'Token validation failed.', 'content-performance-analyzer' );
			return array(
				'success' => false,
				'email'   => '',
				'message' => $error,
			);
		}

		return array(
			'success' => true,
			'email'   => sanitize_email( $body['email'] ),
			'message' => sprintf(
				/* translators: %s: Google account email */
				__( 'Connected as %s', 'content-performance-analyzer' ),
				sanitize_email( $body['email'] )
			),
		);
	}

	// -----------------------------------------------------------------------
	// Private helpers
	// -----------------------------------------------------------------------

	/**
	 * Use the stored refresh token to get a new access token from Google.
	 *
	 * @return true|\WP_Error
	 */
	private function refresh_access_token() {
		$refresh_token = get_option( 'cpanalyzer_oauth_refresh_token', '' );
		if ( empty( $refresh_token ) ) {
			return new WP_Error( 'no_refresh_token', __( 'No refresh token stored. Please reconnect Google Account.', 'content-performance-analyzer' ) );
		}

		// Hosted mode: the proxy holds the client secret and mirrors Google's
		// token endpoint verbatim, so the same response handling applies.
		if ( $this->is_hosted_mode() ) {
			$response = wp_remote_post(
				$this->get_proxy_base() . '/token/refresh',
				array(
					'timeout' => 20,
					'headers' => array( 'Content-Type' => 'application/json' ),
					'body'    => wp_json_encode( array( 'refresh_token' => $refresh_token ) ),
				)
			);

			return $this->handle_token_response( $response, 'refresh' );
		}

		$response = wp_remote_post(
			self::TOKEN_ENDPOINT,
			array(
				'timeout' => 20,
				'body'    => array(
					'client_id'     => $this->get_client_id(),
					'client_secret' => $this->get_client_secret(),
					'refresh_token' => $refresh_token,
					'grant_type'    => 'refresh_token',
				),
			)
		);

		return $this->handle_token_response( $response, 'refresh' );
	}

	/**
	 * Parse a token endpoint response and persist the result to wp_options.
	 *
	 * @param  mixed  $response wp_remote_post() return value.
	 * @param  string $context  'exchange' or 'refresh' — used in error messages.
	 * @return true|\WP_Error
	 */
	private function handle_token_response( $response, $context ) {
		if ( is_wp_error( $response ) ) {
			return new WP_Error( 'http_error', $response->get_error_message() );
		}

		$code = (int) wp_remote_retrieve_response_code( $response );
		$body = json_decode( wp_remote_retrieve_body( $response ), true );

		if ( $code !== 200 || empty( $body['access_token'] ) ) {
			$google_error = isset( $body['error'] ) ? sanitize_text_field( $body['error'] ) : '';
			$description  = isset( $body['error_description'] )
				? sanitize_text_field( $body['error_description'] )
				: sprintf(
					/* translators: 1: context (exchange/refresh), 2: HTTP status code */
					__( 'Token %1$s failed (HTTP %2$d).', 'content-performance-analyzer' ),
					$context,
					$code
				);

			// Prefix Google's machine-readable code (e.g. invalid_grant) so logs
			// are diagnosable — error_description alone is often just "Bad Request".
			$error = '' !== $google_error ? $google_error . ' — ' . $description : $description;

			// A fatal auth error on refresh means the connection is dead until the
			// user reconnects. Persist it so the admin UI can surface a banner.
			if ( 'refresh' === $context && in_array( $google_error, self::FATAL_AUTH_ERRORS, true ) ) {
				update_option(
					self::CONNECTION_ERROR_OPTION,
					array(
						'code'    => $google_error,
						'message' => $error,
						'time'    => time(),
					),
					false
				);
			}

			return new WP_Error( 'token_' . $context . '_failed', $error );
		}

		// Token issued successfully — clear any recorded connection failure.
		delete_option( self::CONNECTION_ERROR_OPTION );

		// Persist the access token and expiry.
		update_option( 'cpanalyzer_oauth_access_token', sanitize_text_field( $body['access_token'] ) );
		$expires_in = isset( $body['expires_in'] ) ? (int) $body['expires_in'] : 3600;
		update_option( 'cpanalyzer_oauth_token_expires', time() + $expires_in );

		// Only an initial exchange returns a refresh_token; preserve existing one on refresh.
		if ( ! empty( $body['refresh_token'] ) ) {
			update_option( 'cpanalyzer_oauth_refresh_token', sanitize_text_field( $body['refresh_token'] ) );
		} elseif ( 'exchange' === $context ) {
			$this->log( 'OAuth exchange returned no refresh token. The connection can still work until the current access token expires.' );
		}

		// If a new access token arrived, try to resolve the connected email.
		if ( 'exchange' === $context ) {
			$info = $this->test_connection();
			if ( $info['success'] ) {
				update_option( 'cpanalyzer_oauth_connected_email', sanitize_email( $info['email'] ) );
			}
		}

		return true;
	}

	/**
	 * @return string
	 */
	private function get_client_id() {
		return (string) get_option( 'cpanalyzer_oauth_client_id', '' );
	}

	/**
	 * @return string
	 */
	private function get_client_secret() {
		return (string) get_option( 'cpanalyzer_oauth_client_secret', '' );
	}

	/**
	 * Log a debug message if WP_DEBUG_LOG is active.
	 *
	 * @param string $message
	 */
	private function log( $message ) {
		if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Protected debug logging
			error_log( '[CPA OAuth] ' . $message );
		}
	}
}
