<?php

namespace EasyCriticalCSS;

use DateTime;

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

class Critical_CSS {
	public static $generated_css = [];

	public static function init() {
		Critical_CSS_Injector::init();
		Critical_CSS_Server::init();
		Critical_CSS_Status::init();

		add_action( 'template_redirect', [ __CLASS__, 'generate_critical_css_if_needed' ] );
		add_action( 'wp_head', [ __CLASS__, 'add_requested_time' ] );
	}

	public static function get_page_identifier() {
		$current_url = isset( $_SERVER['REQUEST_URI'] ) ? home_url( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) ) : '';

		// Block requests for unwanted file types.
		$excluded_extensions = apply_filters(
			'easy_cc_excluded_extensions',
			[
				'txt',
				'css',
				'js',
				'xml',
				'xsl',
				'json',
				'jpg',
				'jpeg',
				'png',
				'gif',
				'svg',
				'webp',
				'woff',
				'woff2',
				'ttf',
				'eot',
				'ico',
				'pdf',
				'csv',
				'zip',
				'gz',
				'rar',
			]
		);

		// If the URL ends with a blocked file extension, return null.
		$path_info = pathinfo( wp_parse_url( $current_url, PHP_URL_PATH ) );
		if ( isset( $path_info['extension'] ) && in_array( strtolower( $path_info['extension'] ), $excluded_extensions, true ) ) {
			return null;
		}

		// Block any wp-admin requests
		$path = wp_parse_url( $current_url, PHP_URL_PATH );
		if ( strpos( $path, '/wp-admin/' ) !== false ) {
			return null;
		}

		// Clean the URL from unwanted params.
		$excluded_url_params = Helpers::get_excluded_url_params();
		$clean_url           = esc_url_raw( remove_query_arg( $excluded_url_params, $current_url ) );

		// If it's a singular post, return post ID instead of URL.
		if ( is_singular() ) {
			return get_the_ID();
		}

		// Skip Critical CSS generation on paginated pages.
		if ( is_paged() ) {
			return null;
		}

		// Allow filtering of the final page identifier.
		$identifier = apply_filters( 'easy_cc_page_identifier', $clean_url );

		return $identifier;
	}

	public static function get_status( $status, $is_active ) {
		$valid_statuses        = [ 'completed', 'expired', 'failed', 'paused', 'pending', 'unprocessed' ];
		$is_rest_api_reachable = Helpers::is_rest_api_reachable();
		$is_local_site         = Helpers::is_local_site( wp_parse_url( site_url(), PHP_URL_HOST ) );

		// Is the REST API not reachable or the site local?
		if ( $is_local_site || ! $is_rest_api_reachable ) {
			return 'unreachable';
		}

		// Is critical paused?
		if ( ! $is_active ) {
			return 'paused';
		}

		return in_array( $status, $valid_statuses, true ) ? $status : 'unprocessed';
	}

	public static function get_generated_css( $identifier ) {
		// If we've already generated it based off post_id, just return that.
		if ( isset( self::$generated_css[ $identifier ] ) ) {
			return self::$generated_css[ $identifier ];
		}

		$data = Database::get_row( $identifier );

		// Prefer canonical permalink hash for this post.
		if ( is_numeric( $identifier ) ) {
			$expected_hash = Helpers::get_url_hash( $identifier );
			if ( empty( $data['url_hash'] ) || $data['url_hash'] !== $expected_hash ) {
				$by_hash = Database::get_row_by_url_hash( $expected_hash );
				if ( ! empty( $by_hash ) ) {
					$data = $by_hash;
				}
			}
		}

		// If we've already retrieved generated Critical based off hash, then return that.
		if ( ! empty( $data['url_hash'] ) && isset( self::$generated_css[ $data['url_hash'] ] ) ) {
			return self::$generated_css[ $data['url_hash'] ];
		}

		$generated_css = [
			'critical_css'   => ( isset( $data['critical_css'] ) ) ? $data['critical_css'] : '',
			'generated_time' => ( isset( $data['generated_time'] ) ) ? $data['generated_time'] : '',
			'updated_time'   => ( isset( $data['updated_time'] ) ) ? $data['updated_time'] : '',
			'requested_time' => ( isset( $data['requested_time'] ) ) ? $data['requested_time'] : '',
			'secondary_css'  => ( isset( $data['secondary_css'] ) ) ? $data['secondary_css'] : '',
			'last_error'     => ( isset( $data['last_error'] ) ) ? $data['last_error'] : '',
			'settings'       => ( isset( $data['settings'] ) ) ? $data['settings'] : '',
			'url_hash'       => ( isset( $data['url_hash'] ) ) ? $data['url_hash'] : '',
			'post_id'        => ( isset( $data['post_id'] ) ) ? $data['post_id'] : '',
			'size_savings'   => ( isset( $data['size_savings'] ) ) ? $data['size_savings'] : '',
		];

		// Compute status dynamically.
		$status                  = ( isset( $data['processing_status'] ) ) ? $data['processing_status'] : 'unprocessed';
		$status                  = ( ! empty( $generated_css['post_id'] ) && get_post_status( $generated_css['post_id'] ) !== 'publish' ) ? 'unprocessed' : $status;
		$settings                = ( isset( $data['settings'] ) ) ? json_decode( $data['settings'], true ) : [];
		$is_active               = Settings::activate_critical_compatibility( isset( $settings['activate_critical_css'] ) ? (bool) $settings['activate_critical_css'] : true );
		$generated_css['status'] = self::get_status( $status, $is_active );

		// Clear last error if our status isn't an error status
		if ( ! in_array( $generated_css['status'], [ 'failed', 'unreachable' ], true ) ) {
			$generated_css['last_error'] = '';
		}

		// Store result in static cache.
		self::$generated_css[ $identifier ] = $generated_css;

		if ( ! empty( $data['url_hash'] ) && $data['url_hash'] !== $identifier ) {
			self::$generated_css[ $data['url_hash'] ] = $generated_css;
		}

		return $generated_css;
	}

	public static function clear_cache( $identifier ) {
		unset( self::$generated_css[ $identifier ] );

		$hash = Helpers::get_url_hash( $identifier );
		if ( $hash !== (string) $identifier ) {
			unset( self::$generated_css[ $hash ] );
		}
	}

	private static function get_css_from_file( $type, $identifier ) {
		if ( ! Settings::get_global_serve_css_from_files() ) {
			return '';
		}

		$hash = Helpers::get_url_hash( $identifier );
		$path = trailingslashit( wp_upload_dir()['basedir'] ) . "easy-critical-css/{$type}/{$hash}.css";

		if ( is_readable( $path ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading from internal file.
			return file_get_contents( $path );
		}

		return '';
	}

	public static function get_critical_css( $identifier ) {
		// Manual mode will always use default.
		$mode = Settings::get_individual_mode( $identifier );
		if ( Settings::get_individual_mode( $identifier ) === 'manual' ) {
			$css = Settings::get_individual_default_critical_css( $identifier );
			if ( Helpers::css_looks_like_url( $css ) ) {
				return '';
			}
			return $css;
		}

		// If file-based serving is enabled, try loading it from file.
		$file_css = self::get_css_from_file( 'critical', $identifier );
		if ( ! empty( $file_css ) ) {
			return $file_css;
		}

		// Use generated if exists, falling back to default.
		$generated = self::get_generated_css( $identifier );

		return ! empty( $generated['critical_css'] )
			? $generated['critical_css']
			: Settings::get_individual_default_critical_css( $identifier );
	}

	public static function get_remaining_css( $identifier ) {
		$mode = Settings::get_individual_mode( $identifier );

		// Remaining CSS doesn't apply in manual mode.
		if ( $mode === 'manual' ) {
			return '';
		}

		$file_css = self::get_css_from_file( 'remaining', $identifier );
		if ( ! empty( $file_css ) ) {
			return $file_css;
		}

		$generated = self::get_generated_css( $identifier );
		return ! empty( $generated['remaining_css'] ) ? $generated['remaining_css'] : '';
	}

	public static function get_secondary_css( $identifier ) {
		$mode = Settings::get_individual_mode( $identifier );
		if ( $mode === 'manual' ) {
			$css = Settings::get_individual_manual_secondary_css( $identifier );
			if ( Helpers::css_looks_like_url( $css ) ) {
				return '';
			}
			return $css;
		}

		$file_css = self::get_css_from_file( 'secondary', $identifier );
		if ( ! empty( $file_css ) ) {
			return $file_css;
		}

		$generated = self::get_generated_css( $identifier );
		return ! empty( $generated['secondary_css'] ) ? $generated['secondary_css'] : '';
	}

	public static function generate_critical_css_if_needed() {
		if ( self::should_skip_critical() ) {
			return;
		}

		// Skip Cron requests.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- No need for nonce verification as we are using this only for skipping purposes.
		if ( ( defined( 'DOING_CRON' ) && DOING_CRON ) || isset( $_GET['doing_wp_cron'] ) ) {
			return;
		}

		// Skip REST, AJAX, XML-RPC follow-ups.
		if (
			( defined( 'REST_REQUEST' ) && REST_REQUEST )
				|| ( defined( 'DOING_AJAX' ) && DOING_AJAX )
				|| ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST )
		) {
			return;
		}

		// Skip known User-Agents.
		$request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
		if ( $request_method === 'HEAD' ) {
			return;
		}

		$blocked_user_agents = [
			'EasyCriticalCSS WP Fetch',
			'Uptime', // Catch any uptime monitor.
			'WP Rocket/Preload',
		];

		/**
		 * Filters the list of blocked User-Agents from triggering a Critical CSS regeneration.
		 *
		 * @since 1.2.0
		 *
		 * @param array $user_agents List of User-Agent fragments to ignore.
		 */
		$blocked_user_agents = apply_filters( 'easy_cc_blocked_user_agents', $blocked_user_agents );

		$ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
		foreach ( $blocked_user_agents as $blocked_user_agent ) {
			if ( stripos( $ua, $blocked_user_agent ) !== false ) {
				return;
			}
		}

		// Skip if we have no identifier or the mode is manual.
		$identifier = self::get_page_identifier();
		if ( empty( $identifier ) || Settings::get_individual_mode( $identifier ) === 'manual' ) {
			return;
		}

		// Skip if generation is paused due to repeated failures.
		if ( Failure_Patterns::is_paused( $identifier ) ) {
			return;
		}

		// Get current status and time, pulling directly from the DB to prevent any caching issues.
		$database_row   = Database::get_row( $identifier );
		$status         = ! empty( $database_row['processing_status'] ) ? $database_row['processing_status'] : 'unprocessed';
		$generated_time = ! empty( $database_row['generated_time'] ) ? $database_row['generated_time'] : '';

		// If we've generated before AND it's not expired, only proceed once per hour.
		if ( $status !== 'expired' && ! empty( $generated_time ) ) {
			$throttle = HOUR_IN_SECONDS;
			if ( strtotime( current_time( 'mysql' ) ) - strtotime( $generated_time ) < $throttle ) {
				return;
			}
		}

		// Only move forward if we aren't pending or completed.
		if ( in_array( $status, [ 'pending', 'completed' ], true ) ) {
			return;
		}

		// Immediately mark it as pending so that any concurrent request sees pending and runs away.
		Critical_CSS_Status::update_critical_css_status( $identifier, 'pending' );

		// Request Critical CSS generation
		API_Service::request_critical_css( $identifier );
	}

	private static function is_gutenberg_page() {
		// Are we in the block editor or full-site editor.
		if ( function_exists( 'get_current_screen' ) ) {
			$screen = get_current_screen();
			if ( isset( $screen->is_block_editor ) && $screen->is_block_editor ) {
				return true;
			}
		}

		// Is this an FSE page?
		if ( isset( $_GET['postType'] ) && 'wp_template' === $_GET['postType'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- No need for nonce verification as we are using this to check for Gutenberg.
			return true;
		}

		return false;
	}

	public static function should_skip_critical() {
		// Does the url skip critical?
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- No need for nonce verification as we are using this for read-only purposes.
		if ( isset( $_GET['critical-css'] ) && substr( sanitize_text_field( wp_unslash( $_GET['critical-css'] ) ), 0, 4 ) === 'skip' ) {
			return true;
		}

		// Is nocache turned on?
		if ( isset( $_GET['nocache'] ) ) {
			return true;
		}
		// phpcs:enable WordPress.Security.NonceVerification.Recommended

		// Is the file type something we don't need to generate for?
		$identifier = self::get_page_identifier();
		if ( empty( $identifier ) ) {
			return true;
		}

		// Is this an admin, preview, customizer, or Gutenberg page?
		if ( is_admin() || is_preview() || is_customize_preview() || self::is_gutenberg_page() ) {
			return true;
		}

		// Skip on search results pages
		if ( is_search() ) {
			return true;
		}

		// Skip on paged archive pages
		if ( is_paged() ) {
			return true;
		}

		// Skip RSS feeds
		if ( is_feed() ) {
			return true;
		}

		// Skip 404 pages
		if ( is_404() ) {
			return true;
		}

		// Is the post published? (Singular only)
		if ( is_singular() ) {
			$post_id = get_the_ID();
			if ( ! $post_id || get_post_status( $post_id ) !== 'publish' ) {
				return true;
			}
		}

		// Did we override this?
		return apply_filters( 'easy_cc_skip_critical', false );
	}

	public static function expire_individual_css( $identifier ) {
		Critical_CSS_Status::update_critical_css_status( $identifier, 'expired' );

		if ( Settings::get_global_expired_css_behavior() === 'use_expired' ) {
			return;
		}

		/**
		 * Fires immediately after Critical CSS has been expired on a single post/page/archive etc... if the expired behavior doesn't output expired Critical CSS.
		 *
		 * @since 1.0.0
		 *
		 * @param string $identifier Unique identifier of the expired Critical CSS.
		 */
		do_action( 'easy_cc_single_expired', $identifier );
	}

	// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- This method uses a direct DB query for improved performance.
	public static function expire_all_css() {
		global $wpdb;
		$wpdb->update(
			Database::get_table_name(),
			[ 'processing_status' => 'expired' ],
			[ 'processing_status' => 'completed' ],
		);

		if ( Settings::get_global_expired_css_behavior() === 'use_expired' ) {
			return;
		}

		/**
		 * Fires immediately after all Critical CSS has been expired sitewide if the expired behavior doesn't output expired Critical CSS.
		 *
		 * @since 1.0.0
		 */
		do_action( 'easy_cc_all_expired' );
	}
	// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

	public static function add_requested_time() {
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- No need for nonce verification as we are using this for read-only purposes.
		if ( ! isset( $_GET['critical-css'] ) || substr( sanitize_text_field( wp_unslash( $_GET['critical-css'] ) ), 0, 4 ) !== 'skip' ) {
			return;
		}

		$identifier = self::get_page_identifier();
		$critical   = self::get_generated_css( $identifier );

		if ( empty( $critical['requested_time'] ) ) {
			return;
		}

		// Convert the URL param timestamp into the same format.
		$url_timestamp = '';
		$datetime      = DateTime::createFromFormat( 'YmdHis', str_replace( 'skip', '', sanitize_text_field( wp_unslash( $_GET['critical-css'] ) ) ) );
		if ( $datetime instanceof DateTime ) {
			$url_timestamp = $datetime->format( 'Y-m-d H:i:s' );
		}

		// Output if we have a match or mismatch.
		if ( $url_timestamp === $critical['requested_time'] ) {
			echo "\n<!-- ECC_REQUESTED_MATCH -->\n";
		} else {
			echo "\n<!-- ECC_REQUESTED_MISMATCH -->\n";
		}

		// Only output actual time if Debug Mode is active.
		if ( ! Settings::get_global_debug_mode() ) {
			echo '<!-- ECC_REQUESTED_AT: ' . esc_html( $url_timestamp ) . " -->\n";
		}
	}
}
