<?php
/**
 * Cache module — Page Cache (capture + write)
 *
 * Owns the "miss" path: when the drop-in did not have a usable cache file,
 * WordPress generates the page normally. We open an output buffer early and,
 * on shutdown, write the rendered HTML to disk so the drop-in can serve it
 * on the next request.
 *
 * @package BlaminhorEssentials
 */

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

class Blaminhor_Essentials_Cache_Page_Cache {

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

	/** @var Blaminhor_Essentials_Cache_Storage */
	private $storage;

	/** @var Blaminhor_Essentials_Cache_Safe_Mode */
	private $safe_mode;

	/**
	 * Whether we have started an output buffer for this request.
	 *
	 * @var bool
	 */
	private $buffering = false;

	/**
	 * Reason this request became uncacheable. Useful for debug `X-BE-Cache: BYPASS:<reason>`.
	 *
	 * @var string
	 */
	private $bypass_reason = '';

	public function __construct(
		Blaminhor_Essentials_Module_Cache $parent,
		Blaminhor_Essentials_Cache_Storage $storage,
		Blaminhor_Essentials_Cache_Safe_Mode $safe_mode
	) {
		$this->parent    = $parent;
		$this->storage   = $storage;
		$this->safe_mode = $safe_mode;
	}

	public function register_hooks() {
		// Earliest reasonable hook AFTER WP knows whether the user is logged in
		// (which happens around `init`). We use priority 99 so other plugins
		// can pre-emptively bypass us via `blaminhor_essentials_cache_skip` filter.
		add_action( 'template_redirect', array( $this, 'maybe_start_buffering' ), 0 );
		add_action( 'shutdown', array( $this, 'on_shutdown' ), 0 );

		// Skip-cache filter for third-party integrations.
		add_filter( 'blaminhor_essentials_cache_skip', array( $this, 'apply_default_skip_rules' ), 10, 2 );
	}

	/**
	 * Decide if the current request is cacheable. If yes, start the output
	 * buffer; otherwise, set the bypass reason for telemetry.
	 */
	public function maybe_start_buffering() {
		if ( ! $this->parent->get_setting( 'page_cache_enabled', true ) ) {
			$this->bypass_reason = 'disabled';
			return;
		}

		if ( $this->safe_mode->is_active() ) {
			$this->bypass_reason = 'safe_mode';
			return;
		}

		if ( $reason = $this->compute_bypass_reason() ) {
			$this->bypass_reason = $reason;
			$this->emit_debug_header( 'BYPASS:' . $reason );
			return;
		}

		// Allow third-parties to opt-out (e.g. if WC is in checkout).
		if ( apply_filters( 'blaminhor_essentials_cache_skip', false, $_SERVER ) ) {
			$this->bypass_reason = 'filter';
			$this->emit_debug_header( 'BYPASS:filter' );
			return;
		}

		ob_start( array( $this, 'on_output' ) );
		$this->buffering = true;
		$this->emit_debug_header( 'MISS' );
	}

	/**
	 * Output buffer callback. Returns the HTML untouched (we write on shutdown).
	 *
	 * @param string $html
	 * @return string
	 */
	public function on_output( $html ) {
		return $html;
	}

	/**
	 * On shutdown, decide whether to persist the captured HTML.
	 */
	public function on_shutdown() {
		if ( ! $this->buffering ) {
			return;
		}

		$status = (int) http_response_code();
		if ( ! $status ) {
			$status = 200;
		}

		// Permanent redirect → save a tiny .redirect file the drop-in can
		// serve straight away on the next request, no PHP boot needed.
		// Only 301 and 308 are cached: 302 and 307 are by definition
		// temporary, and 410 has no Location to follow.
		if ( in_array( $status, array( 301, 308 ), true ) ) {
			if ( ! is_user_logged_in() || $this->parent->get_setting( 'cache_logged_in', false ) ) {
				$location = '';
				foreach ( headers_list() as $h ) {
					if ( 0 === stripos( $h, 'Location:' ) ) {
						$location = trim( substr( $h, 9 ) );
						break;
					}
				}
				if ( '' !== $location ) {
					$this->write_redirect_file( $status, $location );
				}
			}
			return;
		}

		if ( 200 !== $status ) {
			// 404, 500, 410, 302, 307, etc. — never cached.
			return;
		}

		// Don't cache if WP encountered a fatal recoverable error (the page
		// rendered but the user signalled "do not cache").
		if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) {
			return;
		}

		$html = '';
		// Walk the OB stack from outermost to innermost and concatenate.
		while ( ob_get_level() > 0 ) {
			$html = ob_get_clean() . $html;
		}

		if ( ! is_string( $html ) || '' === $html ) {
			return;
		}

		// Allow optimizers (CSS/JS/Media/etc.) to transform the captured HTML
		// before it is served to the visitor and persisted to the cache file.
		// Filter chain (priority asc): css optimizer (10) → media (J5+) → final.
		$html = (string) apply_filters( 'blaminhor_essentials_cache_html_buffer', $html );

		// Apply HTML minifier last so both the current visitor and the cache
		// file get the same compact bytes.
		if ( $this->parent->get_setting( 'minify_html', true ) && class_exists( 'Blaminhor_Essentials_Cache_Html_Minifier' ) ) {
			$minified = Blaminhor_Essentials_Cache_Html_Minifier::minify( $html );
			if ( is_string( $minified ) && '' !== $minified ) {
				$html = $minified;
			}
		}

		// Echo back so the visitor sees the page.
		echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML is the rendered page itself.

		// Only cache full HTML pages (skip JSON / XML / RSS embedded responses).
		$head = ltrim( substr( $html, 0, 200 ) );
		if ( '' !== $head && 0 !== stripos( $head, '<!doctype' ) && 0 !== stripos( $head, '<html' ) ) {
			return;
		}

		// Only public, author-less posts/pages — skip preview / customizer.
		if ( is_user_logged_in() && ! $this->parent->get_setting( 'cache_logged_in', false ) ) {
			return;
		}

		$this->write_cache_file( $html );
	}

	/* ------------------------------------------------------------------ */
	/*  Bypass logic                                                      */
	/* ------------------------------------------------------------------ */

	/**
	 * Centralised bypass logic — used both at maybe_start_buffering AND by the
	 * filter callback. Returns empty string if cacheable, reason key otherwise.
	 *
	 * @return string Reason key or '' to cache.
	 */
	private function compute_bypass_reason() {
		// Method.
		$method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET';
		if ( 'GET' !== $method && 'HEAD' !== $method ) {
			return 'method';
		}

		// Standard WP contexts that must never be cached.
		if ( is_admin() || is_user_logged_in() && ! $this->parent->get_setting( 'cache_logged_in', false ) ) {
			return is_admin() ? 'admin' : 'logged_in';
		}
		if ( is_feed() ) {
			return 'feed';
		}
		if ( is_preview() ) {
			return 'preview';
		}
		if ( is_search() ) {
			return 'search';
		}
		if ( function_exists( 'is_customize_preview' ) && is_customize_preview() ) {
			return 'customizer';
		}
		if ( function_exists( 'is_404' ) && is_404() ) {
			return '404';
		}
		if ( function_exists( 'is_robots' ) && is_robots() ) {
			return 'robots';
		}
		if ( wp_doing_ajax() ) {
			return 'ajax';
		}
		if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
			return 'rest';
		}

		// Theme compatibility hard-bypass.
		// Avada / Divi compile their CSS through a pipeline that downgrades to a
		// minimal stylesheet bundle as soon as they detect an active output
		// buffer from a cache plugin. With our buffer running, the page baked
		// into the cache file ends up missing every parent stylesheet — the
		// HTML still has fusion-* classes but no CSS, so anonymous visitors see
		// a layout-less page. The defuser constant FUSION_DISABLE_COMPILERS
		// would force the full bundle but in turn breaks the JS that defines
		// `window.fusion`, so we cannot use it by default. The only safe path
		// is to skip the page cache entirely on these themes.
		if ( function_exists( 'wp_get_theme' ) ) {
			$current    = wp_get_theme();
			$template   = strtolower( (string) $current->get_template() );
			$stylesheet = strtolower( (string) $current->get_stylesheet() );

			$builtin_heavy = array();
			if ( 'avada' === $template || 'avada' === $stylesheet || false !== strpos( $stylesheet, 'avada' ) ) {
				$builtin_heavy[] = 'avada';
			}
			if ( 'divi' === $template || 'divi' === $stylesheet || false !== strpos( $stylesheet, 'divi' ) ) {
				$builtin_heavy[] = 'divi';
			}

			// User opt-out: a developer can disable the auto-bypass per theme
			// from the Cache settings (`theme_compat_disabled` array).
			$opted_out = (array) $this->parent->get_setting( 'theme_compat_disabled', array() );
			foreach ( $builtin_heavy as $theme_id ) {
				if ( in_array( $theme_id, $opted_out, true ) ) {
					continue;
				}
				return 'theme:' . sanitize_key( $theme_id );
			}

			// Site-specific overrides — append a theme template name to also
			// hard-bypass on it. Empty by default.
			$blocklist = (array) apply_filters(
				'blaminhor_essentials_cache_theme_blocklist',
				array()
			);
			foreach ( $blocklist as $name ) {
				$name = (string) $name;
				if ( '' === $name ) {
					continue;
				}
				if ( $name === $template || $name === $stylesheet ) {
					return 'theme:' . sanitize_key( $template );
				}
			}
		}

		// Tracking-only query strings are stripped by the drop-in; here, ANY
		// non-tracking query string also bypasses (we don't cache the result
		// to avoid serving the same HTML to ?utm_… and ?p=123).
		$qs = isset( $_SERVER['QUERY_STRING'] ) ? sanitize_text_field( wp_unslash( $_SERVER['QUERY_STRING'] ) ) : '';
		if ( '' !== $qs ) {
			parse_str( $qs, $pairs );
			foreach ( array_keys( (array) $pairs ) as $k ) {
				if ( 'gclid' === $k || 'fbclid' === $k || 0 === strpos( $k, 'utm_' ) ) {
					continue;
				}
				return 'qs:' . sanitize_key( $k );
			}
		}

		// User-defined URL exclusions.
		$exclude_urls = (string) $this->parent->get_setting( 'exclude_urls', '' );
		if ( '' !== $exclude_urls ) {
			$req_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
			foreach ( $this->lines( $exclude_urls ) as $pattern ) {
				if ( $this->url_matches( $req_uri, $pattern ) ) {
					return 'exclude_url';
				}
			}
		}

		// Cookie exclusions (custom + always-on).
		if ( ! empty( $_COOKIE ) ) {
			$always_on = array(
				'wordpress_logged_in_', 'wp-postpass_', 'comment_author_',
				'woocommerce_items_in_cart', 'woocommerce_cart_hash', 'wp_woocommerce_session_',
				'edd_items_in_cart',
			);
			$custom = $this->lines( (string) $this->parent->get_setting( 'exclude_cookies', '' ) );
			$haystack = array_merge( $always_on, $custom );
			foreach ( array_keys( $_COOKIE ) as $cookie ) {
				foreach ( $haystack as $needle ) {
					if ( '' !== $needle && false !== strpos( $cookie, $needle ) ) {
						return 'cookie:' . sanitize_key( $needle );
					}
				}
			}
		}

		// User-Agent exclusions.
		$ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
		if ( '' !== $ua ) {
			$ua_low = strtolower( $ua );
			$custom = $this->lines( (string) $this->parent->get_setting( 'exclude_user_agents', '' ) );
			foreach ( $custom as $needle ) {
				if ( '' !== $needle && false !== strpos( $ua_low, strtolower( $needle ) ) ) {
					return 'ua';
				}
			}
		}

		// WC core endpoints (cart/checkout/my-account). Two layers:
		// 1) is_cart() / is_checkout() / is_account_page() — canonical, only
		//    works once WP has resolved the query. Fails if the URL has not
		//    been mapped to the WC page (custom permalinks, broken WC config,
		//    early hits before WC fully loads).
		// 2) URL-path match against the actual WC page permalinks pulled from
		//    options. Catches the same URL even when WC is in a degraded
		//    state. Belt-and-braces — both checks can return true on the
		//    same request without harm.
		if ( function_exists( 'is_cart' ) && function_exists( 'is_checkout' ) && function_exists( 'is_account_page' ) ) {
			if ( is_cart() || is_checkout() || is_account_page() ) {
				return 'wc_dynamic';
			}
		}
		if ( function_exists( 'wc_get_page_permalink' ) ) {
			$req_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
			$req_path = (string) wp_parse_url( $req_uri, PHP_URL_PATH );
			$req_path = '/' . trim( $req_path, '/' ) . '/';
			foreach ( array( 'cart', 'checkout', 'myaccount' ) as $wc_slug ) {
				$page_url = @wc_get_page_permalink( $wc_slug );
				if ( ! $page_url ) {
					continue;
				}
				$page_path = (string) wp_parse_url( $page_url, PHP_URL_PATH );
				if ( '' === $page_path || '/' === $page_path ) {
					continue;
				}
				$page_path = '/' . trim( $page_path, '/' ) . '/';
				// Also bypass sub-paths (e.g. /my-account/orders/).
				if ( $req_path === $page_path || 0 === strpos( $req_path, $page_path ) ) {
					return 'wc_url:' . $wc_slug;
				}
			}
		}

		return '';
	}

	/**
	 * Default skip rules — applied via filter so third-parties can override.
	 *
	 * @param bool  $skip
	 * @param array $server $_SERVER snapshot.
	 * @return bool
	 */
	public function apply_default_skip_rules( $skip, $server ) {
		return (bool) $skip;
	}

	/**
	 * Whether a request URI matches a user-supplied pattern.
	 * Supports literal prefix and one wildcard `*`.
	 *
	 * @param string $req_uri
	 * @param string $pattern
	 * @return bool
	 */
	private function url_matches( $req_uri, $pattern ) {
		$pattern = trim( $pattern );
		if ( '' === $pattern || 0 === strpos( $pattern, '#' ) ) {
			return false;
		}
		// Wildcard-aware comparison.
		if ( false !== strpos( $pattern, '*' ) ) {
			$regex = '#^' . str_replace( '\*', '.*', preg_quote( $pattern, '#' ) ) . '$#i';
			return (bool) preg_match( $regex, $req_uri );
		}
		// Otherwise: prefix match.
		return 0 === strpos( $req_uri, $pattern );
	}

	/**
	 * Split a multi-line textarea into trimmed non-empty non-comment lines.
	 *
	 * @param string $raw
	 * @return string[]
	 */
	private function lines( $raw ) {
		$out = array();
		foreach ( preg_split( '/[\r\n]+/', (string) $raw ) as $line ) {
			$line = trim( $line );
			if ( '' === $line || 0 === strpos( $line, '#' ) ) {
				continue;
			}
			$out[] = $line;
		}
		return $out;
	}

	/* ------------------------------------------------------------------ */
	/*  Variant key                                                       */
	/* ------------------------------------------------------------------ */

	/**
	 * Variant key for the current request. Mirrors the drop-in's calculation.
	 *
	 * @return string
	 */
	public function variant_key() {
		$parts = array( 'desktop' );

		if ( $this->parent->get_setting( 'cache_mobile_separate', false ) && wp_is_mobile() ) {
			$parts[0] = 'mobile';
		}

		// Locale / currency cookies (WPML/PLL/WC).
		$variant_cookies = array();
		foreach ( array( '_icl_current_language', 'pll_language', 'wp-wpml_current_language', 'wcml_currency', 'currency' ) as $name ) {
			if ( isset( $_COOKIE[ $name ] ) ) {
				$variant_cookies[ $name ] = (string) $_COOKIE[ $name ];
			}
		}
		if ( ! empty( $variant_cookies ) ) {
			ksort( $variant_cookies );
			$parts[] = substr( md5( http_build_query( $variant_cookies ) ), 0, 8 );
		}

		return implode( '-', $parts );
	}

	/* ------------------------------------------------------------------ */
	/*  Cache file write                                                  */
	/* ------------------------------------------------------------------ */

	/**
	 * Persist a permanent redirect (301/308) so the drop-in can re-emit it
	 * directly, without booting PHP. The file format is:
	 *
	 *     <status>
	 *     <Location URL>
	 *
	 * One number, one URL, newline-separated. Atomic write — same pattern as
	 * the HTML cache file.
	 *
	 * @param int    $status
	 * @param string $location
	 */
	private function write_redirect_file( $status, $location ) {
		$scheme = is_ssl() ? 'https' : 'http';
		$host   = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
		$req    = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
		$path   = wp_parse_url( $req, PHP_URL_PATH );
		if ( null === $path || '' === $path ) {
			$path = '/';
		}
		$url = $scheme . '://' . $host . $path;
		$dir = $this->storage->url_to_cache_dir( $url );

		if ( ! wp_mkdir_p( $dir ) ) {
			return;
		}
		$variant = $this->variant_key();
		$file    = $dir . $variant . '.redirect';
		$body    = (int) $status . "\n" . $location . "\n";
		$tmp     = $file . '.tmp-' . wp_generate_password( 6, false, false );
		if ( false === @file_put_contents( $tmp, $body, LOCK_EX ) ) {
			return;
		}
		if ( ! @rename( $tmp, $file ) ) {
			@unlink( $tmp );
			return;
		}
		if ( class_exists( 'Blaminhor_Essentials_Cache_Pages_Index' ) ) {
			Blaminhor_Essentials_Cache_Pages_Index::instance()->record_write( $url, $variant, strlen( $body ), 'redirect' );
		}
	}

	/**
	 * Persist the captured HTML to disk (atomic write + optional gzip).
	 *
	 * @param string $html
	 */
	private function write_cache_file( $html ) {
		// Compute target path from the actual request URI rather than home_url(),
		// so query-string-stripped variants land exactly where the drop-in
		// looks them up.
		$scheme = is_ssl() ? 'https' : 'http';
		$host   = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
		$req    = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
		$path   = wp_parse_url( $req, PHP_URL_PATH );
		if ( null === $path || '' === $path ) {
			$path = '/';
		}
		$url = $scheme . '://' . $host . $path;

		$dir = $this->storage->url_to_cache_dir( $url );

		if ( ! wp_mkdir_p( $dir ) ) {
			return;
		}

		$variant = $this->variant_key();
		$file    = $dir . $variant . '.html';

		// Atomic write.
		$tmp = $file . '.tmp-' . wp_generate_password( 6, false, false );
		if ( false === @file_put_contents( $tmp, $html, LOCK_EX ) ) {
			return;
		}
		if ( ! @rename( $tmp, $file ) ) {
			@unlink( $tmp );
			return;
		}
		@chmod( $file, ( defined( 'FS_CHMOD_FILE' ) ? FS_CHMOD_FILE : 0644 ) );

		if ( class_exists( 'Blaminhor_Essentials_Cache_Pages_Index' ) ) {
			Blaminhor_Essentials_Cache_Pages_Index::instance()->record_write( $url, $variant, strlen( $html ), 'html' );
		}

		// Pre-compress for the drop-in's gzip fast path.
		if ( function_exists( 'gzencode' ) ) {
			$gz = @gzencode( $html, 6 );
			if ( false !== $gz ) {
				$gz_tmp = $file . '.gz.tmp-' . wp_generate_password( 6, false, false );
				if ( false !== @file_put_contents( $gz_tmp, $gz, LOCK_EX ) ) {
					@rename( $gz_tmp, $file . '.gz' );
					@chmod( $file . '.gz', ( defined( 'FS_CHMOD_FILE' ) ? FS_CHMOD_FILE : 0644 ) );
				}
			}
		}
	}

	/**
	 * Emit a debug header (only when WP_DEBUG is on) so devs can see the cache
	 * decision in DevTools without parsing logs.
	 *
	 * @param string $value
	 */
	private function emit_debug_header( $value ) {
		if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
			return;
		}
		if ( ! headers_sent() ) {
			header( 'X-BE-Cache: ' . $value );
		}
	}
}
