<?php
/**
 * Cache module — Internal performance audit
 *
 * Runs entirely in PHP, no Google API key required. Covers ~80% of PSI
 * recommendations by inspecting:
 *   - Response headers of home_url() (Cache-Control, Content-Encoding, X-Powered-By)
 *   - Rendered HTML of home_url() (render-blocking, unsized images, lazy attr,
 *     fonts, third-party scripts)
 *   - Active plugins (heavy plugin signatures, conflicts)
 *   - Server environment (PHP version, WP version, opcache, gzip)
 *
 * Returns the SAME shape as Cache_Audit_PSI->run() so the recommender can
 * consume both interchangeably.
 *
 * @package BlaminhorEssentials
 */

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

class Blaminhor_Essentials_Cache_Audit_Internal {

	const TRANSIENT_TTL = HOUR_IN_SECONDS;

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

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

	/**
	 * Run the internal audit. Returns the same normalised shape as PSI.
	 *
	 * @param string $url URL to inspect (default: home_url).
	 * @return array
	 */
	public function run( $url = '' ) {
		if ( '' === $url ) {
			$url = home_url( '/' );
		}
		$cache_key = 'blaminhor_cache_audit_internal_' . md5( $url );
		$cached    = get_transient( $cache_key );
		if ( is_array( $cached ) ) {
			return $cached;
		}

		$result = array(
			'score'      => 0,
			'metrics'    => array(),
			'audits'     => array(),
			'partial'    => false,
			'fetched_at' => time(),
		);

		$head     = wp_remote_head( $url, array( 'timeout' => 10, 'sslverify' => false ) );
		$headers  = is_wp_error( $head ) ? array() : wp_remote_retrieve_headers( $head );
		$ttfb_ms  = $this->measure_ttfb( $url );

		// Mirror a real browser request: ask explicitly for gzip/br/deflate AND
		// keep the response body compressed (decompress => false). Without
		// these two together, several WP HTTP transports decompress silently
		// and strip Content-Encoding from the returned headers — which is what
		// historically caused the gzip audit to false-positive on sites that
		// were actually serving compressed HTML just fine.
		$body_resp    = wp_remote_get(
			$url,
			array(
				'timeout'    => 15,
				'sslverify'  => false,
				'user-agent' => 'Blaminhor-Cache-Auditor/1.0',
				'decompress' => false,
				'headers'    => array(
					'Accept-Encoding' => 'gzip, br, deflate',
				),
			)
		);
		$html         = '';
		$body_headers = array();
		$raw_body     = '';
		if ( ! is_wp_error( $body_resp ) ) {
			$body_headers = wp_remote_retrieve_headers( $body_resp );
			$raw_body     = (string) wp_remote_retrieve_body( $body_resp );
			$html         = $this->maybe_decode_body( $raw_body, $body_headers );
		}

		// --- Audits ----------------------------------------------------------

		// 1. uses-text-compression (gzip / brotli)
		// Take the strongest available signal — the GET response (whose body
		// we kept compressed on purpose), and fall back to the HEAD headers
		// only if it tells us nothing. If neither set carried Content-Encoding
		// (some hosts strip it for cache-busting), sniff the raw body bytes.
		$enc = $this->read_content_encoding( $body_headers );
		if ( '' === $enc ) {
			$enc = $this->read_content_encoding( $headers );
		}
		if ( '' === $enc && '' !== $raw_body ) {
			$enc = $this->sniff_compression( $raw_body );
		}

		if ( '' === $enc ) {
			$result['audits'][] = $this->audit(
				'uses-text-compression',
				__( 'Text compression is not active', 'blaminhor-essentials' ),
				__( 'The server is not sending Content-Encoding: gzip/br for HTML. Text compression typically reduces HTML transfer by 30 to 70 percent.', 'blaminhor-essentials' ),
				0
			);
		}

		// 2. uses-long-cache-ttl
		$cache_control = $headers && isset( $headers['cache-control'] ) ? (string) $headers['cache-control'] : '';
		if ( '' === $cache_control || false === stripos( $cache_control, 'max-age' ) ) {
			$result['audits'][] = $this->audit(
				'uses-long-cache-ttl',
				__( 'Static assets do not declare a cache lifetime', 'blaminhor-essentials' ),
				__( 'No max-age directive was found in the response headers. Without browser-cache directives, repeat visitors refetch every asset on each visit.', 'blaminhor-essentials' ),
				0.4
			);
		}

		// 3. server-response-time (TTFB)
		if ( $ttfb_ms > 600 ) {
			$result['metrics']['TTFB'] = $ttfb_ms;
			$result['audits'][] = $this->audit(
				'server-response-time',
				sprintf(
					/* translators: %d: TTFB in ms */
					__( 'High TTFB: %d ms', 'blaminhor-essentials' ),
					(int) $ttfb_ms
				),
				__( 'Time to first byte exceeds 600 ms. This is largely a hosting concern: page cache usually brings it under 100 ms — if it does not, the bottleneck is on the server side (CPU, RAM, database).', 'blaminhor-essentials' ),
				0.3
			);
		}

		// 4. HTML-derived audits
		if ( '' !== $html ) {
			// 4a. render-blocking-resources: count <link rel=stylesheet> + <script> in HEAD without async/defer
			$head_html = '';
			if ( preg_match( '#<head\b[^>]*>(.*?)</head>#is', $html, $hm ) ) {
				$head_html = $hm[1];
			}
			$count_blocking_css = preg_match_all( '#<link\b[^>]*rel=["\']?stylesheet["\']?[^>]*>#i', $head_html );
			$count_blocking_js  = preg_match_all( '#<script\b(?![^>]*\b(?:async|defer|type=["\']?module["\']?))[^>]*src=#i', $head_html );

			if ( ( $count_blocking_css + $count_blocking_js ) > 4 ) {
				$result['audits'][] = $this->audit(
					'render-blocking-resources',
					__( 'Render-blocking resources detected', 'blaminhor-essentials' ),
					sprintf(
						/* translators: 1: number of blocking CSS, 2: number of blocking JS */
						__( '%1$d blocking stylesheet links and %2$d blocking scripts in the document head. Deferring scripts and inlining critical CSS can shave 1 to 3 seconds off LCP on slow connections.', 'blaminhor-essentials' ),
						(int) $count_blocking_css,
						(int) $count_blocking_js
					),
					0.5
				);
			}

			// 4b. unsized-images: <img> without width or height attribute (CLS risk).
			$count_unsized = preg_match_all( '#<img\b(?![^>]*\bwidth=)(?![^>]*\bheight=)[^>]*>#i', $html );
			if ( $count_unsized > 5 ) {
				$result['audits'][] = $this->audit(
					'unsized-images',
					__( 'Many images are missing explicit dimensions', 'blaminhor-essentials' ),
					sprintf(
						/* translators: %d: count */
						__( '%d images without width/height. WP usually adds them automatically; check that no plugin or theme is stripping them — this is the leading cause of high Cumulative Layout Shift.', 'blaminhor-essentials' ),
						(int) $count_unsized
					),
					0.5
				);
			}

			// 4c. modern-image-formats — count .jpg/.png that don't have a webp/avif variant alongside.
			$count_jpeg = preg_match_all( '#<img\b[^>]*\.(?:jpe?g|png)["\'][^>]*>#i', $html );
			if ( $count_jpeg > 6 ) {
				$result['audits'][] = $this->audit(
					'modern-image-formats',
					__( 'No modern image formats (WebP / AVIF)', 'blaminhor-essentials' ),
					__( 'The page serves JPEG/PNG. Modern formats are 25–50% lighter. WordPress core supports WebP/AVIF since 6.1; you can also convert at upload via dedicated plugins.', 'blaminhor-essentials' ),
					0.5
				);
			}

			// 4d. font-display: Google Fonts URL without display=swap
			if ( preg_match( '#fonts\.googleapis\.com/css[^"\']*#', $html, $gf ) && false === stripos( $gf[0], 'display=' ) ) {
				$result['audits'][] = $this->audit(
					'font-display',
					__( 'Google Fonts loaded without display=swap', 'blaminhor-essentials' ),
					__( 'Browsers hide text until the custom font loads (FOIT). The display=swap descriptor lets fallback text render immediately while the font downloads.', 'blaminhor-essentials' ),
					0.5
				);
			}

			// 4e. third-party-summary — count external scripts/fonts
			$third_party_origins = $this->collect_third_party_origins( $html );
			if ( count( $third_party_origins ) > 4 ) {
				$result['audits'][] = $this->audit(
					'third-party-summary',
					__( 'Many third-party origins', 'blaminhor-essentials' ),
					sprintf(
						/* translators: %d: count */
						__( '%d external origins detected (analytics, embeds, CDNs, ad networks). Each adds latency on the first hit; preconnecting the busiest ones can shave a few hundred milliseconds.', 'blaminhor-essentials' ),
						count( $third_party_origins )
					),
					0.5
				);
			}

			// 4f. offscreen-images — img without loading=lazy AND past the fold (heuristic)
			$total_imgs = preg_match_all( '#<img\b#i', $html );
			$lazy_imgs  = preg_match_all( '#<img\b[^>]*\bloading=["\']?lazy["\']?#i', $html );
			if ( $total_imgs > 5 && $lazy_imgs < ceil( $total_imgs * 0.4 ) ) {
				$result['audits'][] = $this->audit(
					'offscreen-images',
					__( 'Most images are not lazy-loaded', 'blaminhor-essentials' ),
					sprintf(
						/* translators: 1: lazy count, 2: total */
						__( '%1$d/%2$d images carry loading="lazy". Lazy-loading below-the-fold images frees bandwidth and main-thread time for content the user actually sees.', 'blaminhor-essentials' ),
						(int) $lazy_imgs,
						(int) $total_imgs
					),
					0.5
				);
			}

			// 4g. uses-rel-preconnect — heuristic: if 3rd-parties exist but no preconnect link in head
			if ( count( $third_party_origins ) > 1 && false === stripos( $head_html, 'rel="preconnect"' ) && false === stripos( $head_html, "rel='preconnect'" ) ) {
				$result['audits'][] = $this->audit(
					'uses-rel-preconnect',
					__( 'No <link rel="preconnect"> for third-party origins', 'blaminhor-essentials' ),
					__( 'No preconnect hints for third-party origins (Google Fonts, Analytics, …). Preconnecting can shave a few hundred milliseconds off the first request.', 'blaminhor-essentials' ),
					0.5
				);
			}

			// 4h. dom-size
			$dom_size = preg_match_all( '#<[a-zA-Z][^>]*>#', $html );
			if ( $dom_size > 1500 ) {
				$result['audits'][] = $this->audit(
					'dom-size',
					__( 'Large DOM size', 'blaminhor-essentials' ),
					sprintf(
						/* translators: %d: count */
						__( 'Approx. %d HTML elements. Above ~1500 nodes, layout cost rises sharply on low-end devices. This is a theme/content concern; the plugin cannot fix it.', 'blaminhor-essentials' ),
						(int) $dom_size
					),
					0.5
				);
			}
		}

		// 5. Plugin / theme heuristics
		$active_plugins = (array) get_option( 'active_plugins', array() );
		$heavy = array(
			'elementor/elementor.php'           => 'Elementor',
			'elementor-pro/elementor-pro.php'   => 'Elementor Pro',
			'js_composer/js_composer.php'       => 'WPBakery',
			'revslider/revslider.php'           => 'Slider Revolution',
			'wpforms/wpforms.php'               => 'WPForms',
			'gravityforms/gravityforms.php'     => 'Gravity Forms',
			'jetpack/jetpack.php'               => 'Jetpack',
		);
		$found_heavy = array();
		foreach ( $heavy as $slug => $label ) {
			if ( in_array( $slug, $active_plugins, true ) ) {
				$found_heavy[] = $label;
			}
		}
		if ( ! empty( $found_heavy ) ) {
			$result['audits'][] = $this->audit(
				'bootup-time',
				__( 'Heavy front-end plugins detected', 'blaminhor-essentials' ),
				sprintf(
					/* translators: %s: comma-separated plugin names */
					__( 'Active heavy plugins: %s. Each adds JavaScript to parse on every page. Defer JS, Delay JS or Critical CSS can help; lighter alternatives often help more.', 'blaminhor-essentials' ),
					implode( ', ', $found_heavy )
				),
				0.5
			);
		}

		// Heuristic score — compute a 0..100 rough estimate
		$result['score'] = $this->compute_pseudo_score( $result['audits'] );

		set_transient( 'blaminhor_cache_audit_internal_' . md5( $url ), $result, self::TRANSIENT_TTL );
		return $result;
	}

	/**
	 * @param string $id
	 * @param string $title
	 * @param string $description
	 * @param float|null $score
	 * @return array
	 */
	private function audit( $id, $title, $description, $score ) {
		return array(
			'id'           => $id,
			'title'        => $title,
			'description'  => $description,
			'displayValue' => '',
			'score'        => $score,
			'numericValue' => null,
			'urls'         => array(),
		);
	}

	/**
	 * Best-effort TTFB measurement. Two GETs to capture variance.
	 *
	 * @param string $url
	 * @return int Milliseconds.
	 */
	private function measure_ttfb( $url ) {
		$samples = array();
		for ( $i = 0; $i < 2; $i++ ) {
			$t = microtime( true );
			$response = wp_remote_get( $url, array( 'timeout' => 15, 'sslverify' => false, 'user-agent' => 'Blaminhor-Cache-Auditor/1.0' ) );
			if ( ! is_wp_error( $response ) ) {
				$samples[] = (int) ( ( microtime( true ) - $t ) * 1000 );
			}
		}
		if ( empty( $samples ) ) {
			return 0;
		}
		return (int) round( array_sum( $samples ) / count( $samples ) );
	}

	/**
	 * Collect unique third-party origins from <script src> and <link href>.
	 *
	 * @param string $html
	 * @return string[]
	 */
	private function collect_third_party_origins( $html ) {
		$origins  = array();
		$site_host = wp_parse_url( home_url(), PHP_URL_HOST );
		if ( preg_match_all( '#<(?:script|link|img|iframe)\b[^>]*\b(?:src|href)=["\']([^"\']+)["\']#i', $html, $m ) ) {
			foreach ( $m[1] as $u ) {
				$host = wp_parse_url( $u, PHP_URL_HOST );
				if ( ! $host || $host === $site_host ) {
					continue;
				}
				$origins[ $host ] = 1;
			}
		}
		return array_keys( $origins );
	}

	/**
	 * Heuristic pseudo-score from the audit list. Honest about being rough.
	 *
	 * @param array $audits
	 * @return int 0..100
	 */
	private function compute_pseudo_score( $audits ) {
		if ( empty( $audits ) ) {
			return 95;
		}
		$penalty = 0;
		foreach ( $audits as $a ) {
			$score = isset( $a['score'] ) ? (float) $a['score'] : 0.5;
			$penalty += ( 1.0 - $score ) * 8; // each audit caps at -8 points
		}
		$score = 95 - (int) round( $penalty );
		return max( 0, min( 100, $score ) );
	}

	/**
	 * Read the Content-Encoding header from a WP HTTP headers bag, handling
	 * both array access and WpOrg\Requests\Utility\CaseInsensitiveDictionary.
	 *
	 * @param mixed $headers
	 * @return string
	 */
	private function read_content_encoding( $headers ) {
		if ( ! $headers ) {
			return '';
		}
		if ( is_array( $headers ) ) {
			if ( isset( $headers['content-encoding'] ) ) {
				return (string) $headers['content-encoding'];
			}
			if ( isset( $headers['Content-Encoding'] ) ) {
				return (string) $headers['Content-Encoding'];
			}
			return '';
		}
		if ( method_exists( $headers, 'offsetExists' ) ) {
			if ( $headers->offsetExists( 'content-encoding' ) ) {
				return (string) $headers['content-encoding'];
			}
			if ( $headers->offsetExists( 'Content-Encoding' ) ) {
				return (string) $headers['Content-Encoding'];
			}
		}
		return '';
	}

	/**
	 * When the response was kept compressed (decompress => false) we still
	 * need readable HTML for the downstream audits (render-blocking,
	 * unsized-images, font-display, …). Decode gzip/deflate locally and
	 * surface the body untouched for brotli (PHP rarely ships brotli ext).
	 *
	 * @param string $body
	 * @param mixed  $headers
	 * @return string
	 */
	private function maybe_decode_body( $body, $headers ) {
		if ( '' === $body ) {
			return '';
		}
		$enc = strtolower( $this->read_content_encoding( $headers ) );
		if ( '' === $enc ) {
			$enc = $this->sniff_compression( $body );
		}
		if ( false !== strpos( $enc, 'gzip' ) && function_exists( 'gzdecode' ) ) {
			$decoded = @gzdecode( $body );
			if ( is_string( $decoded ) && '' !== $decoded ) {
				return $decoded;
			}
		}
		if ( false !== strpos( $enc, 'deflate' ) && function_exists( 'gzinflate' ) ) {
			$decoded = @gzinflate( $body );
			if ( is_string( $decoded ) && '' !== $decoded ) {
				return $decoded;
			}
		}
		if ( false !== strpos( $enc, 'br' ) && function_exists( 'brotli_uncompress' ) ) {
			$decoded = @brotli_uncompress( $body );
			if ( is_string( $decoded ) && '' !== $decoded ) {
				return $decoded;
			}
		}
		return $body;
	}

	/**
	 * Best-effort body sniff: gzip starts with 0x1F 0x8B; ASCII HTML starts
	 * with whitespace, '<' or BOM. Anything else with high-byte density is
	 * almost certainly compressed text (typically brotli, which we cannot
	 * fingerprint by header but can detect by entropy).
	 *
	 * @param string $body
	 * @return string '' | 'gzip' | 'br' | 'deflate'
	 */
	private function sniff_compression( $body ) {
		if ( strlen( $body ) < 8 ) {
			return '';
		}
		if ( "\x1F\x8B" === substr( $body, 0, 2 ) ) {
			return 'gzip';
		}
		// deflate (raw) often starts with 0x78 0x9C / 0x78 0xDA.
		$b0 = ord( $body[0] );
		$b1 = ord( $body[1] );
		if ( 0x78 === $b0 && in_array( $b1, array( 0x01, 0x9C, 0xDA ), true ) ) {
			return 'deflate';
		}
		// Heuristic for brotli: HTML always begins with whitespace, '<', BOM
		// or a doctype char. A non-printable, non-whitespace first byte on a
		// response advertised as text/html overwhelmingly indicates a
		// compressed payload (almost always brotli on modern hosts).
		$first = $body[0];
		if ( $first < ' ' && $first !== "\n" && $first !== "\r" && $first !== "\t" ) {
			return 'br';
		}
		return '';
	}
}
