<?php
/**
 * BaseCloud Boost — Resource Hints
 *
 * Outputs early resource hints in <head>:
 *  - preconnect / dns-prefetch for common third-party origins
 *  - Custom DNS prefetch domains from settings
 *  - Font preloading (crossorigin)
 *  - Preload key requests (critical CSS/JS/fonts)
 *  - Inline global critical CSS
 *
 * @package BaseCloud\Boost\Optimizer
 */

declare( strict_types=1 );

namespace BaseCloud\Boost\Optimizer;

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

/**
 * Outputs early <link> resource hints.
 */
class Resource_Hints {

	/**
	 * Common third-party origins — preconnect + dns-prefetch.
	 *
	 * @var array<string, bool> origin => needs_crossorigin
	 */
	private const DEFAULT_ORIGINS = [
		'https://www.googletagmanager.com' => false,
		'https://www.google-analytics.com' => false,
		'https://fonts.googleapis.com'     => false,
		'https://fonts.gstatic.com'        => true,
		'https://ajax.googleapis.com'      => false,
		'https://cdnjs.cloudflare.com'     => false,
	];

	/**
	 * Boot resource hints module.
	 *
	 * @return void
	 */
	public function boot(): void {
		if ( is_admin() ) {
			return;
		}
		// Priority 1 — output hints as early as possible in <head>.
		add_action( 'wp_head',            [ $this, 'output_hints' ], 1 );
		add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_critical_css' ], 2 );

		// Buffer-time preconnect to the LCP hero's origin. The hero URL is only
		// final after LCP_Optimizer (BCBOOST_buffer priority 12), so this runs at
		// 20 — the CDN host is already covered by the wp_head list above, this
		// catches heroes served from a different third-party origin.
		if ( BCBOOST_get_option( 'auto_preconnect', true ) ) {
			add_filter( 'BCBOOST_buffer', [ $this, 'auto_preconnect_lcp' ], 20 );
			// Same gate/priority tier: cross-origin video-background heroes
			// (Oxygen's oxy-video-background pattern) benefit from the same
			// handshake warm-up as image heroes. Registered after the image pass
			// so its preconnect counts against this pass's dedupe/budget.
			add_filter( 'BCBOOST_buffer', [ $this, 'auto_preconnect_video_hero' ], 20 );
		}

		// Smart third-party preconnects: DEFAULT_ORIGINS are no longer emitted
		// blindly at wp_head — this buffer pass (after the hero preconnect at 20)
		// emits them only when the origin actually appears in the page, capped so
		// the browser's useful-preconnect budget (~4-6) is never exceeded. An
		// unused preconnect burns a TLS handshake in the critical window and
		// draws Lighthouse's "preconnect not used" warning.
		if ( BCBOOST_get_option( 'resource_hints', true ) ) {
			add_filter( 'BCBOOST_buffer', [ $this, 'smart_preconnects' ], 21 );
		}
	}

	/**
	 * Emit preconnect/dns-prefetch for the known third-party origins that are
	 * actually referenced by this page's HTML. Total preconnects (including
	 * those already present from the CDN/hero/theme) are capped at 4 — beyond
	 * that browsers ignore or waste them. Never throws; on failure the page is
	 * returned unchanged.
	 *
	 * @param string $html Full page HTML.
	 * @return string
	 */
	public function smart_preconnects( string $html ): string {
		try {
			$head_open = stripos( $html, '<head' );
			$head_end  = stripos( $html, '</head>' );
			if ( false === $head_open || false === $head_end ) {
				return $html;
			}

			// Existing preconnects count against the budget.
			$head   = substr( $html, $head_open, $head_end - $head_open );
			$budget = 4 - (int) preg_match_all( '/<link\b[^>]*rel=["\']preconnect["\']/i', $head );
			if ( $budget <= 0 ) {
				return $html;
			}

			$links = '';
			foreach ( self::DEFAULT_ORIGINS as $origin => $crossorigin ) {
				if ( $budget <= 0 ) {
					break;
				}
				$host = (string) wp_parse_url( (string) $origin, PHP_URL_HOST );
				if ( '' === $host ) {
					continue;
				}
				// Referenced by the page, and not already hinted?
				if ( false === stripos( $html, $host ) ) {
					continue;
				}
				if ( false !== stripos( $head, 'preconnect" href="https://' . $host )
					|| preg_match( '/<link\b[^>]*rel=["\']preconnect["\'][^>]*' . preg_quote( $host, '/' ) . '/i', $head ) ) {
					continue;
				}
				$safe   = esc_url( (string) $origin );
				$cross  = $crossorigin ? ' crossorigin' : '';
				$links .= '<link rel="preconnect" href="' . $safe . '"' . $cross . '>'
					. '<link rel="dns-prefetch" href="' . $safe . '">';
				$budget--;
			}
			if ( '' === $links ) {
				return $html;
			}

			// Inject right after the <head …> open tag — earliest possible spot.
			$tag_end = strpos( $html, '>', $head_open );
			if ( false === $tag_end ) {
				return $html;
			}
			return substr_replace( $html, $links, $tag_end + 1, 0 );
		} catch ( \Throwable $e ) {
			return $html;
		}
	}

	/**
	 * Output all resource hints.
	 *
	 * @return void
	 */
	public function output_hints(): void {
		// ── Preconnect / dns-prefetch origins ─────────────────────────────
		// Since 1.3.6 the static third-party list is NOT emitted here — the
		// smart_preconnects buffer pass emits only the origins the page really
		// references (unused preconnects waste the critical window). This
		// wp_head emission carries just the filter-registered origins that are
		// used by definition (the BunnyCDN host under `auto_preconnect`).
		$origins = (array) apply_filters( 'BCBOOST_preconnect_origins', [] );
		foreach ( $origins as $origin => $crossorigin ) {
			$safe  = esc_url( (string) $origin );
			$cross = $crossorigin ? ' crossorigin' : '';
			echo '<link rel="preconnect" href="' . $safe . '"' . $cross . ">\n";
			echo '<link rel="dns-prefetch" href="' . $safe . "\">\n";
		}

		// ── Custom DNS prefetch domains from settings ─────────────────────
		$custom_dns = array_filter( array_map( 'trim', explode( "\n", (string) BCBOOST_get_option( 'prefetch_dns', '' ) ) ) );
		foreach ( $custom_dns as $domain ) {
			$safe = esc_url( $domain );
			if ( $safe ) {
				echo '<link rel="dns-prefetch" href="' . $safe . "\">\n";
			}
		}

		// ── Font preloading ───────────────────────────────────────────────
		$preload_fonts = array_filter( array_map( 'trim', explode( "\n", (string) BCBOOST_get_option( 'preload_fonts', '' ) ) ) );
		foreach ( $preload_fonts as $font_url ) {
			$safe = esc_url( $font_url );
			if ( $safe ) {
				// Detect type from extension for better browser hints.
				// (Plain array lookup — match() is PHP 8.0+, plugin floor is 7.4.)
				$ext      = strtolower( pathinfo( strtok( $font_url, '?' ), PATHINFO_EXTENSION ) );
				$type_map = [
					'woff2' => ' type="font/woff2"',
					'woff'  => ' type="font/woff"',
					'ttf'   => ' type="font/ttf"',
					'otf'   => ' type="font/otf"',
				];
				$type     = $type_map[ $ext ] ?? '';
				echo '<link rel="preload" as="font"' . $type . ' href="' . $safe . '" crossorigin>' . "\n";
			}
		}

		// ── Preload key requests (critical CSS/JS/fonts) ──────────────────
		$key_requests = array_filter( array_map( 'trim', explode( "\n", (string) BCBOOST_get_option( 'preload_key_requests', '' ) ) ) );
		foreach ( $key_requests as $url ) {
			$safe = esc_url( $url );
			if ( ! $safe ) {
				continue;
			}
			// (Plain array lookup — match() is PHP 8.0+, plugin floor is 7.4.)
			$ext    = strtolower( pathinfo( strtok( $url, '?' ), PATHINFO_EXTENSION ) );
			$as_map = [
				'css'   => 'style',
				'js'    => 'script',
				'woff2' => 'font',
				'woff'  => 'font',
				'ttf'   => 'font',
				'otf'   => 'font',
				'jpg'   => 'image',
				'jpeg'  => 'image',
				'png'   => 'image',
				'webp'  => 'image',
				'avif'  => 'image',
				'gif'   => 'image',
				'svg'   => 'image',
			];
			$as     = $as_map[ $ext ] ?? 'fetch';
			$crossorigin = ( 'font' === $as ) ? ' crossorigin' : '';
			echo '<link rel="preload" href="' . $safe . '" as="' . $as . '"' . $crossorigin . ">\n";
		}
	}

	/**
	 * Enqueue global critical CSS via wp_add_inline_style.
	 *
	 * @return void
	 */
	public function enqueue_critical_css(): void {
		// Only inline when the user has switched Critical CSS on — the textarea and
		// this toggle live together in the admin, so an unticked toggle must mean
		// "don't inline" (previously the CSS was inlined regardless of the toggle).
		if ( ! BCBOOST_get_option( 'critical_css_enabled', false ) ) {
			return;
		}

		$css = trim( (string) BCBOOST_get_option( 'critical_css', '' ) );
		if ( '' === $css ) {
			return;
		}
		// Sanitize CSS: strip tags, remove unsafe CSS expressions and escape.
		$css = wp_strip_all_tags( $css );
		$css = preg_replace( '/expression\s*\(|javascript\s*:|behavior\s*:|@import|@charset/i', '', $css );
		wp_register_style( 'bcboost-global-critical-css', false, [], BCBOOST_VERSION ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters
		wp_enqueue_style( 'bcboost-global-critical-css' );
		wp_add_inline_style( 'bcboost-global-critical-css', $css );
	}

	/**
	 * Preconnect to the LCP hero image's origin when it is a cross-origin https
	 * host (a CDN or third-party image host). Warming the TCP+TLS handshake before
	 * the hero download shaves one round-trip off LCP on high-latency mobile.
	 *
	 * Runs on BCBOOST_buffer at priority 20 — after LCP_Optimizer (12) and after
	 * the Lazy_Loader's own <img> preload have finalised the hero, so the URL read
	 * here is the exact one the browser will fetch. Never throws: any failure logs
	 * under WP_DEBUG_LOG and returns the HTML untouched (fail open).
	 *
	 * @param string $html Full page HTML.
	 * @return string
	 */
	public function auto_preconnect_lcp( string $html ): string {
		try {
			if ( '' === $html || stripos( $html, '</head>' ) === false ) {
				return $html;
			}
			// Boost's LCP preload carries this shared id (exactly one per page from
			// either emitter). Fall back to any image preload the page already has.
			if ( ! preg_match( '/<link\b[^>]*id=["\']bcboost-lcp-preload["\'][^>]*>/i', $html, $m )
				&& ! preg_match( '/<link\b[^>]*rel=["\']preload["\'][^>]*as=["\']image["\'][^>]*>/i', $html, $m ) ) {
				return $html;
			}
			$tag = $m[0];

			// Resolve the hero URL: a single href, or the first candidate of a
			// responsive imagesrcset.
			$url = '';
			if ( preg_match( '/(?<![-\w])href\s*=\s*["\']([^"\']+)["\']/i', $tag, $hm ) ) {
				$url = $hm[1];
			} elseif ( preg_match( '/(?<![-\w])imagesrcset\s*=\s*["\']([^"\']+)["\']/i', $tag, $sm ) ) {
				$first = trim( (string) ( explode( ',', $sm[1] )[0] ?? '' ) );
				$parts = preg_split( '/\s+/', $first ) ?: [];
				$url   = (string) ( $parts[0] ?? '' );
			}
			$url = trim( html_entity_decode( $url, ENT_QUOTES ) );
			if ( '' === $url ) {
				return $html;
			}
			if ( strpos( $url, '//' ) === 0 ) {
				$url = 'https:' . $url;
			}

			$host   = wp_parse_url( $url, PHP_URL_HOST );
			$scheme = (string) wp_parse_url( $url, PHP_URL_SCHEME );
			if ( ! $host || ( '' !== $scheme && 'https' !== strtolower( $scheme ) ) ) {
				return $html; // Only cross-origin https hosts benefit from preconnect.
			}

			$site_host = wp_parse_url( home_url(), PHP_URL_HOST );
			if ( $site_host && strcasecmp( (string) $host, (string) $site_host ) === 0 ) {
				return $html; // Same origin — already connected.
			}

			// Already preconnected (our CDN hint, the theme, or a previous pass)?
			if ( preg_match( '/<link\b[^>]*rel=["\']preconnect["\'][^>]*' . preg_quote( (string) $host, '/' ) . '/i', $html ) ) {
				return $html;
			}

			// Image origins load as plain (non-CORS) requests, so NO `crossorigin`
			// here — a CORS-mode preconnect would not be reused for the hero fetch.
			$link = '<link rel="preconnect" href="' . esc_url( 'https://' . $host ) . '">' . "\n";

			$out = preg_replace( '/<head\b[^>]*>/i', '$0' . $link, $html, 1 );
			return is_string( $out ) ? $out : $html;
		} catch ( \Throwable $e ) {
			if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
				error_log( '[BaseCloud Boost] Resource_Hints::auto_preconnect_lcp failed open: ' . $e->getMessage() );
			}
			return $html;
		}
	}

	/**
	 * Preconnect to a video-background hero's origin. When an above-the-fold
	 * autoplaying <video> (Oxygen's oxy-video-background pattern, hero video
	 * sections) streams from a DIFFERENT https origin than the page (an Azure
	 * blob, S3, a video CDN), warming that handshake before the first byte of
	 * video shaves a round-trip exactly like the image-hero preconnect above.
	 *
	 * Scope is deliberately tight: only the FIRST autoplay video inside the
	 * first 150 KB of <body> (above-the-fold heuristic) is considered, at most
	 * ONE video origin is hinted, and the emission shares the ≤4-preconnect
	 * head budget with smart_preconnects(). The video element itself is never
	 * touched — no lazy-loading, no facade, no preload attribute changes
	 * (12cutssteakhouse danger map).
	 *
	 * Side effect: if that same hero video has no poster attribute, a
	 * WP_DEBUG_LOG hint is emitted — a poster is what gives the browser an LCP
	 * paint before video frames arrive. No admin UI for this yet.
	 *
	 * Never throws: any failure logs under WP_DEBUG_LOG and returns the HTML
	 * untouched (fail open).
	 *
	 * @param string $html Full page HTML.
	 * @return string
	 */
	public function auto_preconnect_video_hero( string $html ): string {
		try {
			if ( '' === $html || stripos( $html, '<video' ) === false ) {
				return $html;
			}
			$head_open = stripos( $html, '<head' );
			$head_end  = stripos( $html, '</head>' );
			$body_open = stripos( $html, '<body' );
			if ( false === $head_open || false === $head_end || false === $body_open ) {
				return $html;
			}

			// Above-the-fold heuristic: only the first 150 KB of <body>.
			$fold = substr( $html, $body_open, 150 * 1024 );
			if ( ! preg_match( '/<video\b[^>]*(?<![-\w])autoplay(?![-\w])[^>]*>/i', $fold, $vm, PREG_OFFSET_CAPTURE ) ) {
				return $html;
			}
			$open_tag = $vm[0][0];

			// Poster nudge: an autoplay hero without a poster paints nothing until
			// video frames arrive — the LCP fix is content, not plugin config.
			if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG
				&& ! preg_match( '/(?<![-\w])poster\s*=/i', $open_tag ) ) {
				error_log(
					'[BaseCloud Boost] Resource_Hints: above-the-fold autoplay <video> has no poster attribute on '
					. ( $_SERVER['REQUEST_URI'] ?? '?' )
					. ' — add a poster image so the browser has an LCP paint before video frames arrive.'
				);
			}

			// Resolve the stream URL: src on <video>, else the first <source>.
			$url = '';
			if ( preg_match( '/(?<![-\w])src\s*=\s*["\']([^"\']+)["\']/i', $open_tag, $sm ) ) {
				$url = $sm[1];
			} else {
				$close   = stripos( $fold, '</video>', (int) $vm[0][1] );
				$element = substr( $fold, (int) $vm[0][1], ( false === $close ? 4096 : $close - (int) $vm[0][1] ) );
				if ( preg_match( '/<source\b[^>]*(?<![-\w])src\s*=\s*["\']([^"\']+)["\']/i', $element, $sm ) ) {
					$url = $sm[1];
				}
			}
			$url = trim( html_entity_decode( $url, ENT_QUOTES ) );
			if ( '' === $url ) {
				return $html;
			}
			if ( strpos( $url, '//' ) === 0 ) {
				$url = 'https:' . $url;
			}

			$host   = wp_parse_url( $url, PHP_URL_HOST );
			$scheme = (string) wp_parse_url( $url, PHP_URL_SCHEME );
			if ( ! $host || 'https' !== strtolower( $scheme ) ) {
				return $html; // Relative/same-origin or non-https — nothing to warm.
			}
			$site_host = wp_parse_url( home_url(), PHP_URL_HOST );
			if ( $site_host && strcasecmp( (string) $host, (string) $site_host ) === 0 ) {
				return $html; // Same origin — already connected.
			}

			// Already preconnected (CDN hint, image-hero pass, or the theme)?
			if ( preg_match( '/<link\b[^>]*rel=["\']preconnect["\'][^>]*' . preg_quote( (string) $host, '/' ) . '/i', $html ) ) {
				return $html;
			}

			// Shared head budget (see smart_preconnects): beyond ~4 preconnects
			// browsers ignore or waste them — never spend the budget on a hint
			// that can't help.
			$head = substr( $html, $head_open, $head_end - $head_open );
			if ( preg_match_all( '/<link\b[^>]*rel=["\']preconnect["\']/i', $head ) >= 4 ) {
				return $html;
			}

			// Media loads as a plain (non-CORS) request — no `crossorigin` here.
			$link = '<link rel="preconnect" href="' . esc_url( 'https://' . $host ) . '">' . "\n";

			$out = preg_replace( '/<head\b[^>]*>/i', '$0' . $link, $html, 1 );
			return is_string( $out ) ? $out : $html;
		} catch ( \Throwable $e ) {
			if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
				error_log( '[BaseCloud Boost] Resource_Hints::auto_preconnect_video_hero failed open: ' . $e->getMessage() );
			}
			return $html;
		}
	}
}
