<?php
/**
 * Opt-in Google PageSpeed Insights client.
 *
 * @package MarketingConsole
 */

namespace MarketingConsole\Admin\Free;

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

/**
 * Calls only the fixed Google PSI endpoint with the site owner's API key.
 */
final class PsiClient {

	/**
	 * Allowlisted Lighthouse audit IDs projected into an action list. Anything
	 * outside this list (and every `details.items` entry, which can carry URLs,
	 * DOM snippets and filenames) is discarded.
	 */
	/*
	 * Closed allowlist of actionable audits, spanning both Lighthouse eras.
	 * Lighthouse 12+ (current PSI, verified 13.4.0 live) renamed most classic
	 * opportunities to "*-insight" audits; older engines still emit the legacy
	 * IDs. Only one set is present in any single response, so listing both keeps
	 * the action list complete across PSI versions without free-form audit IDs.
	 */
	private const OPPORTUNITY_AUDITS = array(
		// Lighthouse 12/13 insight audits.
		'document-latency-insight',
		'render-blocking-insight',
		'image-delivery-insight',
		'lcp-discovery-insight',
		'network-dependency-tree-insight',
		'cache-insight',
		'font-display-insight',
		'legacy-javascript-insight',
		'duplicated-javascript-insight',
		'cls-culprits-insight',
		'dom-size-insight',
		'forced-reflow-insight',
		'viewport-insight',
		// Classic audit IDs (older Lighthouse; some still emitted by 13.x).
		'redirects',
		'server-response-time',
		'render-blocking-resources',
		'unused-javascript',
		'unused-css-rules',
		'unminified-javascript',
		'unminified-css',
		'uses-text-compression',
		'modern-image-formats',
		'uses-optimized-images',
		'uses-responsive-images',
		'offscreen-images',
		'unsized-images',
		'mainthread-work-breakdown',
		'bootup-time',
	);

	private const MAX_OPPORTUNITIES = 8;

	public const SERVICE_ENDPOINT = 'https://api.marketingconsole.cz/v1/psi';

	/** @var callable|null */
	private $http_post;

	/**
	 * @param callable|null $http_post Optional test HTTP POST callback.
	 */
	public function __construct( $http_post = null ) {
		$this->http_post = is_callable( $http_post ) ? $http_post : null;
	}

	/**
	 * Measure through our own service, on our Google key.
	 *
	 * The site proves it controls the domain by publishing a one-time nonce on
	 * its own public REST route; the service fetches it back before measuring.
	 * The Google payload is normalized by exactly the same code as a direct
	 * call, so the honest guard and the audit allowlist keep applying.
	 *
	 * @param string $home_url Site home URL.
	 * @param string $nonce    One-time value published by this site.
	 * @return array<string,mixed>
	 */
	public function fetch_via_service( string $home_url, string $nonce ): array {
		$url = $this->canonical_home_url( $home_url );

		if ( '' === $url || '' === $nonce ) {
			return $this->error_result( 'missing_configuration' );
		}

		$args = array(
			'timeout'             => 75,
			'redirection'         => 0,
			'limit_response_size' => 4194304,
			'headers'             => array(
				'Accept'       => 'application/json',
				'Content-Type' => 'application/json',
			),
			'body'                => wp_json_encode(
				array(
					'origin'     => untrailingslashit( $url ),
					'nonce'      => $nonce,
					'plugin_ver' => defined( 'MARKETING_CONSOLE_VERSION' ) ? constant( 'MARKETING_CONSOLE_VERSION' ) : '',
				)
			),
		);

		$response = null !== $this->http_post
			? call_user_func( $this->http_post, self::SERVICE_ENDPOINT, $args )
			: wp_safe_remote_post( self::SERVICE_ENDPOINT, $args );

		if ( is_wp_error( $response ) ) {
			return $this->error_result( 'request_failed' );
		}

		$status_code = wp_remote_retrieve_response_code( $response );
		$decoded     = json_decode( wp_remote_retrieve_body( $response ), true );

		if ( 429 === $status_code ) {
			return $this->error_result( 'daily_limit_reached', $status_code );
		}
		if ( 403 === $status_code ) {
			$result = $this->error_result( 'domain_not_verified', $status_code );
			// Co naše služba viděla na vaší vlastní adrese: 403 blokace,
			// 404 vypnuté REST API, 0 nedostupné. Vede k přesné hlášce.
			if ( is_array( $decoded ) && isset( $decoded['probe_status'] ) && is_int( $decoded['probe_status'] ) ) {
				$result['probe_status'] = $decoded['probe_status'];
			}

			return $result;
		}
		if ( 200 !== $status_code || ! is_array( $decoded ) || ! isset( $decoded['psi'] ) || ! is_array( $decoded['psi'] ) ) {
			return $this->error_result( 'service_failed', is_int( $status_code ) ? $status_code : 0 );
		}

		return $this->build_result( $decoded['psi'], $url );
	}

	/**
	 * Normalize one Google payload into the stored result shape.
	 *
	 * @param array<string,mixed> $decoded Google PSI response.
	 * @param string              $url     Requested URL.
	 * @return array<string,mixed>
	 */
	private function build_result( array $decoded, string $url ): array {
		if ( $this->left_requested_host( $decoded, $url ) ) {
			return $this->error_result( 'redirected_offsite' );
		}

		return array(
			'status'      => 'ok',
			'observed_at' => gmdate( 'Y-m-d\TH:i:s\Z' ),
			'metrics'     => array(
				'lab_performance' => $this->numeric_metric( $decoded, array( 'lighthouseResult', 'categories', 'performance', 'score' ), 100 ),
				'lab_lcp_ms'      => $this->numeric_metric( $decoded, array( 'lighthouseResult', 'audits', 'largest-contentful-paint', 'numericValue' ) ),
				'lab_cls'         => $this->numeric_metric( $decoded, array( 'lighthouseResult', 'audits', 'cumulative-layout-shift', 'numericValue' ) ),
				'field_lcp_ms'    => $this->numeric_metric( $decoded, array( 'loadingExperience', 'metrics', 'LARGEST_CONTENTFUL_PAINT_MS', 'percentile' ) ),
				'field_cls'       => $this->numeric_metric( $decoded, array( 'loadingExperience', 'metrics', 'CUMULATIVE_LAYOUT_SHIFT_SCORE', 'percentile' ), 0.01 ),
				'field_inp_ms'    => $this->numeric_metric( $decoded, array( 'loadingExperience', 'metrics', 'INTERACTION_TO_NEXT_PAINT', 'percentile' ) ),
			),
			'opportunities' => $this->opportunities( $decoded ),
		);
	}

	/**
	 * Prioritized action list from allowlisted Lighthouse opportunity audits.
	 *
	 * Only a safe numeric projection is kept: the audit id, its 0..1 score and
	 * the Lighthouse-estimated savings. `details.items` is never read (URLs, DOM,
	 * filenames). Localized `displayValue`/`title` is not parsed either; the UI
	 * labels each audit id itself.
	 *
	 * @param array<string,mixed> $decoded PSI response.
	 * @return array<int,array<string,mixed>>
	 */
	private function opportunities( array $decoded ): array {
		$audits = $decoded;
		foreach ( array( 'lighthouseResult', 'audits' ) as $segment ) {
			if ( ! is_array( $audits ) || ! isset( $audits[ $segment ] ) ) {
				return array();
			}
			$audits = $audits[ $segment ];
		}

		if ( ! is_array( $audits ) ) {
			return array();
		}

		$items = array();
		foreach ( self::OPPORTUNITY_AUDITS as $id ) {
			if ( ! isset( $audits[ $id ] ) || ! is_array( $audits[ $id ] ) ) {
				continue;
			}

			$audit = $audits[ $id ];
			$mode  = isset( $audit['scoreDisplayMode'] ) ? $audit['scoreDisplayMode'] : '';
			if ( in_array( $mode, array( 'notApplicable', 'error' ), true ) ) {
				continue;
			}

			$score         = ( isset( $audit['score'] ) && ( is_int( $audit['score'] ) || is_float( $audit['score'] ) ) ) ? (float) $audit['score'] : null;
			$savings_ms    = $this->audit_savings( $audit, 'overallSavingsMs' );
			$savings_bytes = $this->audit_savings( $audit, 'overallSavingsBytes' );

			/*
			 * Insight audits report their impact in `metricSavings` (per metric,
			 * e.g. {"LCP":800,"FCP":800}) instead of details.overallSavingsMs.
			 * Use the largest single-metric impact as the ordering weight.
			 */
			if ( null === $savings_ms ) {
				$savings_ms = $this->metric_savings_ms( $audit );
			}

			// A passing audit with no estimated savings is not an action item.
			if ( ( null === $score || $score >= 0.9 ) && null === $savings_ms && null === $savings_bytes ) {
				continue;
			}

			$items[] = array(
				'audit_id'      => $id,
				'score'         => null === $score ? null : round( $score, 3 ),
				'savings_ms'    => $savings_ms,
				'savings_bytes' => $savings_bytes,
			);
		}

		// Deterministic final tie-break by allowlist position: usort is not stable
		// before PHP 8, so equal-priority audits must have a total order or the
		// eight-of-N slice could differ between PHP versions.
		$order = array_flip( self::OPPORTUNITY_AUDITS );
		usort(
			$items,
			static function ( array $left, array $right ) use ( $order ): int {
				$lms = null === $left['savings_ms'] ? -1.0 : $left['savings_ms'];
				$rms = null === $right['savings_ms'] ? -1.0 : $right['savings_ms'];
				if ( $lms !== $rms ) {
					return $rms <=> $lms;
				}

				$lb = null === $left['savings_bytes'] ? -1.0 : $left['savings_bytes'];
				$rb = null === $right['savings_bytes'] ? -1.0 : $right['savings_bytes'];
				if ( $lb !== $rb ) {
					return $rb <=> $lb;
				}

				$ls = null === $left['score'] ? 1.0 : $left['score'];
				$rs = null === $right['score'] ? 1.0 : $right['score'];
				if ( $ls !== $rs ) {
					return $ls <=> $rs;
				}

				$lpos = isset( $order[ $left['audit_id'] ] ) ? $order[ $left['audit_id'] ] : PHP_INT_MAX;
				$rpos = isset( $order[ $right['audit_id'] ] ) ? $order[ $right['audit_id'] ] : PHP_INT_MAX;

				return $lpos <=> $rpos;
			}
		);

		return array_slice( $items, 0, self::MAX_OPPORTUNITIES );
	}

	/**
	 * Whether Lighthouse ended up on a different host than the one requested.
	 *
	 * Compares only hostnames (no path, no query), so an on-site redirect such
	 * as a trailing slash or www canonicalization is still a valid measurement.
	 *
	 * @param array<string,mixed> $decoded       Decoded PSI response.
	 * @param string              $requested_url URL the plugin asked PSI to measure.
	 * @return bool
	 */
	private function left_requested_host( array $decoded, string $requested_url ): bool {
		$lighthouse = isset( $decoded['lighthouseResult'] ) && is_array( $decoded['lighthouseResult'] ) ? $decoded['lighthouseResult'] : array();

		$final = '';
		foreach ( array( 'mainDocumentUrl', 'finalDisplayedUrl', 'finalUrl' ) as $key ) {
			if ( isset( $lighthouse[ $key ] ) && is_string( $lighthouse[ $key ] ) && '' !== $lighthouse[ $key ] ) {
				$final = $lighthouse[ $key ];
				break;
			}
		}

		if ( '' === $final ) {
			return false;
		}

		$requested_host = wp_parse_url( $requested_url, PHP_URL_HOST );
		$final_host     = wp_parse_url( $final, PHP_URL_HOST );

		if ( ! is_string( $requested_host ) || ! is_string( $final_host ) || '' === $final_host ) {
			return false;
		}

		$requested_host = strtolower( ltrim( $requested_host, '.' ) );
		$final_host     = strtolower( ltrim( $final_host, '.' ) );

		if ( $requested_host === $final_host ) {
			return false;
		}

		// Treat www and the bare domain as the same site (prefix strip, not charlist).
		$requested_host = (string) preg_replace( '/^www\./', '', $requested_host );
		$final_host     = (string) preg_replace( '/^www\./', '', $final_host );

		return $requested_host !== $final_host;
	}

	/**
	 * Largest per-metric saving an insight audit reports, in milliseconds.
	 *
	 * Only numeric values are accepted; a missing or zero impact stays null so
	 * the audit is never shown with a fabricated saving.
	 *
	 * @param array<string,mixed> $audit One Lighthouse audit.
	 * @return float|null
	 */
	private function metric_savings_ms( array $audit ): ?float {
		if ( ! isset( $audit['metricSavings'] ) || ! is_array( $audit['metricSavings'] ) ) {
			return null;
		}

		$best = null;
		foreach ( $audit['metricSavings'] as $value ) {
			if ( ! is_int( $value ) && ! is_float( $value ) ) {
				continue;
			}

			$value = round( (float) $value, 0 );
			if ( $value > 0 && ( null === $best || $value > $best ) ) {
				$best = $value;
			}
		}

		return $best;
	}

	/**
	 * @param array<string,mixed> $audit One Lighthouse audit.
	 * @param string              $key   Details key to read.
	 * @return float|null Positive rounded savings, or null.
	 */
	private function audit_savings( array $audit, string $key ): ?float {
		if ( ! isset( $audit['details'] ) || ! is_array( $audit['details'] ) || ! isset( $audit['details'][ $key ] ) ) {
			return null;
		}

		$value = $audit['details'][ $key ];
		if ( ! is_int( $value ) && ! is_float( $value ) ) {
			return null;
		}

		$value = round( (float) $value, 0 );

		return $value > 0 ? $value : null;
	}

	/**
	 * @param string $url Candidate home URL.
	 * @return string
	 */
	private function canonical_home_url( string $url ): string {
		$parts = wp_parse_url( $url );

		if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) || ! in_array( strtolower( $parts['scheme'] ), array( 'http', 'https' ), true ) ) {
			return '';
		}

		$canonical = strtolower( $parts['scheme'] ) . '://' . strtolower( $parts['host'] );
		if ( isset( $parts['port'] ) ) {
			$canonical .= ':' . (int) $parts['port'];
		}
		$canonical .= isset( $parts['path'] ) && is_string( $parts['path'] ) && '' !== $parts['path'] ? '/' . ltrim( $parts['path'], '/' ) : '/';

		return esc_url_raw( $canonical, array( 'http', 'https' ) );
	}

	/**
	 * @param array<string,mixed> $data       PSI response.
	 * @param array<int,string>   $path       Fixed response path.
	 * @param float               $multiplier Documented unit conversion.
	 * @return array<string,mixed>
	 */
	private function numeric_metric( array $data, array $path, float $multiplier = 1.0 ): array {
		$value = $data;

		foreach ( $path as $segment ) {
			if ( ! is_array( $value ) || ! array_key_exists( $segment, $value ) ) {
				return array( 'value' => null, 'status' => 'not_sampled' );
			}
			$value = $value[ $segment ];
		}

		if ( ! is_int( $value ) && ! is_float( $value ) ) {
			return array( 'value' => null, 'status' => 'not_sampled' );
		}

		return array(
			'value'  => round( $value * $multiplier, 3 ),
			'status' => 'ok',
		);
	}

	/**
	 * @param string $code        Safe local error code.
	 * @param int    $status_code Optional HTTP status.
	 * @return array<string,mixed>
	 */
	private function error_result( string $code, int $status_code = 0 ): array {
		$result = array(
			'status'      => 'error',
			'error_code'  => $code,
			'observed_at' => gmdate( 'Y-m-d\TH:i:s\Z' ),
		);

		if ( $status_code >= 100 && $status_code <= 599 ) {
			$result['http_status'] = $status_code;
		}

		return $result;
	}
}
