<?php
/**
 * Cache module — Render Watchdog
 *
 * Self-checking guard that detects when the cache is serving a visually
 * broken page to anonymous visitors and rolls back the offending
 * optimisation automatically.
 *
 * Why this exists: combinations like critical CSS + RUCSS + a theme that
 * compiles its own CSS dynamically (Avada / Divi / Elementor pro) can
 * produce a cached page that is missing every stylesheet except a tiny
 * child theme — visually broken in a private-browsing window while the
 * admin (always cache-bypassed) sees the site fine. Without this watchdog
 * the owner only finds out from a customer complaint.
 *
 * Triggers:
 *   1. After every cache purge (`blaminhor_essentials_cache_purge_request`).
 *   2. After every save of the Cache module's settings.
 *   3. Daily WP-Cron as a safety net.
 *   4. Manual button in the Audit tab UI.
 *
 * Strategy:
 *   - Schedule a single deferred event ~30 s in the future so the current
 *     request completes and the cache has time to warm up.
 *   - Fetch home_url('/') anonymously (no cookies, no Authorization header)
 *     via wp_remote_get — this mirrors what a real visitor sees.
 *   - Run a series of HTML checks. Each returns either '' (ok) or a short
 *     flag string identifying the failure mode.
 *   - If any flag is set, walk a hierarchy of optimisations and disable
 *     them one-by-one (most invasive first), re-fetching after each toggle
 *     until the page comes back. Persist the final state + the list of
 *     disabled optimisations and surface a prominent admin notice so the
 *     owner knows.
 *
 * @package BlaminhorEssentials
 */

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

class Blaminhor_Essentials_Cache_Render_Watchdog {

	const OPTION_LOG               = 'blaminhor_essentials_cache_watchdog_log';
	const OPTION_AUTO_DISABLED     = 'blaminhor_essentials_cache_watchdog_auto_disabled';
	const CRON_HOOK                = 'blaminhor_essentials_cache_watchdog_run';
	const CRON_DAILY_HOOK          = 'blaminhor_essentials_cache_watchdog_daily';
	const TRANSIENT_CHECK_PENDING  = 'blaminhor_essentials_cache_watchdog_pending';

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

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

	public function register_hooks() {
		// Self-trigger on cache-changing events. Priority 99 so we run AFTER
		// the cache has actually been purged (purge_all is hooked at default
		// priority 10 on the same intent action).
		add_action( 'blaminhor_essentials_cache_purge_request', array( $this, 'schedule_check' ), 99 );
		// purged_all fires AFTER purge_all() actually wiped the cache files —
		// covers both the AJAX "Purge now" button and the auto-purge hooks
		// (save_post, comment, etc.) which never go through purge_request.
		add_action( 'blaminhor_essentials_cache_purged_all', array( $this, 'schedule_check' ) );
		// Settings save — any module setting change is potentially layout-
		// affecting (toggle critical CSS, RUCSS, delay JS, …).
		add_action( 'update_option_blaminhor_essentials_module_cache', array( $this, 'schedule_check' ), 10, 0 );

		// The deferred check itself.
		add_action( self::CRON_HOOK, array( $this, 'run_check' ) );

		// Daily safety net so config drift (theme update, manual file delete…)
		// surfaces within 24 h even if the admin never touches the settings.
		add_action( self::CRON_DAILY_HOOK, array( $this, 'run_check' ) );
		if ( ! wp_next_scheduled( self::CRON_DAILY_HOOK ) ) {
			wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', self::CRON_DAILY_HOOK );
		}

		// Surface the result in admin.
		add_action( 'admin_notices', array( $this, 'maybe_render_admin_notice' ) );

		// AJAX endpoints for manual run + dismiss the notice.
		add_action( 'wp_ajax_ap_cache_watchdog_run', array( $this, 'ajax_run' ) );
		add_action( 'wp_ajax_ap_cache_watchdog_dismiss', array( $this, 'ajax_dismiss' ) );
	}

	/**
	 * Coalesce purge bursts: if many purges arrive in quick succession we
	 * still only schedule one check 30 s later.
	 */
	public function schedule_check() {
		if ( get_transient( self::TRANSIENT_CHECK_PENDING ) ) {
			return;
		}
		set_transient( self::TRANSIENT_CHECK_PENDING, 1, MINUTE_IN_SECONDS );
		if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
			wp_schedule_single_event( time() + 30, self::CRON_HOOK );
		}
	}

	/**
	 * The actual deferred check. Fetches the home page anonymously, scores
	 * the HTML, and rolls back optimisations if needed.
	 */
	public function run_check() {
		delete_transient( self::TRANSIENT_CHECK_PENDING );

		$url    = $this->probe_url();
		$report = $this->probe( $url );

		// Always log the probe — even successes — so the user can see the
		// watchdog is active and the page is healthy.
		$this->append_log( array_merge( array( 'event' => 'scheduled_run' ), $report ) );

		if ( ! empty( $report['flags'] ) ) {
			$this->attempt_rollback( $url, $report );
		} else {
			// Auto-clear the "auto-disabled" notice if the page is healthy
			// again — most likely because the user manually fixed the
			// underlying cause (e.g. regenerated Avada CSS cache).
			$auto = (array) get_option( self::OPTION_AUTO_DISABLED, array() );
			if ( ! empty( $auto ) && empty( $auto['unrecovered'] ) ) {
				// Keep recent breakage records for transparency, but mark
				// resolved so the admin notice goes away.
				$auto['resolved_ts'] = time();
				update_option( self::OPTION_AUTO_DISABLED, $auto, false );
			}
		}
	}

	/**
	 * The URL the watchdog probes. Allows site owners to point it at a
	 * specific high-traffic page rather than the home page if needed.
	 *
	 * @return string
	 */
	private function probe_url() {
		return (string) apply_filters( 'blaminhor_essentials_cache_watchdog_url', home_url( '/' ) );
	}

	/**
	 * Single anonymous probe.
	 *
	 * @param string $url
	 * @return array{ok:bool,flags:array,metrics:array,url:string,ts:int}
	 */
	public function probe( $url ) {
		$report = array(
			'url'     => $url,
			'ts'      => time(),
			'ok'      => true,
			'flags'   => array(),
			'metrics' => array(),
		);

		$response = wp_remote_get(
			$url,
			array(
				'timeout'     => 15,
				'redirection' => 3,
				'cookies'     => array(),
				'headers'     => array(
					'User-Agent'    => 'Mozilla/5.0 (compatible; BE-Render-Watchdog/1.0; +https://wordpress.org/plugins/blaminhor-essentials)',
					'Cache-Control' => 'no-cache',
				),
				'sslverify'   => apply_filters( 'blaminhor_essentials_cache_watchdog_sslverify', true ),
			)
		);
		if ( is_wp_error( $response ) ) {
			$report['ok']      = false;
			$report['flags'][] = 'fetch_error:' . $response->get_error_code();
			return $report;
		}
		$code = (int) wp_remote_retrieve_response_code( $response );
		if ( 200 !== $code ) {
			$report['ok']      = false;
			$report['flags'][] = 'http_' . $code;
			return $report;
		}

		$body = (string) wp_remote_retrieve_body( $response );
		$report['metrics']['bytes'] = strlen( $body );

		if ( '' === $body ) {
			$report['ok']      = false;
			$report['flags'][] = 'empty_body';
			return $report;
		}

		// -- Test 1: critical inline present without complement --
		$has_critical        = false !== stripos( $body, 'id="be-critical-css"' )
								|| false !== stripos( $body, "id='be-critical-css'" );
		$stylesheet_count    = preg_match_all( '#<link[^>]*\srel\s*=\s*["\']?stylesheet#i', $body );
		$preload_style_count = preg_match_all( '#<link[^>]*\srel\s*=\s*["\']?preload["\']?[^>]*\sas\s*=\s*["\']?style#i', $body );

		$report['metrics']['critical_inline']     = (int) $has_critical;
		$report['metrics']['stylesheet_count']    = (int) $stylesheet_count;
		$report['metrics']['preload_style_count'] = (int) $preload_style_count;

		// 2 stylesheets is the floor for any production theme (parent + child).
		// If we're below that AND we have no async preloads, the page is
		// almost certainly under-styled.
		if ( $has_critical && $stylesheet_count < 2 && 0 === $preload_style_count ) {
			$report['ok']      = false;
			$report['flags'][] = 'critical_without_complement';
		}

		// -- Test 2: absolutely no styling at all --
		if ( 0 === $stylesheet_count && 0 === $preload_style_count && ! $has_critical ) {
			// Either we hit a content-less endpoint by mistake, or no theme
			// CSS is loading. Either way, suspicious.
			$inline_style_blocks = preg_match_all( '#<style\b#i', $body );
			$report['metrics']['inline_style_blocks'] = (int) $inline_style_blocks;
			if ( $inline_style_blocks < 1 ) {
				$report['ok']      = false;
				$report['flags'][] = 'no_styles_at_all';
			}
		}

		// -- Test 3: body content size --
		if ( preg_match( '#<body[^>]*>(.*?)</body>#is', $body, $m ) ) {
			$body_len = strlen( $m[1] );
			$report['metrics']['body_bytes'] = $body_len;
			if ( $body_len < 500 ) {
				$report['ok']      = false;
				$report['flags'][] = 'body_too_small';
			}
		}

		// -- Test 4: theme-specific tells. The Avada body class is unmistakable.
		if ( false !== stripos( $body, 'wp-theme-Avada' ) || false !== stripos( $body, 'fusion-body' ) ) {
			// Avada in use. Try the explicit Fusion CSS handles first.
			$has_fusion_css = false !== stripos( $body, 'fusion-stylesheet' )
							|| false !== stripos( $body, 'awb-cb-css' )
							|| false !== stripos( $body, 'awb-stylesheet' )
							|| false !== stripos( $body, 'avada-stylesheet' )
							|| false !== stripos( $body, 'fusion-dynamic-css' )
							|| false !== stripos( $body, 'fusion-styles/' );

			// Avada Child Theme builds often compile every parent rule into
			// the child stylesheet and dynamic <style> blocks rather than
			// shipping a separate fusion-* URL. In that case the markup-only
			// signal is a child stylesheet pointing at a directory that
			// includes "Avada" — combined with at least one stylesheet URL
			// AND substantive body content the layout is intact even though
			// no fusion-* handle is present. Test 5 (CSS URLs reachable)
			// catches the truly broken case anyway.
			if ( ! $has_fusion_css && false !== stripos( $body, 'avada-child' ) ) {
				$has_fusion_css = ( $stylesheet_count >= 1 || $preload_style_count >= 1 );
			}

			if ( ! $has_fusion_css ) {
				$report['ok']      = false;
				$report['flags'][] = 'avada_main_css_missing';
			}
		}

		// -- Test 4b: Divi tells. et-* handles or et_pb_* shortcode rendered
		// without a corresponding stylesheet means Divi got starved by RUCSS.
		if ( false !== stripos( $body, 'class="et_pb_' ) || false !== stripos( $body, 'et_pb_section' ) ) {
			$has_divi_css = false !== stripos( $body, 'divi-style' )
						 || false !== stripos( $body, 'et-builder-' )
						 || false !== stripos( $body, 'et-core-unified' )
						 || false !== stripos( $body, 'et-divi-' )
						 || false !== stripos( $body, '/Divi/style' );
			if ( ! $has_divi_css ) {
				$report['ok']      = false;
				$report['flags'][] = 'divi_main_css_missing';
			}
		}

		// -- Test 5: are referenced stylesheet/preload URLs actually reachable?
		// We sample up to 8 CSS URLs (round-robin between <link rel="stylesheet">
		// and preload-as-style) and HEAD each. If any returns >= 400, the page
		// is rendered without that CSS for real visitors — invisible to the
		// markup-only checks above. Sample-only to keep the probe under a few
		// extra seconds even on heavy themes; a single broken CSS is enough
		// to flag.
		if ( preg_match_all( '#<link[^>]*href=["\']([^"\']+\.css[^"\']*)["\']#i', $body, $css_urls ) ) {
			$urls   = array_unique( $css_urls[1] );
			$sample = array_slice( $urls, 0, 8 );
			$broken = array();
			foreach ( $sample as $url ) {
				// Resolve protocol-relative URLs against the current site.
				if ( 0 === strpos( $url, '//' ) ) {
					$url = 'https:' . $url;
				}
				$head = wp_remote_head( $url, array(
					'timeout'   => 4,
					'sslverify' => apply_filters( 'blaminhor_essentials_cache_watchdog_sslverify', true ),
					'cookies'   => array(),
				) );
				if ( is_wp_error( $head ) ) {
					continue; // network glitch — don't fail on transient errors.
				}
				$code = (int) wp_remote_retrieve_response_code( $head );
				if ( $code >= 400 ) {
					$broken[] = array( 'url' => $url, 'status' => $code );
				}
			}
			$report['metrics']['css_sample_size']   = count( $sample );
			$report['metrics']['css_sample_broken'] = count( $broken );
			if ( ! empty( $broken ) ) {
				$report['ok']           = false;
				$report['flags'][]      = 'css_files_unreachable';
				$report['broken_css'] = array_slice( $broken, 0, 5 );
			}
		}

		return $report;
	}

	/**
	 * Walk the optimisation hierarchy disabling items until the probe comes
	 * back OK. Returns nothing — state is persisted via update_option.
	 *
	 * Order matters: we disable the most-invasive optimisations first
	 * (those that REMOVE markup from the HTML), since they are the most
	 * likely culprits for a "broken layout" symptom. Read-only optimisers
	 * (like minify_html) are tried last.
	 *
	 * @param string $url
	 * @param array  $initial_report
	 * @return void
	 */
	private function attempt_rollback( $url, $initial_report ) {
		// Every optimisation that mutates the rendered HTML is in scope. Order
		// goes from "rewrites or removes markup wholesale" (most likely culprit
		// on a broken-layout symptom) down to "leaves the DOM intact, only
		// changes attributes/timing". A single optimisation outside this list
		// is enough to walk the whole hierarchy without recovering — exactly
		// the failure the production logs surfaced on Avada (1.9.8.13).
		$hierarchy = array(
			// URL rewriter — a misconfigured CDN turns every CSS/JS into a 4xx.
			'cdn_enabled',
			// Removes <link rel="stylesheet"> entirely (replaces with preload swap).
			'critical_async_loading',
			// Inlines a separate <style id="be-critical-css">.
			'critical_css_enabled',
			// RUCSS strips unused rules — over-strips on dynamic-class sites.
			'rucss_enabled',
			// Rewrites Google Fonts <link> to a self-hosted bundle; a build
			// failure or path mismatch leaves the page unstyled.
			'local_google_fonts_enabled',
			// Adds dns-prefetch / preconnect hints; conflicts with hints already
			// emitted by themes like Avada/Divi can break inline-CSS resolution.
			'auto_resource_hints_enabled',
			// HTML minifier — usually safe, but Fusion's inline JSON payloads
			// can trip a naïve minifier and produce malformed JS.
			'minify_html',
			// CSS minifier — concatenates and rewrites @media; aggressive rules
			// can drop selectors a builder relied on.
			'minify_css',
			// Auto-injects width/height on every <img>; if a theme relies on
			// CSS-driven aspect ratios this clamps images to native size.
			'image_dimensions_enabled',
			// Lazy-loading layer (iframes, YouTube facade, images).
			'lazy_load_iframes',
			'youtube_facade',
			'lazy_load_images',
			// JS timing — least likely to cause CSS-layout breakage, but a
			// script error in a render-time inline script could.
			'delay_js',
			'defer_js',
		);

		$disabled = array();
		foreach ( $hierarchy as $key ) {
			if ( ! $this->parent->get_setting( $key, false ) ) {
				continue;
			}
			$this->set_setting( $key, false );
			$this->purge_pages();
			// Give the server a moment so the next anonymous probe re-bakes
			// a fresh cache file rather than serving the stale one.
			sleep( 2 );
			$report     = $this->probe( $url );
			$disabled[] = $key;
			if ( $report['ok'] ) {
				update_option(
					self::OPTION_AUTO_DISABLED,
					array(
						'ts'       => time(),
						'disabled' => $disabled,
						'flags'    => $initial_report['flags'],
						'metrics'  => $initial_report['metrics'],
						'url'      => $url,
					),
					false
				);
				$this->append_log(
					array(
						'event'    => 'auto_rollback_recovered',
						'disabled' => $disabled,
						'flags'    => $initial_report['flags'],
					)
				);
				blaminhor_essentials_log(
					sprintf(
						'Render Watchdog auto-disabled %s after detecting %s',
						implode( ', ', $disabled ),
						implode( ', ', $initial_report['flags'] )
					),
					'warning',
					'cache'
				);
				return;
			}
		}

		// Walked the entire hierarchy and the page is still broken. Don't
		// re-enable everything (the user needs to investigate manually) —
		// but record the unrecovered state so the admin notice surfaces.
		update_option(
			self::OPTION_AUTO_DISABLED,
			array(
				'ts'          => time(),
				'disabled'    => $disabled,
				'flags'       => $initial_report['flags'],
				'metrics'     => $initial_report['metrics'],
				'url'         => $url,
				'unrecovered' => true,
			),
			false
		);
		$this->append_log(
			array(
				'event'       => 'auto_rollback_unrecovered',
				'disabled'    => $disabled,
				'flags'       => $initial_report['flags'],
			)
		);
		blaminhor_essentials_log(
			sprintf(
				'Render Watchdog walked the full hierarchy (%s) and could not restore the page — manual investigation needed',
				implode( ', ', $disabled )
			),
			'error',
			'cache'
		);
	}

	/**
	 * Persist a single setting change. We hit update_option directly because
	 * load_settings/save_settings on the abstract Module base are protected
	 * (they're meant to be called from inside the module class itself), so a
	 * helper class like this one cannot reach them. Reading via get_setting
	 * (which IS public) for consistency, then writing back the full array.
	 */
	private function set_setting( $key, $value ) {
		$option_key = 'blaminhor_essentials_module_' . $this->parent->get_id();
		$settings   = (array) get_option( $option_key, array() );
		$settings[ $key ] = $value;
		update_option( $option_key, $settings, false );
	}

	private function purge_pages() {
		if ( isset( $this->parent->purge ) && method_exists( $this->parent->purge, 'purge_all' ) ) {
			$this->parent->purge->purge_all();
		}
	}

	private function append_log( $entry ) {
		$log   = (array) get_option( self::OPTION_LOG, array() );
		$log[] = array_merge( array( 'ts' => time() ), (array) $entry );
		// Cap at 30 entries so the option stays small even on busy sites.
		if ( count( $log ) > 30 ) {
			$log = array_slice( $log, -30 );
		}
		update_option( self::OPTION_LOG, $log, false );
	}

	/**
	 * The persistent admin notice that surfaces when an auto-rollback has
	 * occurred. Only shown to admins, dismissible per-user, auto-clears
	 * once the page is healthy again on a subsequent probe.
	 */
	public function maybe_render_admin_notice() {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}
		$auto = (array) get_option( self::OPTION_AUTO_DISABLED, array() );
		if ( empty( $auto ) || empty( $auto['disabled'] ) ) {
			return;
		}
		// Auto-cleared after a healthy probe — hide the notice.
		if ( ! empty( $auto['resolved_ts'] ) ) {
			return;
		}
		$dismissed_ts = (int) get_user_meta( get_current_user_id(), 'blaminhor_essentials_cache_watchdog_dismissed_ts', true );
		if ( $dismissed_ts >= (int) $auto['ts'] ) {
			return;
		}

		$disabled_list = '<code>' . esc_html( implode( ', ', (array) $auto['disabled'] ) ) . '</code>';
		$flags_list    = '<code>' . esc_html( implode( ', ', (array) $auto['flags'] ) ) . '</code>';
		$url_disp      = '<code>' . esc_html( $auto['url'] ) . '</code>';

		$is_unrecovered = ! empty( $auto['unrecovered'] );
		$class_suffix   = $is_unrecovered ? 'notice-error' : 'notice-warning';

		?>
		<div class="notice <?php echo esc_attr( $class_suffix ); ?> is-dismissible blaminhor-essentials-cache-watchdog-notice">
			<h3 style="margin:0.5em 0;">
				<?php esc_html_e( 'Cache & Performance — render watchdog protected your site', 'blaminhor-essentials' ); ?>
			</h3>
			<p>
				<?php
				if ( $is_unrecovered ) {
					printf(
						/* translators: 1: URL probed, 2: comma-separated flags, 3: comma-separated settings disabled */
						wp_kses(
							__( 'The cached version of %1$s appears visually broken (signals: %2$s). The plugin disabled %3$s to try to recover, but the page is still not healthy. Please review the Cache & Performance settings and your theme/builder CSS cache.', 'blaminhor-essentials' ),
							array( 'code' => array() )
						),
						$url_disp, // already escaped above (esc_html → wrapped in <code>)
						$flags_list,
						$disabled_list
					);
				} else {
					printf(
						/* translators: 1: URL probed, 2: comma-separated flags, 3: comma-separated settings disabled */
						wp_kses(
							__( 'The cached version of %1$s was visually broken to anonymous visitors (signals: %2$s). The plugin auto-disabled %3$s and the page is healthy again. Open the Cache settings to review what changed.', 'blaminhor-essentials' ),
							array( 'code' => array() )
						),
						$url_disp,
						$flags_list,
						$disabled_list
					);
				}
				?>
			</p>
			<p>
				<a class="button button-primary" href="<?php echo esc_url( admin_url( 'admin.php?page=blaminhor-essentials-cache#audit' ) ); ?>">
					<?php esc_html_e( 'Open Cache settings', 'blaminhor-essentials' ); ?>
				</a>
			</p>
		</div>
		<?php
	}

	/**
	 * Manual trigger from the Audit tab — runs the probe immediately and
	 * returns the report.
	 */
	/**
	 * Inline auth check — same surface as the abstract Module's
	 * verify_ajax_request() but callable from a non-Module helper class.
	 */
	private function authenticate_ajax() {
		check_ajax_referer( 'blaminhor_essentials_admin', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			blaminhor_essentials_ajax_error( __( 'Insufficient permissions.', 'blaminhor-essentials' ) );
		}
	}

	public function ajax_run() {
		$this->authenticate_ajax();
		$url    = $this->probe_url();
		$report = $this->probe( $url );
		$this->append_log( array_merge( array( 'event' => 'manual_run' ), $report ) );
		if ( ! $report['ok'] && ! empty( $report['flags'] ) ) {
			$this->attempt_rollback( $url, $report );
			// Re-read the persisted result so the UI shows the rollback decision.
			$report['rollback'] = (array) get_option( self::OPTION_AUTO_DISABLED, array() );
		}
		blaminhor_essentials_ajax_success( __( 'Render watchdog complete.', 'blaminhor-essentials' ), $report );
	}

	public function ajax_dismiss() {
		$this->authenticate_ajax();
		update_user_meta( get_current_user_id(), 'blaminhor_essentials_cache_watchdog_dismissed_ts', time() );
		blaminhor_essentials_ajax_success();
	}

	/**
	 * Read accessor used by the Audit tab UI to render the most recent
	 * watchdog log entries (a small in-tab dashboard).
	 *
	 * @param int $limit
	 * @return array
	 */
	public function get_recent_log( $limit = 10 ) {
		$log = (array) get_option( self::OPTION_LOG, array() );
		$log = array_slice( $log, -$limit );
		return array_reverse( $log );
	}

	/**
	 * Cleanup on plugin/module deactivation: cancel scheduled events.
	 */
	public function on_deactivate() {
		wp_clear_scheduled_hook( self::CRON_HOOK );
		wp_clear_scheduled_hook( self::CRON_DAILY_HOOK );
		delete_transient( self::TRANSIENT_CHECK_PENDING );
	}
}
