<?php
/**
 * BaseCloud Boost — Cache Engine (Orchestrator)
 *
 * Central caching orchestrator. Registers WordPress hooks that trigger
 * automatic, targeted cache purging on content changes.
 *
 * Hook API (for developers extending BaseCloud Boost):
 *
 *   Filters:
 *     BCBOOST_cache_reject_uri          — add URI patterns that bypass cache
 *     BCBOOST_cache_reject_cookies      — add cookie names that bypass cache
 *     BCBOOST_cache_dynamic_cookies     — cookies that create cache variants
 *     BCBOOST_cache_ignored_parameters  — query params stripped before cache key
 *     BCBOOST_do_generate_cache         — prevent cache write for current request
 *     BCBOOST_buffer                    — modify HTML before it is cached
 *     BCBOOST_htaccess_rules            — modify generated .htaccess content
 *
 *   Actions:
 *     BCBOOST_before_write_cache($html, $file)
 *     BCBOOST_after_write_cache($file)
 *     BCBOOST_before_purge_url($url)
 *     BCBOOST_after_purge_url($url)
 *     BCBOOST_after_purge_all()
 *     BCBOOST_after_purge_post($post_id, $purged_urls)
 *
 * @package BaseCloud\Boost\Core
 */

declare( strict_types=1 );

namespace BaseCloud\Boost\Core;

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

/**
 * Registers WordPress hooks for automatic cache invalidation.
 */
class Cache_Engine {

	/** @var Page_Cache */
	private Page_Cache $page_cache;

	/** @var Cache_Purge */
	private Cache_Purge $purge;

	/** @var Metrics_Collector */
	private Metrics_Collector $metrics;

	/** @var int Output buffer level before our ob_start call. */
	private int $ob_level = 0;

	/**
	 * Constructor.
	 */
	public function __construct() {
		$this->page_cache = new Page_Cache();
		$this->purge      = new Cache_Purge();
		$this->metrics    = new Metrics_Collector();
	}

	/**
	 * Register all WordPress lifecycle hooks.
	 *
	 * @return void
	 */
	public function boot(): void {
		// ── Output buffer: capture and write page cache ───────────────────
		// Priority 0 = outermost buffer so ALL other optimizations (lazy load,
		// minification) run BEFORE the cache write — we cache the final output.
		add_action( 'template_redirect', [ $this, 'maybe_start_cache_buffer' ], 0 );

		// ── Auto-purge on content mutations ───────────────────────────────
		add_action( 'save_post',                  [ $this, 'on_save_post' ],       10, 2 );
		add_action( 'deleted_post',               [ $this, 'on_save_post' ],       10, 2 );
		add_action( 'comment_post',               [ $this, 'on_comment' ],         10, 2 );
		add_action( 'edit_comment',               [ $this, 'on_comment_edit' ] );
		add_action( 'delete_comment',             [ $this, 'on_comment_edit' ] );
		add_action( 'transition_comment_status',  [ $this, 'on_comment_status' ],  10, 3 );

		// ── Site-wide changes (purge all) ──────────────────────────────────
		add_action( 'wp_update_nav_menu',            [ $this, 'on_major_change' ] );
		add_action( 'switch_theme',                  [ $this, 'on_major_change' ] );
		add_action( 'upgrader_process_complete',     [ $this, 'on_major_change' ] );
		add_action( 'customize_save_after',          [ $this, 'on_major_change' ] );
		add_action( 'activated_plugin',              [ $this, 'on_major_change' ] );
		add_action( 'deactivated_plugin',            [ $this, 'on_major_change' ] );
		add_action( 'update_option_blogname',        [ $this, 'on_major_change' ] );
		add_action( 'update_option_blogdescription', [ $this, 'on_major_change' ] );

		// ── Taxonomy changes ───────────────────────────────────────────────
		add_action( 'created_term', [ $this, 'on_term_change' ], 10, 3 );
		add_action( 'edited_term',  [ $this, 'on_term_change' ], 10, 3 );
		add_action( 'delete_term',  [ $this, 'on_term_change' ], 10, 3 );

		// ── REST API purge endpoint ────────────────────────────────────────
		add_action( 'rest_api_init', [ $this, 'register_rest_routes' ] );

		// ── Admin bar "Clear Cache" button ─────────────────────────────────
		add_action( 'admin_bar_menu',         [ $this, 'add_admin_bar_button' ], 100 );
		add_action( 'wp_ajax_BCBOOST_clear_cache', [ $this, 'ajax_clear_cache' ] );

		// Front-end + admin script powering the in-place AJAX "Boost Cache" button.
		add_action( 'wp_enqueue_scripts',    [ $this, 'enqueue_admin_bar_assets' ] );
		add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_bar_assets' ] );

		// ── Scheduled expired-cache cleanup ───────────────────────────────
		add_action( 'BCBOOST_expire_cache', [ $this->page_cache, 'delete_expired_cache' ] );
		if ( ! wp_next_scheduled( 'BCBOOST_expire_cache' ) ) {
			wp_schedule_event( time(), 'hourly', 'BCBOOST_expire_cache' );
		}

		// ── Scheduled minify cache cleanup (weekly stale file purge) ──────
		add_action( 'BCBOOST_minify_cache_cleanup', [ $this, 'cleanup_minify_cache' ] );
		if ( ! wp_next_scheduled( 'BCBOOST_minify_cache_cleanup' ) ) {
			wp_schedule_event( time(), 'weekly', 'BCBOOST_minify_cache_cleanup' );
		}

		// ── Scheduled automatic database cleanup ──────────────────────────
		add_action( 'BCBOOST_db_cleanup', static function (): void {
			( new \BaseCloud\Boost\Database\DB_Optimizer() )->scheduled_cleanup();
		} );

		// ── Scheduled daily webhook send ───────────────────────────────────
		add_action( 'BCBOOST_webhook_daily', [ $this, 'send_webhook_scheduled' ] );
		if ( ! wp_next_scheduled( 'BCBOOST_webhook_daily' ) ) {
			wp_schedule_event( time(), 'daily', 'BCBOOST_webhook_daily' );
		}

		// ── Refresh options JSON cache when settings are updated in DB ─────
		add_action( 'update_option_' . BCBOOST_OPTIONS_KEY, [ $this, 'refresh_options_cache' ], 10, 2 );
	}

	/**
	 * Start the page-cache output buffer if this request is cacheable.
	 *
	 * Called at template_redirect priority 0 (outermost buffer layer).
	 * Inner buffers (HTML minifier priority 0+, Lazy_Loader priority 99)
	 * run first, so the cache stores the fully-optimised HTML.
	 *
	 * @return void
	 */
	public function maybe_start_cache_buffer(): void {
		if ( ! BCBOOST_get_option( 'cache_enabled', true ) ) {
			return;
		}

		// Host_Detector disables our file page cache on hosts that run their own
		// (WP Engine, Kinsta, Pagely, Pantheon, WP.com VIP) so the two don't
		// double-cache. This filter was previously documented but never consumed.
		if ( ! apply_filters( 'BCBOOST_page_cache_enabled', true ) ) {
			return;
		}

		// Builder editors/previews (Divi ?et_fb=1, Elementor, Customizer, post
		// previews) must never be captured or cached. Central gate — see
		// BCBOOST_is_optimizable_request() in basecloud-boost.php.
		if ( ! BCBOOST_is_optimizable_request() ) {
			return;
		}

		// Never cache admin, feeds, search, 404, or logged-in users.
		if ( is_admin() || is_feed() || is_search() || is_404() || is_user_logged_in() ) {
			return;
		}

		// Bridge the resolved visitor country into a first-party cookie so the
		// pre-WordPress drop-in can serve the correct per-country variant next time.
		Cache_Context::maybe_set_country_cookie();

		// Country-variance is on but we cannot resolve the country: do NOT cache, or
		// we would risk freezing one country's content (e.g. USD pricing) for all.
		if ( Cache_Context::country_variance_unresolved() ) {
			header( 'X-BaseCloud-Boost: BYPASS-GEO' );
			return;
		}

		// Never cache POST / non-GET requests.
		if ( ( $_SERVER['REQUEST_METHOD'] ?? 'GET' ) !== 'GET' ) {
			return;
		}

		// Never cache pages with a non-tracking query string (unless option is on).
		// Tracking-only params (utm_*, gclid, etc.) do not alter page content — the cache
		// write path already strips query strings, so we can still populate the cache.
		if ( ! empty( $_SERVER['QUERY_STRING'] ) && ! BCBOOST_get_option( 'cache_query_strings', false ) ) {
			static $BCBOOST_tracking = [
				'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
				'gclid', 'gbraid', 'wbraid', 'fbclid', 'msclkid', 'mc_cid', 'mc_eid', 'referrer',
			];
			$raw_qs = sanitize_text_field( wp_unslash( $_SERVER['QUERY_STRING'] ) );
			parse_str( $raw_qs, $BCBOOST_qs );
			if ( ! empty( array_diff_key( $BCBOOST_qs, array_flip( $BCBOOST_tracking ) ) ) ) {
				return;
			}
		}

		// Never cache if a logged-in cookie is present (catches edge cases).
		foreach ( array_keys( $_COOKIE ) as $cookie_name ) {
			if ( strpos( $cookie_name, 'wordpress_logged_in' ) === 0 ) {
				return;
			}
		}

		// ── Developer / integration veto filters ──────────────────────────────
		// These were previously documented but never applied. They now drive the
		// write path, so WooCommerce_Compat (and any add-on) can actually keep a
		// page out of the cache instead of the exclusion silently doing nothing.

		// Cookie-based rejection (e.g. WooCommerce session/cart cookies).
		$reject_cookies = (array) apply_filters( 'BCBOOST_cache_reject_cookies', [] );
		foreach ( array_keys( $_COOKIE ) as $cookie_name ) {
			foreach ( $reject_cookies as $pattern ) {
				if ( '' !== (string) $pattern && false !== strpos( (string) $cookie_name, (string) $pattern ) ) {
					return;
				}
			}
		}

		// URI-based rejection (e.g. cart / checkout / account paths).
		$request_uri = isset( $_SERVER['REQUEST_URI'] )
			? (string) wp_parse_url( (string) wp_unslash( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH )
			: '/';
		$reject_uris = (array) apply_filters( 'BCBOOST_cache_reject_uri', [] );
		foreach ( $reject_uris as $pattern ) {
			if ( '' !== (string) $pattern && false !== strpos( $request_uri, (string) $pattern ) ) {
				return;
			}
		}

		// Allow third-party plugins / WooCommerce to veto caching.
		if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) {
			return;
		}

		// Final programmatic veto for the current request.
		if ( ! apply_filters( 'BCBOOST_do_generate_cache', true ) ) {
			return;
		}

		$this->ob_level = ob_get_level();
		ob_start( [ $this, 'capture_and_cache' ] );
		add_action( 'shutdown', [ $this, 'end_output_buffer' ], PHP_INT_MAX );
	}

	/**
	 * Explicitly close our output buffer on shutdown.
	 *
	 * @return void
	 */
	public function end_output_buffer(): void {
		if ( ob_get_level() > $this->ob_level ) {
			ob_end_flush();
		}
	}

	/**
	 * Output-buffer callback: apply optimisation filters, write to cache,
	 * return the (already optimised) HTML to the browser.
	 *
	 * @param string $html Raw page output.
	 * @return string      HTML (optimised) passed to the browser.
	 */
	public function capture_and_cache( string $html ): string {
		// Only cache proper HTML documents.
		if ( strlen( $html ) < 255 ) {
			return $html;
		}
		if ( strpos( $html, '<html' ) === false && strpos( $html, '<!DOCTYPE' ) === false ) {
			return $html;
		}

		// Let other plugins modify the HTML before caching (runs minifiers etc.).
		$original_len = strlen( $html );
		$html         = apply_filters( 'BCBOOST_buffer', $html );

		// Re-check DONOTCACHEPAGE (can be set late by other plugins).
		if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) {
			return $html;
		}

		// A late veto or an unresolved country (set after template_redirect) must
		// still prevent a write — otherwise we could persist wrong-country content.
		if ( Cache_Context::country_variance_unresolved() || ! apply_filters( 'BCBOOST_do_generate_cache', true ) ) {
			return $html;
		}

		// Sanitize server variables before use.
		$host = isset( $_SERVER['HTTP_HOST'] )
			? preg_replace( '/[^a-zA-Z0-9.\-:]/', '', wp_unslash( $_SERVER['HTTP_HOST'] ) )
			: '';
		$uri  = isset( $_SERVER['REQUEST_URI'] )
			? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) )
			: '/';
		$url  = ( is_ssl() ? 'https' : 'http' ) . '://' . $host . $uri;

		// Store to the variant matching this request (desktop/mobile, country, cookie),
		// so geolocation/currency switching keeps working from cache.
		$stem = Cache_Context::file_stem();

		do_action( 'BCBOOST_before_write_cache', $html, $url );
		$written = $this->page_cache->write_to_cache( $url, $html, $stem );
		do_action( 'BCBOOST_after_write_cache', $url );

		if ( ! $written && ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) ) {
			error_log( sprintf( '[BaseCloud Boost] Page cache write FAILED for %s — check folder ownership/permissions on %s', $url, BCBOOST_CACHE_DIR ) );
		}

		// Track MISS + HTML bytes saved.
		$this->metrics->increment( 'misses' );
		$html_saved = $original_len - strlen( $html );
		if ( $html_saved > 0 ) {
			$this->metrics->increment( 'html_saved', $html_saved );
		}

		header( 'X-BaseCloud-Boost: MISS' ); // First uncached request.

		return $html;
	}

	/**
	 * Purge cache when a post is saved/updated/deleted, then optionally re-warm it.
	 *
	 * @param int      $post_id Post ID.
	 * @param \WP_Post $post    Post object.
	 * @return void
	 */
	public function on_save_post( int $post_id, \WP_Post $post ): void {
		if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
			return;
		}
		if ( ! in_array( $post->post_status, [ 'publish', 'trash', 'private' ], true ) ) {
			return;
		}

		// Cache_Purge::purge_post() re-warms the post URL + front page itself
		// (non-blocking, gated on cache_warm_on_purge) — no duplicate warm here.
		$this->purge->purge_post( $post_id );
	}

	/**
	 * Purge the commented post when a new approved comment is posted.
	 *
	 * @param int        $comment_id Comment ID.
	 * @param int|string $approved   Approval status.
	 * @return void
	 */
	public function on_comment( int $comment_id, $approved ): void {
		if ( $approved !== 1 ) {
			return;
		}
		$comment = get_comment( $comment_id );
		if ( $comment && $comment->comment_post_ID ) {
			$this->purge->purge_post( (int) $comment->comment_post_ID );
		}
	}

	/**
	 * Purge commented post when a comment is edited or deleted.
	 *
	 * @param int $comment_id Comment ID.
	 * @return void
	 */
	public function on_comment_edit( int $comment_id ): void {
		$comment = get_comment( $comment_id );
		if ( $comment && $comment->comment_post_ID ) {
			$this->purge->purge_post( (int) $comment->comment_post_ID );
		}
	}

	/**
	 * Handle comment status transitions.
	 *
	 * @param string      $new_status New status.
	 * @param string      $old_status Old status.
	 * @param \WP_Comment $comment    Comment object.
	 * @return void
	 */
	public function on_comment_status( string $new_status, string $old_status, \WP_Comment $comment ): void {
		if ( $new_status === $old_status ) {
			return;
		}
		if ( $comment->comment_post_ID ) {
			$this->purge->purge_post( (int) $comment->comment_post_ID );
		}
	}

	/**
	 * Purge ALL cache on major site changes.
	 *
	 * @return void
	 */
	public function on_major_change(): void {
		$this->metrics->set( 'last_purge', time() );
		$this->purge->purge_all();

		// Optionally fire webhook on purge.
		if ( BCBOOST_get_option( 'webhook_enabled', false ) && BCBOOST_get_option( 'webhook_on_purge', false ) ) {
			( new Webhook_Sender() )->send();
		}
	}

	/**
	 * Purge a taxonomy term archive and the homepage.
	 *
	 * @param int    $term_id  Term ID.
	 * @param int    $tt_id    Term-taxonomy ID.
	 * @param string $taxonomy Taxonomy slug.
	 * @return void
	 */
	public function on_term_change( int $term_id, int $tt_id, string $taxonomy ): void {
		$this->purge->purge_taxonomy_term( $term_id, $taxonomy );
	}

	/**
	 * Register REST API routes for remote cache purging.
	 *
	 * @return void
	 */
	public function register_rest_routes(): void {
		register_rest_route(
			'basecloud-boost/v1',
			'/purge',
			[
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => [ $this, 'rest_purge_callback' ],
				'permission_callback' => static fn (): bool => current_user_can( 'manage_options' ),
				'args' => [
					'url' => [
						'type'              => 'string',
						'sanitize_callback' => 'esc_url_raw',
					],
				],
			]
		);
	}

	/**
	 * Handle REST purge request.
	 *
	 * @param \WP_REST_Request $request REST request.
	 * @return \WP_REST_Response
	 */
	public function rest_purge_callback( \WP_REST_Request $request ): \WP_REST_Response {
		$url = $request->get_param( 'url' );

		if ( $url ) {
			$this->purge->purge_url( $url );
			return new \WP_REST_Response( [ 'success' => true, 'purged' => $url ], 200 );
		}

		$this->metrics->set( 'last_purge', time() );
		$this->purge->purge_all();
		return new \WP_REST_Response( [ 'success' => true, 'purged' => 'all' ], 200 );
	}

	/**
	 * Enqueue the lightweight script that turns the admin-bar "Boost Cache"
	 * button into an in-place AJAX clear (with animation) instead of a page load.
	 *
	 * @return void
	 */
	public function enqueue_admin_bar_assets(): void {
		if ( ! is_admin_bar_showing() || ! current_user_can( 'manage_options' ) ) {
			return;
		}

		wp_enqueue_script(
			'bcboost-boost-bar',
			BCBOOST_URL . 'admin/js/boost-bar.js',
			[],
			BCBOOST_VERSION,
			true
		);

		wp_localize_script( 'bcboost-boost-bar', 'bcbBoostBar', [
			'ajaxUrl' => admin_url( 'admin-ajax.php' ),
			'nonce'   => wp_create_nonce( 'BCBOOST_admin' ),
			'strings' => [
				'clearing' => __( 'Boosting…', 'basecloud-boost' ),
				'cleared'  => __( 'Cache boosted!', 'basecloud-boost' ),
				'error'    => __( 'Could not clear cache. Please try again.', 'basecloud-boost' ),
			],
		] );
	}

	/**
	 * Add "🚀 Boost Cache" node to the WordPress admin bar.
	 *
	 * @param \WP_Admin_Bar $wp_admin_bar Admin bar object.
	 * @return void
	 */
	public function add_admin_bar_button( \WP_Admin_Bar $wp_admin_bar ): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}

		$wp_admin_bar->add_node( [
			'id'    => 'bcboost-clear-cache',
			'title' => '<span class="ab-icon dashicons dashicons-performance" aria-hidden="true"></span>'
			         . '<span class="ab-label">🚀 ' . __( 'Boost Cache', 'basecloud-boost' ) . '</span>',
			'href'  => wp_nonce_url(
				add_query_arg( 'BCBOOST_action', 'clear_all', admin_url() ),
				'BCBOOST_clear_all'
			),
			'meta'  => [ 'title' => __( 'Clear BaseCloud Boost cache', 'basecloud-boost' ) ],
		] );
	}

	/**
	 * Handle AJAX clear-cache request.
	 *
	 * @return void
	 */
	public function ajax_clear_cache(): void {
		check_ajax_referer( 'BCBOOST_admin', 'nonce' );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'basecloud-boost' ) ], 403 );
		}

		$this->metrics->set( 'last_purge', time() );
		$this->purge->purge_all();

		wp_send_json_success( [ 'message' => __( 'Cache boosted! All pages cleared.', 'basecloud-boost' ) ] );
	}

	/**
	 * Purge stale minified asset files (weekly cron).
	 *
	 * @return void
	 */
	public function cleanup_minify_cache(): void {
		if ( ! is_dir( BCBOOST_MINIFY_DIR ) ) {
			return;
		}

		$max_age  = WEEK_IN_SECONDS * 2;
		$now      = time();
		$iterator = new \RecursiveIteratorIterator(
			new \RecursiveDirectoryIterator( BCBOOST_MINIFY_DIR, \FilesystemIterator::SKIP_DOTS )
		);

		foreach ( $iterator as $file ) {
			/** @var \SplFileInfo $file */
			// Self-hosted font assets (CSS + woff2) are long-lived and referenced by
			// cached pages, so they're exempt from the stale-minified-file sweep —
			// deleting them would 404 fonts until every page cache is rebuilt.
			if ( false !== strpos( $file->getPathname(), DIRECTORY_SEPARATOR . 'fonts' . DIRECTORY_SEPARATOR )
				|| false !== strpos( str_replace( '\\', '/', $file->getPathname() ), '/fonts/' ) ) {
				continue;
			}
			if ( $file->isFile() && ( $now - $file->getMTime() ) > $max_age ) {
				@unlink( $file->getPathname() );
			}
		}
	}

	/**
	 * Send webhook on the daily cron schedule (if enabled).
	 *
	 * @return void
	 */
	public function send_webhook_scheduled(): void {
		if ( ! BCBOOST_get_option( 'webhook_enabled', false ) ) {
			return;
		}
		( new Webhook_Sender() )->send();
	}

	/**
	 * Refresh the JSON options cache whenever a setting is saved.
	 *
	 * @param mixed $old_value Previous option value.
	 * @param mixed $new_value New option value.
	 * @return void
	 */
	public function refresh_options_cache( $old_value, $new_value ): void {
		\BaseCloud\Boost\Admin\Settings_API::write_options_cache( is_array( $new_value ) ? $new_value : null );
	}
}
