<?php
/**
 * Cache module — PageSpeed Insights API client
 *
 * Wraps the PSI v5 endpoint:
 *   https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed
 *
 * Returns a normalised structure:
 *   array(
 *     'score'   => 0..100,
 *     'metrics' => array(  // numericValue (ms or unit-less)
 *       'LCP'  => 2400, 'FCP' => 1200, 'CLS' => 0.05,
 *       'TBT'  => 250, 'SI' => 1800, 'TTFB' => 400, 'INP' => 180,
 *     ),
 *     'audits'  => array(
 *       array(
 *         'id'           => 'render-blocking-resources',
 *         'title'        => '...',
 *         'description'  => '...',
 *         'displayValue' => '...',
 *         'score'        => 0.5,        // null if informative
 *         'numericValue' => 380,
 *         'urls'         => array(...), // affected URLs when available
 *       ),
 *       ...
 *     ),
 *     'partial' => bool,
 *     'fetched_at' => unix-timestamp,
 *   )
 *
 * @package BlaminhorEssentials
 */

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

class Blaminhor_Essentials_Cache_Audit_PSI {

	const ENDPOINT       = 'https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed';
	const TRANSIENT_TTL  = HOUR_IN_SECONDS;

	/** @var Blaminhor_Essentials_Module_Cache */
	private $parent;

	public function __construct( Blaminhor_Essentials_Module_Cache $parent ) {
		$this->parent = $parent;
	}

	/**
	 * @return string Decrypted API key (empty if none).
	 */
	public function get_api_key() {
		$encrypted = (string) get_option( 'blaminhor_essentials_cache_psi_api_key', '' );
		if ( '' === $encrypted ) {
			return '';
		}
		return (string) blaminhor_essentials_decrypt( $encrypted );
	}

	/**
	 * Persist a freshly-typed key (encrypted at rest).
	 *
	 * @param string $key
	 * @return bool
	 */
	public function save_api_key( $key ) {
		$key = trim( (string) $key );
		if ( '' === $key ) {
			return delete_option( 'blaminhor_essentials_cache_psi_api_key' );
		}
		return update_option( 'blaminhor_essentials_cache_psi_api_key', blaminhor_essentials_encrypt( $key ) );
	}

	/**
	 * Lightweight key validity check (1-second timeout against an obvious URL).
	 *
	 * @return true|WP_Error
	 */
	public function test_key( $key ) {
		$response = wp_remote_get(
			add_query_arg(
				array(
					'url'      => 'https://example.com',
					'strategy' => 'mobile',
					'key'      => $key,
				),
				self::ENDPOINT
			),
			array(
				'timeout'   => 60,
				'sslverify' => true,
				// Google rejects keys restricted by HTTP referrer when the
				// request goes out without one (server-side wp_remote_get does
				// not forge a Referer by default).
				'headers'   => array( 'Referer' => home_url( '/' ) ),
			)
		);
		if ( is_wp_error( $response ) ) {
			blaminhor_essentials_log(
				'PSI test_key network error: ' . $response->get_error_message(),
				'error',
				'cache',
				array( 'event' => 'psi_test_key_network_error', 'wp_error_code' => $response->get_error_code() )
			);
			return $response;
		}
		$code = wp_remote_retrieve_response_code( $response );
		if ( 200 === (int) $code ) {
			blaminhor_essentials_log( 'PSI test_key OK', 'info', 'cache', array( 'event' => 'psi_test_key_ok' ) );
			return true;
		}
		$body         = json_decode( wp_remote_retrieve_body( $response ), true );
		$raw_msg      = isset( $body['error']['message'] ) ? (string) $body['error']['message'] : 'HTTP ' . $code;
		$body_excerpt = substr( (string) wp_remote_retrieve_body( $response ), 0, 600 );
		blaminhor_essentials_log(
			'PSI test_key rejected by Google: ' . $raw_msg,
			'error',
			'cache',
			array(
				'event'        => 'psi_test_key_rejected',
				'http_code'    => (int) $code,
				'google_error' => $raw_msg,
				'body_excerpt' => $body_excerpt,
				'referer_sent' => home_url( '/' ),
			)
		);
		return new WP_Error( 'psi_key_invalid', $this->humanize_google_error( $raw_msg ) );
	}

	/**
	 * Turn the raw Google error string into something actionable for the user.
	 *
	 * Google's messages tell you *what* failed but never *what to fix*. We map
	 * the recurring patterns to a short instruction so the user does not have
	 * to translate Cloud Console jargon. Falls back to the original message.
	 *
	 * @param string $msg Raw error message from Google.
	 * @return string
	 */
	private function humanize_google_error( $msg ) {
		$lc = strtolower( (string) $msg );

		// "Requests to this API ... are blocked" → API restriction on the key
		// excludes PageSpeed Insights, OR the API has not been enabled in the
		// project this key belongs to.
		if ( false !== strpos( $lc, 'are blocked' ) || false !== strpos( $lc, 'is blocked' ) ) {
			return sprintf(
				/* translators: %s: original error message from Google */
				__( 'Google blocked the request: "%s". Two things to check in https://console.cloud.google.com → APIs & Services: (1) the "PageSpeed Insights API" must be enabled in the project this key belongs to (Library → search → Enable). (2) on the key itself (Credentials → click the key), under "API restrictions", either select "Don\'t restrict key" or add "PageSpeed Insights API" to the allowed list.', 'blaminhor-essentials' ),
				$msg
			);
		}

		// "Requests from referer ... are blocked" → HTTP referrer restriction
		// excludes the site URL we send. The plugin sends the home URL as
		// Referer since 1.9.7, so the user just needs to add the site to the
		// allow-list (or remove the restriction).
		if ( false !== strpos( $lc, 'referer' ) || false !== strpos( $lc, 'referrer' ) ) {
			return sprintf(
				/* translators: 1: original error message from Google, 2: site URL */
				__( 'Google blocked the request: "%1$s". On the key in Cloud Console → Credentials → click the key → "Application restrictions": either select "None", or under "HTTP referrers" add %2$s to the allowed list.', 'blaminhor-essentials' ),
				$msg,
				home_url( '/*' )
			);
		}

		// "API key not valid" — typo in the key, or key was deleted in Cloud Console.
		if ( false !== strpos( $lc, 'api key not valid' ) || false !== strpos( $lc, 'invalid key' ) ) {
			return sprintf(
				/* translators: %s: original error message from Google */
				__( 'Google rejected the key: "%s". Double-check you copied the full key from Cloud Console (Credentials → click the key → click "Show key") with no leading/trailing space.', 'blaminhor-essentials' ),
				$msg
			);
		}

		return $msg;
	}

	/**
	 * Run an audit. Cached for an hour per (url, strategy) pair to avoid
	 * burning the daily 25k quota on UI refreshes.
	 *
	 * @param string $url
	 * @param string $strategy 'mobile'|'desktop'
	 * @return array|WP_Error
	 */
	public function run( $url, $strategy = 'mobile' ) {
		$url      = esc_url_raw( $url );
		$strategy = ( 'desktop' === $strategy ) ? 'desktop' : 'mobile';
		if ( '' === $url ) {
			return new WP_Error( 'psi_invalid_url', __( 'Invalid URL.', 'blaminhor-essentials' ) );
		}

		$cache_key = 'blaminhor_cache_psi_' . md5( $url . '|' . $strategy );
		$cached    = get_transient( $cache_key );
		if ( is_array( $cached ) ) {
			return $cached;
		}

		$args = array(
			'url'      => $url,
			'strategy' => $strategy,
			'category' => 'PERFORMANCE',
			'locale'   => substr( (string) get_locale(), 0, 5 ),
		);
		$key = $this->get_api_key();
		if ( '' !== $key ) {
			$args['key'] = $key;
		}

		$response = wp_remote_get(
			add_query_arg( $args, self::ENDPOINT ),
			array(
				'timeout'   => 90,
				'sslverify' => true,
				'headers'   => array( 'Referer' => home_url( '/' ) ),
			)
		);

		if ( is_wp_error( $response ) ) {
			blaminhor_essentials_log(
				'PSI run network error: ' . $response->get_error_message(),
				'error',
				'cache',
				array(
					'event'         => 'psi_run_network_error',
					'wp_error_code' => $response->get_error_code(),
					'audited_url'   => $url,
					'strategy'      => $strategy,
				)
			);
			return $response;
		}
		$code = wp_remote_retrieve_response_code( $response );
		$body = json_decode( wp_remote_retrieve_body( $response ), true );

		if ( 429 === (int) $code ) {
			blaminhor_essentials_log(
				'PSI quota exceeded (HTTP 429)',
				'warning',
				'cache',
				array( 'event' => 'psi_run_quota', 'audited_url' => $url, 'strategy' => $strategy )
			);
			return new WP_Error( 'psi_quota', __( 'PSI quota exceeded. Try again later or add a Google API key for higher limits.', 'blaminhor-essentials' ) );
		}
		if ( 200 !== (int) $code ) {
			$raw_msg      = isset( $body['error']['message'] ) ? (string) $body['error']['message'] : 'HTTP ' . $code;
			$body_excerpt = substr( (string) wp_remote_retrieve_body( $response ), 0, 600 );
			blaminhor_essentials_log(
				'PSI run rejected by Google: ' . $raw_msg,
				'error',
				'cache',
				array(
					'event'        => 'psi_run_rejected',
					'http_code'    => (int) $code,
					'google_error' => $raw_msg,
					'body_excerpt' => $body_excerpt,
					'audited_url'  => $url,
					'strategy'     => $strategy,
					'referer_sent' => home_url( '/' ),
				)
			);
			return new WP_Error( 'psi_http', $this->humanize_google_error( $raw_msg ) );
		}

		$normalised = $this->normalise_response( $body );
		set_transient( $cache_key, $normalised, self::TRANSIENT_TTL );

		return $normalised;
	}

	/**
	 * Reduce the verbose PSI response to the structure the recommender consumes.
	 *
	 * @param array $body
	 * @return array
	 */
	private function normalise_response( $body ) {
		$result = array(
			'score'      => 0,
			'metrics'    => array(),
			'audits'     => array(),
			'partial'    => false,
			'fetched_at' => time(),
		);

		$lh = isset( $body['lighthouseResult'] ) ? $body['lighthouseResult'] : array();
		if ( isset( $lh['categories']['performance']['score'] ) ) {
			$result['score'] = (int) round( 100 * (float) $lh['categories']['performance']['score'] );
		}

		// Lab metrics — pulled from named audits in the audits map.
		$metric_map = array(
			'LCP'  => 'largest-contentful-paint',
			'FCP'  => 'first-contentful-paint',
			'CLS'  => 'cumulative-layout-shift',
			'TBT'  => 'total-blocking-time',
			'SI'   => 'speed-index',
			'TTFB' => 'server-response-time',
		);
		foreach ( $metric_map as $metric => $audit_id ) {
			if ( isset( $lh['audits'][ $audit_id ]['numericValue'] ) ) {
				$result['metrics'][ $metric ] = (float) $lh['audits'][ $audit_id ]['numericValue'];
			}
		}

		// Field metrics from CrUX (loadingExperience), when available — these are the
		// real-world Web Vitals that drive Google ranking.
		if ( isset( $body['loadingExperience']['metrics']['INTERACTION_TO_NEXT_PAINT']['percentile'] ) ) {
			$result['metrics']['INP'] = (float) $body['loadingExperience']['metrics']['INTERACTION_TO_NEXT_PAINT']['percentile'];
		}

		// Reduce audits — keep only those that are NOT pass-by-default and have
		// a known mapping in the recommender.
		$relevant = array(
			'render-blocking-resources', 'unused-css-rules', 'unused-javascript',
			'unminified-css', 'unminified-javascript', 'modern-image-formats',
			'uses-optimized-images', 'uses-webp-images', 'uses-text-compression',
			'uses-responsive-images', 'offscreen-images', 'efficient-animated-content',
			'duplicated-javascript', 'legacy-javascript', 'uses-long-cache-ttl',
			'total-byte-weight', 'uses-rel-preconnect', 'uses-rel-preload',
			'preload-lcp-image', 'font-display', 'redirects', 'server-response-time',
			'third-party-summary', 'third-party-facades', 'dom-size',
			'critical-request-chains', 'bootup-time', 'mainthread-work-breakdown',
			'bf-cache', 'lcp-lazy-loaded', 'unsized-images', 'non-composited-animations',
		);
		foreach ( $relevant as $audit_id ) {
			if ( ! isset( $lh['audits'][ $audit_id ] ) ) {
				continue;
			}
			$audit = $lh['audits'][ $audit_id ];
			$score = isset( $audit['score'] ) ? $audit['score'] : null;
			if ( null !== $score && (float) $score >= 0.9 ) {
				continue; // Already passing; skip.
			}
			$result['audits'][] = array(
				'id'           => $audit_id,
				'title'        => isset( $audit['title'] ) ? (string) $audit['title'] : $audit_id,
				'description'  => isset( $audit['description'] ) ? (string) $audit['description'] : '',
				'displayValue' => isset( $audit['displayValue'] ) ? (string) $audit['displayValue'] : '',
				'score'        => $score,
				'numericValue' => isset( $audit['numericValue'] ) ? (float) $audit['numericValue'] : null,
				'urls'         => $this->extract_audit_urls( $audit ),
			);
		}

		return $result;
	}

	private function extract_audit_urls( $audit ) {
		if ( ! isset( $audit['details']['items'] ) || ! is_array( $audit['details']['items'] ) ) {
			return array();
		}
		$urls = array();
		foreach ( $audit['details']['items'] as $item ) {
			if ( isset( $item['url'] ) ) {
				$urls[] = (string) $item['url'];
			}
		}
		return array_slice( array_values( array_unique( $urls ) ), 0, 5 );
	}
}
