<?php
/**
 * Blaminhor Essentials — Advanced Cache Drop-in
 *
 * BLAMINHOR_ESSENTIALS_DROPIN — DO NOT REMOVE this marker; the plugin uses it
 * to detect that the drop-in belongs to it (avoids removing other plugins'
 * drop-ins on deactivation).
 *
 * This file is a TEMPLATE. The installer (Blaminhor_Essentials_Cache_Dropin_Installer)
 * substitutes the `__BE_*__` placeholders with absolute paths and parameters
 * before copying it to wp-content/advanced-cache.php.
 *
 * Loaded by WordPress in wp-settings.php (line ~88) when WP_CACHE is true,
 * BEFORE most of WP loads. We must therefore:
 *   - rely on plain PHP (no WP functions)
 *   - fail open (return) on any unexpected condition so the site keeps working
 *   - never modify global state beyond reading the request
 *
 * @package BlaminhorEssentials
 */

// phpcs:disable WordPress.Security.ValidatedSanitizedInput
// phpcs:disable WordPress.Security.NonceVerification
// phpcs:disable WordPress.PHP.DiscouragedPHPFunctions
// phpcs:disable WordPress.WP.AlternativeFunctions

// Refuse direct HTTP access. WordPress loads this drop-in via wp-settings.php
// where ABSPATH is already defined; if it isn't, we are being invoked outside
// of WP's bootstrap and should not run.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

// Bail if PHP-CLI / cron run.
if ( PHP_SAPI === 'cli' ) {
	return;
}

// Bail if the user explicitly toggled the kill-switch constant.
if ( defined( 'BE_CACHE_SAFE_MODE' ) && BE_CACHE_SAFE_MODE ) {
	return;
}

// Bail on any non-cacheable request method.
$be_method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( $_SERVER['REQUEST_METHOD'] ) : 'GET';
if ( 'GET' !== $be_method && 'HEAD' !== $be_method ) {
	return;
}

// Bail if WP is generating an admin-ajax / REST / login / cron request.
$be_uri = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : '';
if ( '' === $be_uri ) {
	return;
}
if ( strpos( $be_uri, '/wp-admin/' ) !== false ) { return; }
if ( strpos( $be_uri, '/wp-login.php' ) !== false ) { return; }
if ( strpos( $be_uri, '/xmlrpc.php' ) !== false ) { return; }
if ( strpos( $be_uri, '/wp-cron.php' ) !== false ) { return; }
if ( strpos( $be_uri, '/wp-json/' ) !== false ) { return; }
if ( strpos( $be_uri, '?rest_route=' ) !== false ) { return; }
if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) { return; }
if ( defined( 'DOING_CRON' ) && DOING_CRON ) { return; }
if ( defined( 'WP_CLI' ) && WP_CLI ) { return; }

// Persistent kill-switch flag.
if ( file_exists( '__BE_SAFE_FLAG__' ) ) {
	return;
}

// Cross-module bypass sentinels (since 1.9.8.7). Sibling files of the safe-mode
// flag, written by other modules to opt every visitor out of the cache while
// their critical state is active. Each is a tiny zero-byte presence flag —
// reading file_exists() costs ~1 syscall, kept fast.
$be_sentinels_dir = dirname( '__BE_SAFE_FLAG__' );
// Maintenance module: serve the maintenance/coming-soon page fresh, never the
// home/page that was cached BEFORE the operator flipped the switch on.
if ( file_exists( $be_sentinels_dir . '/.maintenance' ) ) {
	return;
}
// Fatal Error Recovery: a recoverable fatal was detected on a previous request.
// We must not hand visitors a cached page while the underlying issue might
// affect what WP rendered into that cache file.
if ( file_exists( $be_sentinels_dir . '/.fer_recovery' ) ) {
	return;
}

// Always-on cookie exclusions: logged-in users, WC cart, comment authors.
$be_cookie_blocklist = array(
	'wordpress_logged_in_',
	'wp-postpass_',
	'comment_author_',
	'woocommerce_items_in_cart',
	'woocommerce_cart_hash',
	'wp_woocommerce_session_',
	'edd_items_in_cart',
);
if ( ! empty( $_COOKIE ) ) {
	foreach ( array_keys( $_COOKIE ) as $be_cookie_name ) {
		foreach ( $be_cookie_blocklist as $be_blocked ) {
			if ( strpos( $be_cookie_name, $be_blocked ) === 0 || strpos( $be_cookie_name, $be_blocked ) !== false ) {
				return;
			}
		}
	}
}

// Don't serve cache when query string carries any non-tracking parameter.
// We allow utm_*, gclid, fbclid (purely tracking, same content) and strip them
// when computing the cache key. Anything else → bypass.
$be_qs = isset( $_SERVER['QUERY_STRING'] ) ? $_SERVER['QUERY_STRING'] : '';
$be_query_pairs = array();
if ( '' !== $be_qs ) {
	parse_str( $be_qs, $be_query_pairs );
	foreach ( $be_query_pairs as $be_qkey => $be_qval ) {
		// Strip known tracking params.
		if ( $be_qkey === 'gclid' || $be_qkey === 'fbclid' || strpos( $be_qkey, 'utm_' ) === 0 ) {
			continue;
		}
		// Anything else: bypass. Visiting ?be_no_cache=1 etc. is a feature, not a bug.
		return;
	}
}

// User-agent blocklist (custom + always-on).
$be_ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? (string) $_SERVER['HTTP_USER_AGENT'] : '';
if ( '' !== $be_ua ) {
	$be_ua_low = strtolower( $be_ua );
	if (
		strpos( $be_ua_low, 'lighthouse' ) !== false ||
		strpos( $be_ua_low, 'pagespeed' ) !== false
	) {
		// Lighthouse / PSI: never serve cache so we measure fresh perf.
		return;
	}
}

/**
 * Variant key — must match Page_Cache::variant_key() in PHP.
 *
 * Mobile is the only optional variant in this drop-in; multilingual / currency
 * variants are also disambiguated via the relevant cookies (set by WPML/PLL/WC)
 * and we therefore include the cookie hash in the variant when those cookies
 * are present.
 */
$be_variant_parts = array( 'desktop' );

if ( '__BE_MOBILE_SEPARATE__' === '1' && '' !== $be_ua ) {
	$be_ua_low = strtolower( $be_ua );
	if (
		strpos( $be_ua_low, 'mobile' ) !== false ||
		strpos( $be_ua_low, 'android' ) !== false ||
		strpos( $be_ua_low, 'iphone' ) !== false ||
		strpos( $be_ua_low, 'ipod' ) !== false ||
		strpos( $be_ua_low, 'blackberry' ) !== false ||
		strpos( $be_ua_low, 'iemobile' ) !== false ||
		strpos( $be_ua_low, 'opera mini' ) !== false ||
		strpos( $be_ua_low, 'opera mobi' ) !== false
	) {
		$be_variant_parts[0] = 'mobile';
	}
}

// Locale / currency cookie variants (WPML/PLL/WC).
$be_variant_cookies = array();
foreach ( array( '_icl_current_language', 'pll_language', 'wp-wpml_current_language', 'wcml_currency', 'currency' ) as $be_v_cookie ) {
	if ( isset( $_COOKIE[ $be_v_cookie ] ) ) {
		$be_variant_cookies[ $be_v_cookie ] = $_COOKIE[ $be_v_cookie ];
	}
}
if ( ! empty( $be_variant_cookies ) ) {
	ksort( $be_variant_cookies );
	$be_variant_parts[] = substr( md5( http_build_query( $be_variant_cookies ) ), 0, 8 );
}

$be_variant_key = implode( '-', $be_variant_parts );

// Resolve cache file path (must match Storage::url_to_cache_dir() in PHP).
$be_scheme = ( ! empty( $_SERVER['HTTPS'] ) && 'off' !== $_SERVER['HTTPS'] ) ? 'https' : 'http';
$be_host   = isset( $_SERVER['HTTP_HOST'] ) ? strtolower( (string) $_SERVER['HTTP_HOST'] ) : '';
// Strip port to match wp_parse_url() behaviour on the PHP write side.
if ( false !== strpos( $be_host, ':' ) ) {
	$be_host = substr( $be_host, 0, strpos( $be_host, ':' ) );
}
$be_host   = preg_replace( '#[^a-z0-9._-]#i', '_', $be_host );

$be_path = parse_url( $be_uri, PHP_URL_PATH );
if ( null === $be_path || '' === $be_path ) {
	$be_path = '/';
}
$be_path = '/' . trim( $be_path, '/' ) . '/';
$be_path = str_replace( '..', '_', $be_path );
$be_path = preg_replace( '#[^a-zA-Z0-9._/-]#', '_', $be_path );

$be_cache_root = '__BE_CACHE_DIR__';
$be_cache_dir  = rtrim( $be_cache_root, '/' ) . '/' . $be_scheme . '/' . $be_host . $be_path;
$be_cache_file = $be_cache_dir . $be_variant_key . '.html';
$be_cache_gz   = $be_cache_file . '.gz';

// Permanent-redirect cache: when a previous request returned 301 or 308 with
// a Location header, the PHP layer wrote a tiny .redirect sibling file. We
// re-emit the redirect immediately, no PHP boot required.
$be_redirect_file = $be_cache_dir . $be_variant_key . '.redirect';
if ( is_file( $be_redirect_file ) ) {
	$be_redirect_payload = @file_get_contents( $be_redirect_file );
	if ( false !== $be_redirect_payload ) {
		$be_redir_lines = explode( "\n", trim( $be_redirect_payload ) );
		if ( 2 === count( $be_redir_lines ) ) {
			$be_redir_status   = (int) $be_redir_lines[0];
			$be_redir_location = trim( $be_redir_lines[1] );
			if ( ( 301 === $be_redir_status || 308 === $be_redir_status ) && '' !== $be_redir_location ) {
				if ( ! headers_sent() ) {
					header( $_SERVER['SERVER_PROTOCOL'] . ' ' . $be_redir_status . ( 301 === $be_redir_status ? ' Moved Permanently' : ' Permanent Redirect' ) );
					header( 'Location: ' . $be_redir_location );
					header( 'X-BE-Cache: HIT-REDIRECT' );
				}
				exit;
			}
		}
	}
}

if ( ! is_file( $be_cache_file ) ) {
	return;
}

// TTL check: if file is older than the configured TTL, bypass and let WP
// regenerate the cache during shutdown.
$be_ttl   = (int) '__BE_TTL__';
$be_mtime = @filemtime( $be_cache_file );
if ( false === $be_mtime ) {
	return;
}
if ( $be_ttl > 0 && ( time() - $be_mtime ) > $be_ttl ) {
	return;
}

// Plugin-upgrade invalidation: every time the cache module reinstalls this
// drop-in (FTP/manual ZIP upload, /release, settings change), the drop-in's
// mtime is bumped. Any cache file older than the drop-in was baked under a
// previous build and could carry stale optimisations (RUCSS strip, missing
// parent stylesheet, …). Wipe the file pair and bypass so WP re-bakes a
// fresh page on this very request.
$be_dropin_mtime = @filemtime( __FILE__ );
if ( false !== $be_dropin_mtime && $be_dropin_mtime > $be_mtime ) {
	@unlink( $be_cache_file );
	if ( is_file( $be_cache_gz ) ) {
		@unlink( $be_cache_gz );
	}
	if ( is_file( $be_redirect_file ) ) {
		@unlink( $be_redirect_file );
	}
	return;
}

// CRITICAL safety net: if anything (a BOM in wp-config, a notice from a
// mu-plugin loaded ahead of us, output_buffering being off, …) has already
// emitted bytes, we cannot send Content-Encoding/Content-Type cleanly.
// Sending a gzipped body without a matching header would dump binary garbage
// into the browser viewport. Bail to WP and let it render the page normally;
// it's slower but never corrupts the page.
if ( headers_sent() ) {
	return;
}

// Drop any output buffer that might have been started by an earlier require
// (PHP's default output_buffering ini, opcache pre-warmer, etc.). We need a
// pristine response so the file's bytes hit the wire unmodified.
while ( ob_get_level() > 0 ) {
	@ob_end_clean();
}

// 304 Not Modified handling — saves bandwidth on returning visitors.
// ETag includes file size so a same-second rebuild with different content gets a fresh tag.
$be_size           = @filesize( $be_cache_file );
$be_etag           = 'W/"' . substr( md5( $be_mtime . '_' . $be_size . '_' . $be_cache_file ), 0, 16 ) . '"';
$be_last_modified  = gmdate( 'D, d M Y H:i:s', $be_mtime ) . ' GMT';
$be_inm            = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? trim( (string) $_SERVER['HTTP_IF_NONE_MATCH'] ) : '';
$be_ims            = isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ? trim( (string) $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) : '';
if ( $be_inm === $be_etag || $be_ims === $be_last_modified ) {
	header( $_SERVER['SERVER_PROTOCOL'] . ' 304 Not Modified' );
	header( 'X-BE-Cache: HIT-304' );
	exit;
}

// Standard headers + serve.
header( 'X-BE-Cache: HIT' );
header( 'Last-Modified: ' . $be_last_modified );
header( 'ETag: ' . $be_etag );
header( 'Vary: Accept-Encoding' );

$be_accept_gz = isset( $_SERVER['HTTP_ACCEPT_ENCODING'] ) && strpos( (string) $_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip' ) !== false;

// Only serve the pre-compressed file if we are CERTAIN the upstream chain
// will not strip or rewrite Content-Encoding (Vary makes proxies cache the
// gzip variant separately). If zlib.output_compression is on, PHP would
// double-compress our already-gzipped body and the browser would render
// binary garbage — guard against that by serving plain HTML in that case.
$be_zlib_compresses = (bool) ini_get( 'zlib.output_compression' );

if ( $be_accept_gz && ! $be_zlib_compresses && is_file( $be_cache_gz ) ) {
	header( 'Content-Encoding: gzip' );
	header( 'Content-Type: text/html; charset=UTF-8' );
	header( 'Content-Length: ' . (string) filesize( $be_cache_gz ) );
	if ( 'HEAD' !== $be_method ) {
		readfile( $be_cache_gz );
	}
	exit;
}

header( 'Content-Type: text/html; charset=UTF-8' );
header( 'Content-Length: ' . (string) filesize( $be_cache_file ) );
if ( 'HEAD' !== $be_method ) {
	readfile( $be_cache_file );
}
exit;
