<?php
/**
 * Customer-facing Progressive Web App support.
 *
 * @package FoodOrd
 */

defined( 'ABSPATH' ) || exit;

/**
 * Provides the web app manifest, service worker, and install experience.
 */
class Foodord_Pwa {
	private const OPTION_ENABLED          = 'foodord_pwa_enabled';
	private const OPTION_EVER_ENABLED     = 'foodord_pwa_ever_enabled';
	private const OPTION_INSTALL_PROMPT   = 'foodord_pwa_install_prompt';
	private const OPTION_APP_NAME         = 'foodord_pwa_app_name';
	private const OPTION_SHORT_NAME       = 'foodord_pwa_short_name';
	private const OPTION_DESCRIPTION      = 'foodord_pwa_description';
	private const OPTION_ICON_ID          = 'foodord_pwa_icon_id';
	private const OPTION_MASKABLE_ICON_ID = 'foodord_pwa_maskable_icon_id';
	private const OPTION_START_PAGE_ID    = 'foodord_pwa_start_page_id';
	private const OPTION_THEME_COLOR      = 'foodord_pwa_theme_color';
	private const OPTION_BG_COLOR         = 'foodord_pwa_background_color';
	private const OPTION_REVISION         = 'foodord_pwa_revision';

	private const ICON_SIZE_APPLE   = 180;
	private const ICON_SIZE_SMALL   = 192;
	private const ICON_SIZE_LARGE   = 512;
	private const ICON_SAFE_RATIO   = 0.78;
	private const ICON_SAFE_VERSION = 'v1';

	/**
	 * Register public hooks.
	 */
	public static function init(): void {
		add_action( 'init', array( self::class, 'register_image_sizes' ) );
		add_action( 'template_redirect', array( self::class, 'serve_public_endpoint' ), 0 );
		add_action( 'wp_head', array( self::class, 'render_document_metadata' ), 2 );
		add_action( 'wp_enqueue_scripts', array( self::class, 'enqueue_assets' ), 30 );
		add_action( 'wp_footer', array( self::class, 'render_install_prompt' ), 10 );
		add_filter( 'body_class', array( self::class, 'add_body_class' ) );
	}

	/**
	 * Register icon sizes used by the manifest.
	 */
	public static function register_image_sizes(): void {
		add_image_size( 'foodord-pwa-180', self::ICON_SIZE_APPLE, self::ICON_SIZE_APPLE, true );
		add_image_size( 'foodord-pwa-192', self::ICON_SIZE_SMALL, self::ICON_SIZE_SMALL, true );
		add_image_size( 'foodord-pwa-512', self::ICON_SIZE_LARGE, self::ICON_SIZE_LARGE, true );
	}

	/**
	 * Determine whether the customer PWA is enabled.
	 */
	public static function is_enabled(): bool {
		return 'yes' === get_option( self::OPTION_ENABLED, 'no' );
	}

	/**
	 * Return manifest URL.
	 */
	public static function get_manifest_url(): string {
		$args = array( 'foodord_pwa_manifest' => '1' );
		$args[ Foodord_Pwa_Language::get_query_arg() ] = Foodord_Pwa_Language::get_current_language();

		return add_query_arg(
			$args,
			self::get_runtime_base_url()
		);
	}

	/**
	 * Return root-scoped service worker URL.
	 */
	public static function get_service_worker_url(): string {
		return add_query_arg( 'foodord_pwa_service_worker', '1', self::get_runtime_base_url() );
	}

	/**
	 * Return service worker scope, including subdirectory installs.
	 */
	public static function get_scope(): string {
		$path = wp_parse_url( self::get_runtime_base_url(), PHP_URL_PATH );
		$path = is_string( $path ) && '' !== $path ? $path : '/';

		return trailingslashit( $path );
	}

	/**
	 * Serve manifest and service worker without rewrite-rule dependencies.
	 */
	public static function serve_public_endpoint(): void {
		$manifest_request = filter_input( INPUT_GET, 'foodord_pwa_manifest', FILTER_VALIDATE_BOOLEAN );
		$worker_request   = filter_input( INPUT_GET, 'foodord_pwa_service_worker', FILTER_VALIDATE_BOOLEAN );
		$offline_request  = filter_input( INPUT_GET, 'foodord_pwa_offline', FILTER_VALIDATE_BOOLEAN );

		if ( $manifest_request ) {
			self::serve_manifest();
		}

		if ( $worker_request ) {
			self::serve_service_worker();
		}

		if ( $offline_request ) {
			self::serve_offline_page();
		}
	}

	/**
	 * Render manifest and platform metadata.
	 */
	public static function render_document_metadata(): void {
		if ( ! self::is_enabled() || is_admin() ) {
			return;
		}

		$icon = self::get_safe_icon_source( self::get_icon_attachment_id(), self::ICON_SIZE_APPLE );
		?>
		<link rel="manifest" href="<?php echo esc_url( self::get_manifest_url() ); ?>" crossorigin="use-credentials" />
		<meta name="theme-color" content="<?php echo esc_attr( self::get_theme_color() ); ?>" />
		<meta name="mobile-web-app-capable" content="yes" />
		<meta name="apple-mobile-web-app-capable" content="yes" />
		<meta name="apple-mobile-web-app-status-bar-style" content="default" />
		<meta name="apple-mobile-web-app-title" content="<?php echo esc_attr( self::get_short_name() ); ?>" />
		<?php if ( ! empty( $icon['src'] ) ) : ?>
			<link rel="apple-touch-icon" sizes="180x180" href="<?php echo esc_url( $icon['src'] ); ?>" />
		<?php endif; ?>
		<?php
	}

	/**
	 * Enqueue the registration and install experience.
	 */
	public static function enqueue_assets(): void {
		if ( is_admin() || ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) ) {
			return;
		}

		$enabled      = self::is_enabled();
		$ever_enabled = 'yes' === get_option( self::OPTION_EVER_ENABLED, 'no' );
		if ( ! $enabled && ! $ever_enabled ) {
			return;
		}

		wp_enqueue_script(
			'foodord-pwa',
			FOODORD_URL . 'public/js/foodord-pwa.js',
			array(),
			FOODORD_VERSION,
			true
		);

		wp_localize_script(
			'foodord-pwa',
			'foodordPwaConfig',
			array(
				'enabled'          => $enabled,
				'serviceWorkerUrl' => self::get_service_worker_url(),
				'scope'            => self::get_scope(),
				'appId'            => md5( self::get_runtime_base_url() ),
				'dismissDays'      => 14,
				'showPrompt'       => $enabled && 'no' !== get_option( self::OPTION_INSTALL_PROMPT, 'yes' ) && ! self::is_transactional_screen(),
				'iosInstructions'  => __( 'Open the browser Share menu and choose Add to Home Screen.', 'foodord' ),
				'macInstructions'  => __( 'Open the Safari File menu and choose Add to Dock.', 'foodord' ),
			)
		);

		if ( $enabled ) {
			wp_enqueue_style(
				'foodord-pwa',
				FOODORD_URL . 'public/css/foodord-pwa.css',
				array(),
				FOODORD_VERSION,
				'all'
			);
		}
	}

	/**
	 * Render the install prompt once; JavaScript controls its visibility.
	 */
	public static function render_install_prompt(): void {
		if ( ! self::is_enabled() || 'no' === get_option( self::OPTION_INSTALL_PROMPT, 'yes' ) || self::is_transactional_screen() ) {
			return;
		}

		$icon = self::get_safe_icon_source( self::get_icon_attachment_id(), self::ICON_SIZE_SMALL );
		?>
		<aside class="foodord-pwa-install" data-foodord-pwa-install hidden aria-live="polite" aria-labelledby="foodord-pwa-install-title">
			<div class="foodord-pwa-install__content">
				<?php if ( ! empty( $icon['src'] ) ) : ?>
					<img class="foodord-pwa-install__icon" src="<?php echo esc_url( $icon['src'] ); ?>" alt="" width="64" height="64" />
				<?php endif; ?>
				<div class="foodord-pwa-install__copy">
					<strong class="foodord-pwa-install__title" id="foodord-pwa-install-title">
						<?php
						/* translators: %s: Restaurant app name. */
						echo esc_html( sprintf( __( 'Install %s', 'foodord' ), self::get_app_name() ) );
						?>
					</strong>
					<span class="foodord-pwa-install__description"><?php esc_html_e( 'Add this restaurant to your device for faster access.', 'foodord' ); ?></span>
					<span class="foodord-pwa-install__instructions" data-foodord-pwa-instructions hidden></span>
				</div>
				<div class="foodord-pwa-install__actions">
					<button class="foodord-pwa-install__button" type="button" data-foodord-pwa-install-button><?php esc_html_e( 'Install', 'foodord' ); ?></button>
					<button class="foodord-pwa-install__dismiss" type="button" data-foodord-pwa-dismiss aria-label="<?php echo esc_attr__( 'Dismiss install prompt', 'foodord' ); ?>">&times;</button>
				</div>
			</div>
		</aside>
		<?php
	}

	/**
	 * Add enabled state to the body.
	 *
	 * @param string[] $classes Existing classes.
	 * @return string[]
	 */
	public static function add_body_class( array $classes ): array {
		if ( self::is_enabled() ) {
			$classes[] = 'foodord-pwa-enabled';
		}

		return $classes;
	}

	/**
	 * Create generated icon sizes for an existing attachment.
	 *
	 * @param int $attachment_id Image attachment ID.
	 */
	public static function prepare_icon_attachment( int $attachment_id ): bool {
		if ( ! self::is_valid_icon_attachment( $attachment_id ) ) {
			return false;
		}

		require_once ABSPATH . 'wp-admin/includes/image.php';

		$file     = get_attached_file( $attachment_id );
		$metadata = wp_get_attachment_metadata( $attachment_id );
		if ( ! is_string( $file ) || '' === $file || ! is_array( $metadata ) ) {
			return false;
		}

		$sizes = array(
			'foodord-pwa-180' => self::ICON_SIZE_APPLE,
			'foodord-pwa-192' => self::ICON_SIZE_SMALL,
			'foodord-pwa-512' => self::ICON_SIZE_LARGE,
		);

		foreach ( $sizes as $name => $size ) {
			if ( isset( $metadata['sizes'][ $name ] ) ) {
				self::prepare_safe_icon_file( $attachment_id, $size );
				continue;
			}

			$generated = image_make_intermediate_size( $file, $size, $size, true );
			if ( is_array( $generated ) ) {
				$metadata['sizes'][ $name ] = $generated;
			}

			self::prepare_safe_icon_file( $attachment_id, $size );
		}

		wp_update_attachment_metadata( $attachment_id, $metadata );

		return true;
	}

	/**
	 * Validate a square app icon attachment.
	 *
	 * @param int $attachment_id Image attachment ID.
	 */
	public static function is_valid_icon_attachment( int $attachment_id ): bool {
		if ( $attachment_id <= 0 || ! wp_attachment_is_image( $attachment_id ) ) {
			return false;
		}

		$metadata = wp_get_attachment_metadata( $attachment_id );
		if ( ! is_array( $metadata ) ) {
			return false;
		}

		$width  = isset( $metadata['width'] ) ? absint( $metadata['width'] ) : 0;
		$height = isset( $metadata['height'] ) ? absint( $metadata['height'] ) : 0;

		return $width >= self::ICON_SIZE_LARGE && $width === $height;
	}

	/**
	 * Increment asset revision after settings change.
	 */
	public static function increment_revision(): void {
		update_option( self::OPTION_REVISION, time(), false );
	}

	/**
	 * Return available option names to the settings handler.
	 *
	 * @return array<string, string>
	 */
	public static function get_option_names(): array {
		return array(
			'enabled'          => self::OPTION_ENABLED,
			'ever_enabled'     => self::OPTION_EVER_ENABLED,
			'install_prompt'   => self::OPTION_INSTALL_PROMPT,
			'app_name'         => self::OPTION_APP_NAME,
			'short_name'       => self::OPTION_SHORT_NAME,
			'description'      => self::OPTION_DESCRIPTION,
			'icon_id'          => self::OPTION_ICON_ID,
			'maskable_icon_id' => self::OPTION_MASKABLE_ICON_ID,
			'start_page_id'    => self::OPTION_START_PAGE_ID,
			'theme_color'      => self::OPTION_THEME_COLOR,
			'background_color' => self::OPTION_BG_COLOR,
		);
	}

	/**
	 * Return the effective app icon attachment ID.
	 */
	public static function get_icon_attachment_id(): int {
		$icon_id = absint( get_option( self::OPTION_ICON_ID, 0 ) );
		if ( self::is_valid_icon_attachment( $icon_id ) ) {
			return $icon_id;
		}

		$site_icon_id = absint( get_option( 'site_icon', 0 ) );

		return self::is_valid_icon_attachment( $site_icon_id ) ? $site_icon_id : 0;
	}

	/**
	 * Return the effective app name.
	 *
	 * @param string $language Target language code.
	 */
	public static function get_app_name( string $language = '' ): string {
		$name = sanitize_text_field( (string) get_option( self::OPTION_APP_NAME, '' ) );
		if ( '' !== $name ) {
			return Foodord_Pwa_Language::translate_string( $name, 'App name', $language );
		}

		return wp_strip_all_tags( get_bloginfo( 'name' ) );
	}

	/**
	 * Return the effective short name.
	 *
	 * @param string $language Target language code.
	 */
	public static function get_short_name( string $language = '' ): string {
		$name = sanitize_text_field( (string) get_option( self::OPTION_SHORT_NAME, '' ) );
		if ( '' === $name ) {
			$name = self::get_app_name( $language );
		} else {
			$name = Foodord_Pwa_Language::translate_string( $name, 'Short name', $language );
		}

		return function_exists( 'mb_substr' ) ? mb_substr( $name, 0, 30 ) : substr( $name, 0, 30 );
	}

	/**
	 * Return the effective app description.
	 *
	 * @param string $language Target language code.
	 */
	private static function get_description( string $language = '' ): string {
		$description = sanitize_text_field( (string) get_option( self::OPTION_DESCRIPTION, '' ) );
		if ( '' !== $description ) {
			return Foodord_Pwa_Language::translate_string( $description, 'App description', $language );
		}

		return wp_strip_all_tags( get_bloginfo( 'description' ) );
	}

	/**
	 * Return manifest theme color.
	 */
	public static function get_theme_color(): string {
		$color = sanitize_hex_color( (string) get_option( self::OPTION_THEME_COLOR, '#ea004b' ) );

		return is_string( $color ) ? $color : '#ea004b';
	}

	/**
	 * Return manifest background color.
	 */
	public static function get_background_color(): string {
		$color = sanitize_hex_color( (string) get_option( self::OPTION_BG_COLOR, '#ffffff' ) );

		return is_string( $color ) ? $color : '#ffffff';
	}

	/**
	 * Serve the web app manifest.
	 */
	private static function serve_manifest(): void {
		if ( ! self::is_enabled() ) {
			status_header( 404 );
			exit;
		}

		$language = Foodord_Pwa_Language::get_current_language();
		Foodord_Pwa_Language::activate_language( $language );

		$icons = self::get_manifest_icons();
		if ( empty( $icons ) ) {
			status_header( 422 );
			exit;
		}

		$manifest = array(
			'id'                          => self::get_scope(),
			'name'                        => self::get_app_name( $language ),
			'short_name'                  => self::get_short_name( $language ),
			'description'                 => self::get_description( $language ),
			'lang'                        => Foodord_Pwa_Language::get_language_tag( $language ),
			'dir'                         => is_rtl() ? 'rtl' : 'ltr',
			'start_url'                   => self::get_start_url( $language ),
			'scope'                       => self::get_scope(),
			'display'                     => 'standalone',
			'display_override'            => array( 'standalone', 'minimal-ui' ),
			'orientation'                 => 'any',
			'theme_color'                 => self::get_theme_color(),
			'background_color'            => self::get_background_color(),
			'icons'                       => $icons,
			'categories'                  => array( 'food', 'shopping', 'business' ),
			'prefer_related_applications' => false,
			'shortcuts'                   => self::get_shortcuts( $language ),
		);

		nocache_headers();
		header( 'Content-Type: application/manifest+json; charset=' . get_option( 'blog_charset', 'UTF-8' ) );
		header( 'X-Content-Type-Options: nosniff' );

		exit(
			wp_json_encode(
				$manifest,
				JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE
			)
		);
	}

	/**
	 * Serve the root-scoped service worker.
	 */
	private static function serve_service_worker(): void {
		$offline_pages = self::get_offline_pages();

		nocache_headers();
		header( 'Content-Type: application/javascript; charset=UTF-8' );
		header( 'Service-Worker-Allowed: ' . self::get_scope() );
		header( 'X-Content-Type-Options: nosniff' );

		$config = array(
			'enabled'          => self::is_enabled(),
			'cacheName'        => self::get_cache_name( 'shell' ),
			'runtimeCacheName' => self::get_cache_name( 'runtime' ),
			'cachePrefix'      => 'foodord-pwa-',
			'runtimeMaxItems'  => 120,
			'precacheUrls'     => self::get_precache_urls( $offline_pages ),
			'offlinePages'     => $offline_pages,
		);

		$worker_source = <<<'JAVASCRIPT'
const currentCacheNames = new Set([FOODORD_PWA.cacheName, FOODORD_PWA.runtimeCacheName]);

const deleteFoodordCaches = (preserveCurrent = true) => caches.keys().then((keys) => Promise.all(
	keys.filter((key) => key.startsWith(FOODORD_PWA.cachePrefix) && (!preserveCurrent || !currentCacheNames.has(key)))
		.map((key) => caches.delete(key))
));

self.addEventListener('install', (event) => {
	event.waitUntil((async () => {
		if (FOODORD_PWA.enabled && FOODORD_PWA.precacheUrls.length) {
			const cache = await caches.open(FOODORD_PWA.cacheName);
			await Promise.allSettled(FOODORD_PWA.precacheUrls.map((url) => cache.add(url)));
		}
		await self.skipWaiting();
	})());
});

self.addEventListener('activate', (event) => {
	event.waitUntil((async () => {
		await deleteFoodordCaches();
		if (!FOODORD_PWA.enabled) {
			await Promise.all(Array.from(currentCacheNames).map((cacheName) => caches.delete(cacheName)));
			await self.registration.unregister();
		}
		await self.clients.claim();
	})());
});

self.addEventListener('message', (event) => {
	if (event.data && event.data.type === 'FOODORD_PWA_CLEAR') {
		event.waitUntil(deleteFoodordCaches(false));
	}
});

const isTransactionalRequest = (url) => {
	const path = url.pathname.toLowerCase();
	const blockedPathParts = [
		'/wp-admin/', '/wp-login.php', '/wp-json/', '/cart/', '/checkout/',
		'/my-account/', '/order-pay/', '/order-received/'
	];
	const blockedQueryKeys = [
		'add-to-cart', 'wc-ajax', 'foodord_reservation_manage', 'foodord_reservation_token',
		'foodord_reservation_preorder_edit', 'foodord_reservation_preorder_key',
		'foodord_pro_review_token'
	];

	return blockedPathParts.some((part) => path.includes(part)) ||
		blockedQueryKeys.some((key) => url.searchParams.has(key));
};

const isStaticAsset = (url) => {
	if (isTransactionalRequest(url)) {
		return false;
	}

	return /\.(?:css|js|mjs|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf)$/i.test(url.pathname) &&
		(url.pathname.includes('/wp-content/') || url.pathname.includes('/wp-includes/'));
};

const trimRuntimeCache = async (cache) => {
	const keys = await cache.keys();
	const overflow = keys.length - FOODORD_PWA.runtimeMaxItems;
	if (overflow > 0) {
		await Promise.all(keys.slice(0, overflow).map((request) => cache.delete(request)));
	}
};

const staleWhileRevalidate = async (request) => {
	const cache = await caches.open(FOODORD_PWA.runtimeCacheName);
	const cached = await cache.match(request);
	const network = fetch(request).then(async (response) => {
		const cacheControl = response.headers.get('Cache-Control') || '';
		if (response.ok && response.type === 'basic' && !/no-store/i.test(cacheControl)) {
			await cache.put(request, response.clone());
			await trimRuntimeCache(cache);
		}
		return response;
	}).catch(() => cached || Response.error());

	return cached || network;
};

const getOfflineUrl = (requestUrl) => {
	const pages = [...FOODORD_PWA.offlinePages].sort((left, right) => {
		const leftUrl = new URL(left.homeUrl);
		const rightUrl = new URL(right.homeUrl);
		return (rightUrl.pathname.length + rightUrl.search.length) -
			(leftUrl.pathname.length + leftUrl.search.length);
	});

	const match = pages.find((page) => {
		const homeUrl = new URL(page.homeUrl);
		const homePath = homeUrl.pathname.replace(/\/$/, '');
		const requestPath = requestUrl.pathname.replace(/\/$/, '');
		if (homeUrl.origin !== requestUrl.origin ||
			(requestPath !== homePath && !requestPath.startsWith(`${homePath}/`))) {
			return false;
		}

		return [...homeUrl.searchParams.entries()].every(
			([key, value]) => requestUrl.searchParams.get(key) === value
		);
	});

	return match ? match.offlineUrl : (pages[0] ? pages[0].offlineUrl : '');
};

self.addEventListener('fetch', (event) => {
	if (!FOODORD_PWA.enabled || event.request.method !== 'GET') {
		return;
	}

	const url = new URL(event.request.url);
	if (url.origin !== self.location.origin) {
		return;
	}

	if (event.request.mode === 'navigate') {
		event.respondWith(fetch(event.request).catch(async () => {
			const cache = await caches.open(FOODORD_PWA.cacheName);
			const offlineUrl = getOfflineUrl(url);
			return offlineUrl ? cache.match(offlineUrl).then((response) => response || Response.error()) : Response.error();
		}));
		return;
	}

	if (isStaticAsset(url)) {
		event.respondWith(staleWhileRevalidate(event.request));
	}
});
JAVASCRIPT;

		$response = sprintf(
			"const FOODORD_PWA = %s;\n%s",
			wp_json_encode(
				$config,
				JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE
			),
			$worker_source
		);
		$output   = fopen( 'php://output', 'wb' );

		if ( false === $output ) {
			status_header( 500 );
			exit;
		}

		fwrite( $output, $response );
		fclose( $output );
		exit;
	}

	/**
	 * Serve the localized offline document cached by the service worker.
	 */
	private static function serve_offline_page(): void {
		if ( ! self::is_enabled() ) {
			status_header( 404 );
			exit;
		}

		$language = Foodord_Pwa_Language::get_current_language();
		Foodord_Pwa_Language::activate_language( $language );

		nocache_headers();
		header( 'Content-Type: text/html; charset=' . get_option( 'blog_charset', 'UTF-8' ) );
		header( 'X-Content-Type-Options: nosniff' );

		wp_enqueue_style(
			'foodord-pwa-offline',
			FOODORD_URL . 'public/css/foodord-pwa-offline.css',
			array(),
			FOODORD_VERSION,
			'all'
		);
		?>
		<!doctype html>
		<html <?php language_attributes(); ?>>
		<head>
			<meta charset="<?php bloginfo( 'charset' ); ?>" />
			<meta name="viewport" content="width=device-width, initial-scale=1" />
			<title><?php esc_html_e( 'You are offline', 'foodord' ); ?></title>
			<?php wp_styles()->do_items( array( 'foodord-pwa-offline' ) ); ?>
		</head>
		<body>
			<main class="foodord-pwa-offline">
				<h1 class="foodord-pwa-offline__title"><?php esc_html_e( 'You are offline', 'foodord' ); ?></h1>
				<p class="foodord-pwa-offline__message"><?php esc_html_e( 'Reconnect to continue ordering or managing your reservation.', 'foodord' ); ?></p>
			</main>
		</body>
		</html>
		<?php
		exit;
	}

	/**
	 * Return manifest icon definitions.
	 *
	 * @return array<int, array<string, string>>
	 */
	private static function get_manifest_icons(): array {
		$icons   = array();
		$icon_id = self::get_icon_attachment_id();

		foreach ( array( self::ICON_SIZE_SMALL, self::ICON_SIZE_LARGE ) as $size ) {
			$icon = self::get_icon_source( $icon_id, $size );
			if ( ! empty( $icon['src'] ) ) {
				$icons[] = array(
					'src'     => $icon['src'],
					'sizes'   => $icon['width'] . 'x' . $icon['height'],
					'type'    => $icon['type'],
					'purpose' => 'any',
				);
			}
		}

		$maskable_id = absint( get_option( self::OPTION_MASKABLE_ICON_ID, 0 ) );
		if ( ! self::is_valid_icon_attachment( $maskable_id ) ) {
			$maskable_id = $icon_id;
		}

		if ( self::is_valid_icon_attachment( $maskable_id ) ) {
			foreach ( array( self::ICON_SIZE_SMALL, self::ICON_SIZE_LARGE ) as $size ) {
				$icon = self::get_safe_icon_source( $maskable_id, $size );
				if ( ! empty( $icon['src'] ) ) {
					$icons[] = array(
						'src'     => $icon['src'],
						'sizes'   => $icon['width'] . 'x' . $icon['height'],
						'type'    => $icon['type'],
						'purpose' => 'maskable',
					);
				}
			}
		}

		return $icons;
	}

	/**
	 * Return a generated icon whose artwork stays inside platform safe areas.
	 *
	 * @param int $attachment_id Image attachment ID.
	 * @param int $size          Requested square size.
	 * @return array{src:string,width:int,height:int,type:string}
	 */
	private static function get_safe_icon_source( int $attachment_id, int $size ): array {
		$asset = self::prepare_safe_icon_file( $attachment_id, $size );
		if ( empty( $asset['path'] ) || empty( $asset['url'] ) ) {
			return self::get_icon_source( $attachment_id, $size );
		}

		return array(
			'src'    => esc_url_raw( $asset['url'] ),
			'width'  => $size,
			'height' => $size,
			'type'   => 'image/png',
		);
	}

	/**
	 * Generate and return one safe-area icon file.
	 *
	 * @param int $attachment_id Image attachment ID.
	 * @param int $size          Requested square size.
	 * @return array{path:string,url:string}
	 */
	private static function prepare_safe_icon_file( int $attachment_id, int $size ): array {
		$empty = array(
			'path' => '',
			'url'  => '',
		);

		$allowed_sizes = array( self::ICON_SIZE_APPLE, self::ICON_SIZE_SMALL, self::ICON_SIZE_LARGE );
		if ( ! in_array( $size, $allowed_sizes, true ) || ! self::is_valid_icon_attachment( $attachment_id ) ) {
			return $empty;
		}

		$source_path = get_attached_file( $attachment_id );
		$source_url  = wp_get_attachment_url( $attachment_id );
		if ( ! is_string( $source_path ) || ! is_file( $source_path ) || ! is_string( $source_url ) || '' === $source_url ) {
			return $empty;
		}

		$source_name      = pathinfo( wp_basename( $source_path ), PATHINFO_FILENAME );
		$destination_name = sanitize_file_name( $source_name . '-foodord-pwa-safe-' . self::ICON_SAFE_VERSION . '-' . $size . '.png' );
		$destination_path = trailingslashit( dirname( $source_path ) ) . $destination_name;
		$destination_url  = trailingslashit( dirname( $source_url ) ) . rawurlencode( $destination_name );

		if ( is_file( $destination_path ) && filesize( $destination_path ) > 0 && filemtime( $destination_path ) >= filemtime( $source_path ) ) {
			return array(
				'path' => $destination_path,
				'url'  => $destination_url,
			);
		}

		if ( ! self::generate_safe_icon( $source_path, $destination_path, $size ) ) {
			return $empty;
		}

		return array(
			'path' => $destination_path,
			'url'  => $destination_url,
		);
	}

	/**
	 * Render one safe-area PNG using the available WordPress image stack.
	 *
	 * @param string $source_path      Source attachment path.
	 * @param string $destination_path Generated icon path.
	 * @param int    $size             Output size.
	 */
	private static function generate_safe_icon( string $source_path, string $destination_path, int $size ): bool {
		require_once ABSPATH . 'wp-admin/includes/file.php';
		require_once ABSPATH . 'wp-admin/includes/image.php';

		$inner_size = max( 1, (int) round( $size * self::ICON_SAFE_RATIO ) );
		$editor     = wp_get_image_editor( $source_path );
		if ( is_wp_error( $editor ) || is_wp_error( $editor->resize( $inner_size, $inner_size, true ) ) ) {
			return false;
		}

		$temporary_seed = wp_tempnam( $source_path );
		if ( ! is_string( $temporary_seed ) || '' === $temporary_seed ) {
			return false;
		}

		$saved = $editor->save( $temporary_seed, 'image/png' );
		if ( is_wp_error( $saved ) || empty( $saved['path'] ) || ! is_file( $saved['path'] ) ) {
			wp_delete_file( $temporary_seed );
			return false;
		}

		$temporary_path = $saved['path'];
		$generated      = false;

		if ( function_exists( 'imagecreatefrompng' ) && function_exists( 'imagepng' ) ) {
			$generated = self::generate_safe_icon_with_gd( $temporary_path, $destination_path, $size );
		} elseif ( class_exists( 'Imagick' ) ) {
			$generated = self::generate_safe_icon_with_imagick( $temporary_path, $destination_path, $size );
		}

		wp_delete_file( $temporary_path );
		if ( $temporary_path !== $temporary_seed ) {
			wp_delete_file( $temporary_seed );
		}

		return $generated;
	}

	/**
	 * Compose a safe-area icon using GD.
	 *
	 * @param string $source_path      Resized source path.
	 * @param string $destination_path Generated icon path.
	 * @param int    $size             Output size.
	 */
	private static function generate_safe_icon_with_gd( string $source_path, string $destination_path, int $size ): bool {
		$source = imagecreatefrompng( $source_path );
		if ( false === $source ) {
			return false;
		}

		$fallback   = self::hex_to_rgb( self::get_theme_color() );
		$background = self::detect_gd_background_color( $source, $fallback );
		$canvas     = imagecreatetruecolor( $size, $size );
		if ( false === $canvas ) {
			imagedestroy( $source );
			return false;
		}

		$fill = imagecolorallocate( $canvas, $background['red'], $background['green'], $background['blue'] );
		imagefilledrectangle( $canvas, 0, 0, $size, $size, $fill );

		$source_width  = imagesx( $source );
		$source_height = imagesy( $source );
		$offset_x      = (int) floor( ( $size - $source_width ) / 2 );
		$offset_y      = (int) floor( ( $size - $source_height ) / 2 );
		imagealphablending( $canvas, true );
		imagecopy( $canvas, $source, $offset_x, $offset_y, 0, 0, $source_width, $source_height );

		$written = imagepng( $canvas, $destination_path, 9 );
		imagedestroy( $source );
		imagedestroy( $canvas );

		return $written;
	}

	/**
	 * Compose a safe-area icon using ImageMagick.
	 *
	 * @param string $source_path      Resized source path.
	 * @param string $destination_path Generated icon path.
	 * @param int    $size             Output size.
	 */
	private static function generate_safe_icon_with_imagick( string $source_path, string $destination_path, int $size ): bool {
		try {
			$source     = new Imagick( $source_path );
			$fallback   = self::hex_to_rgb( self::get_theme_color() );
			$background = self::detect_imagick_background_color( $source, $fallback );
			$canvas     = new Imagick();
			$canvas->newImage(
				$size,
				$size,
				new ImagickPixel( sprintf( 'rgb(%d,%d,%d)', $background['red'], $background['green'], $background['blue'] ) ),
				'png'
			);

			$offset_x = (int) floor( ( $size - $source->getImageWidth() ) / 2 );
			$offset_y = (int) floor( ( $size - $source->getImageHeight() ) / 2 );
			$canvas->compositeImage( $source, Imagick::COMPOSITE_OVER, $offset_x, $offset_y );
			$canvas->setImageFormat( 'png' );
			$written = $canvas->writeImage( $destination_path );

			$source->clear();
			$canvas->clear();

			return $written;
		} catch ( Exception $exception ) {
			unset( $exception );
			return false;
		}
	}

	/**
	 * Detect the dominant opaque edge color of a GD image.
	 *
	 * @param mixed                             $image    GD image resource.
	 * @param array{red:int,green:int,blue:int} $fallback Fallback RGB color.
	 * @return array{red:int,green:int,blue:int}
	 */
	private static function detect_gd_background_color( $image, array $fallback ): array {
		$width  = imagesx( $image );
		$height = imagesy( $image );
		$colors = array();

		foreach ( self::get_edge_sample_points( $width, $height ) as $point ) {
			$color = imagecolorsforindex( $image, imagecolorat( $image, $point[0], $point[1] ) );
			if ( ! is_array( $color ) || ( isset( $color['alpha'] ) && $color['alpha'] > 48 ) ) {
				continue;
			}

			self::add_color_sample( $colors, (int) $color['red'], (int) $color['green'], (int) $color['blue'] );
		}

		return self::resolve_color_samples( $colors, $fallback );
	}

	/**
	 * Detect the dominant opaque edge color of an ImageMagick image.
	 *
	 * @param mixed                             $image    ImageMagick image.
	 * @param array{red:int,green:int,blue:int} $fallback Fallback RGB color.
	 * @return array{red:int,green:int,blue:int}
	 */
	private static function detect_imagick_background_color( $image, array $fallback ): array {
		$colors = array();

		foreach ( self::get_edge_sample_points( $image->getImageWidth(), $image->getImageHeight() ) as $point ) {
			$color = $image->getImagePixelColor( $point[0], $point[1] )->getColor( true );
			if ( ! is_array( $color ) || ( isset( $color['a'] ) && $color['a'] < 0.62 ) ) {
				continue;
			}

			self::add_color_sample(
				$colors,
				(int) round( (float) $color['r'] * 255 ),
				(int) round( (float) $color['g'] * 255 ),
				(int) round( (float) $color['b'] * 255 )
			);
		}

		return self::resolve_color_samples( $colors, $fallback );
	}

	/**
	 * Return points distributed around an image edge.
	 *
	 * @param int $width  Image width.
	 * @param int $height Image height.
	 * @return array<int, array{0:int,1:int}>
	 */
	private static function get_edge_sample_points( int $width, int $height ): array {
		$points  = array();
		$inset_x = min( max( 1, (int) round( $width * 0.02 ) ), max( 0, $width - 1 ) );
		$inset_y = min( max( 1, (int) round( $height * 0.02 ) ), max( 0, $height - 1 ) );

		for ( $step = 0; $step <= 20; $step++ ) {
			$x        = min( $width - 1, (int) round( ( $width - 1 ) * ( $step / 20 ) ) );
			$y        = min( $height - 1, (int) round( ( $height - 1 ) * ( $step / 20 ) ) );
			$points[] = array( $x, $inset_y );
			$points[] = array( $x, max( 0, $height - 1 - $inset_y ) );
			$points[] = array( $inset_x, $y );
			$points[] = array( max( 0, $width - 1 - $inset_x ), $y );
		}

		return $points;
	}

	/**
	 * Add an RGB sample to a quantized color bucket.
	 *
	 * @param array<string, array{count:int,red:int,green:int,blue:int}> $colors Color buckets.
	 * @param int                                                        $red    Red channel.
	 * @param int                                                        $green  Green channel.
	 * @param int                                                        $blue   Blue channel.
	 */
	private static function add_color_sample( array &$colors, int $red, int $green, int $blue ): void {
		$key = (int) floor( $red / 16 ) . ':' . (int) floor( $green / 16 ) . ':' . (int) floor( $blue / 16 );
		if ( ! isset( $colors[ $key ] ) ) {
			$colors[ $key ] = array(
				'count' => 0,
				'red'   => 0,
				'green' => 0,
				'blue'  => 0,
			);
		}

		++$colors[ $key ]['count'];
		$colors[ $key ]['red']   += $red;
		$colors[ $key ]['green'] += $green;
		$colors[ $key ]['blue']  += $blue;
	}

	/**
	 * Resolve sampled color buckets into one RGB color.
	 *
	 * @param array<string, array{count:int,red:int,green:int,blue:int}> $colors   Color buckets.
	 * @param array{red:int,green:int,blue:int}                          $fallback Fallback RGB color.
	 * @return array{red:int,green:int,blue:int}
	 */
	private static function resolve_color_samples( array $colors, array $fallback ): array {
		if ( empty( $colors ) ) {
			return $fallback;
		}

		uasort(
			$colors,
			static function ( array $left, array $right ): int {
				return $right['count'] <=> $left['count'];
			}
		);

		$selected = reset( $colors );
		if ( ! is_array( $selected ) || $selected['count'] < 2 ) {
			return $fallback;
		}

		return array(
			'red'   => (int) round( $selected['red'] / $selected['count'] ),
			'green' => (int) round( $selected['green'] / $selected['count'] ),
			'blue'  => (int) round( $selected['blue'] / $selected['count'] ),
		);
	}

	/**
	 * Convert a hexadecimal color into RGB channels.
	 *
	 * @param string $color Hexadecimal color.
	 * @return array{red:int,green:int,blue:int}
	 */
	private static function hex_to_rgb( string $color ): array {
		$normalized = sanitize_hex_color( $color );
		$normalized = is_string( $normalized ) ? ltrim( $normalized, '#' ) : 'ea004b';

		return array(
			'red'   => hexdec( substr( $normalized, 0, 2 ) ),
			'green' => hexdec( substr( $normalized, 2, 2 ) ),
			'blue'  => hexdec( substr( $normalized, 4, 2 ) ),
		);
	}

	/**
	 * Return attachment source for a manifest icon size.
	 *
	 * @param int $attachment_id Image attachment ID.
	 * @param int $size          Requested square size.
	 * @return array{src:string,width:int,height:int,type:string}
	 */
	private static function get_icon_source( int $attachment_id, int $size ): array {
		$empty = array(
			'src'    => '',
			'width'  => 0,
			'height' => 0,
			'type'   => 'image/png',
		);

		if ( ! self::is_valid_icon_attachment( $attachment_id ) ) {
			return $empty;
		}

		$image_sizes = array(
			self::ICON_SIZE_APPLE => 'foodord-pwa-180',
			self::ICON_SIZE_SMALL => 'foodord-pwa-192',
			self::ICON_SIZE_LARGE => 'foodord-pwa-512',
		);
		$image_size  = $image_sizes[ $size ] ?? 'full';
		$source      = wp_get_attachment_image_src( $attachment_id, $image_size );
		if ( ! is_array( $source ) || empty( $source[0] ) ) {
			$source = wp_get_attachment_image_src( $attachment_id, 'full' );
		}

		if ( ! is_array( $source ) || empty( $source[0] ) ) {
			return $empty;
		}

		$mime = get_post_mime_type( $attachment_id );

		return array(
			'src'    => esc_url_raw( $source[0] ),
			'width'  => absint( $source[1] ),
			'height' => absint( $source[2] ),
			'type'   => is_string( $mime ) && '' !== $mime ? $mime : 'image/png',
		);
	}

	/**
	 * Return the configured app start URL.
	 *
	 * @param string $language Target language code.
	 */
	private static function get_start_url( string $language = '' ): string {
		$language = '' !== $language ? $language : Foodord_Pwa_Language::get_current_language();
		$page_id  = absint( get_option( self::OPTION_START_PAGE_ID, 0 ) );
		$page_id  = Foodord_Pwa_Language::translate_post_id( $page_id, $language );
		if ( $page_id > 0 && 'page' === get_post_type( $page_id ) && 'publish' === get_post_status( $page_id ) ) {
			$url = get_permalink( $page_id );
			if ( is_string( $url ) && '' !== $url ) {
				return $url;
			}
		}

		$order_page_id = absint( get_option( 'foodord_onboarding_order_page_id', 0 ) );
		$order_page_id = Foodord_Pwa_Language::translate_post_id( $order_page_id, $language );
		if ( $order_page_id > 0 && 'publish' === get_post_status( $order_page_id ) ) {
			$url = get_permalink( $order_page_id );
			if ( is_string( $url ) && '' !== $url ) {
				return $url;
			}
		}

		return Foodord_Pwa_Language::get_language_home_url( $language );
	}

	/**
	 * Return manifest shortcuts for available customer pages.
	 *
	 * @param string $language Target language code.
	 * @return array<int, array<string, string>>
	 */
	private static function get_shortcuts( string $language ): array {
		$shortcuts = array(
			array(
				'name'       => __( 'Order food', 'foodord' ),
				'short_name' => __( 'Order', 'foodord' ),
				'url'        => self::get_start_url( $language ),
			),
		);

		if ( class_exists( 'Foodord_Table_Reservations' ) ) {
			$reservation_url = Foodord_Table_Reservations::get_public_reservations_page_url();
			if ( '' !== $reservation_url ) {
				$shortcuts[] = array(
					'name'       => __( 'Reserve a table', 'foodord' ),
					'short_name' => __( 'Reservations', 'foodord' ),
					'url'        => Foodord_Pwa_Language::localize_url( $reservation_url, $language ),
				);
			}
		}

		if ( function_exists( 'wc_get_page_permalink' ) ) {
			$account_url = wc_get_page_permalink( 'myaccount' );
			if ( is_string( $account_url ) && '' !== $account_url ) {
				$shortcuts[] = array(
					'name'       => __( 'My account', 'foodord' ),
					'short_name' => __( 'Account', 'foodord' ),
					'url'        => Foodord_Pwa_Language::localize_url( $account_url, $language ),
				);
			}
		}

		return $shortcuts;
	}

	/**
	 * Return safe static assets to pre-cache.
	 *
	 * @param array<int, array{code:string,homeUrl:string,offlineUrl:string}> $offline_pages Localized offline pages.
	 * @return string[]
	 */
	private static function get_precache_urls( array $offline_pages ): array {
		$urls = array(
			FOODORD_URL . 'public/css/foodord-pwa.css',
			FOODORD_URL . 'public/css/foodord-pwa-offline.css',
			FOODORD_URL . 'public/js/foodord-pwa.js',
		);

		foreach ( $offline_pages as $page ) {
			if ( ! empty( $page['offlineUrl'] ) ) {
				$urls[] = $page['offlineUrl'];
			}
		}

		foreach ( self::get_manifest_icons() as $icon ) {
			if ( ! empty( $icon['src'] ) ) {
				$urls[] = $icon['src'];
			}
		}

		return array_values( array_unique( array_map( 'esc_url_raw', $urls ) ) );
	}

	/**
	 * Return localized offline pages available on the current PWA origin.
	 *
	 * @return array<int, array{code:string,homeUrl:string,offlineUrl:string}>
	 */
	private static function get_offline_pages(): array {
		$runtime_origin = self::get_url_origin( self::get_runtime_base_url() );
		$pages          = array();

		foreach ( Foodord_Pwa_Language::get_active_languages() as $language ) {
			$home_url = $language['home_url'];
			if ( self::get_url_origin( $home_url ) !== $runtime_origin ) {
				continue;
			}

			$offline_args = array( 'foodord_pwa_offline' => '1' );
			$offline_args[ Foodord_Pwa_Language::get_query_arg() ] = $language['code'];

			$pages[] = array(
				'code'       => $language['code'],
				'homeUrl'    => esc_url_raw( $home_url ),
				'offlineUrl' => esc_url_raw(
					add_query_arg(
						$offline_args,
						self::get_runtime_base_url()
					)
				),
			);
		}

		if ( empty( $pages ) ) {
			$language     = Foodord_Pwa_Language::get_current_language();
			$offline_args = array( 'foodord_pwa_offline' => '1' );
			$offline_args[ Foodord_Pwa_Language::get_query_arg() ] = $language;
			$pages[] = array(
				'code'       => $language,
				'homeUrl'    => esc_url_raw( home_url( '/' ) ),
				'offlineUrl' => esc_url_raw(
					add_query_arg(
						$offline_args,
						self::get_runtime_base_url()
					)
				),
			);
		}

		return $pages;
	}

	/**
	 * Return the root URL where root-scoped PWA endpoints are served.
	 */
	private static function get_runtime_base_url(): string {
		$language_home = wp_parse_url( home_url( '/' ) );
		$configured    = wp_parse_url( (string) get_option( 'home', home_url( '/' ) ) );
		if ( ! is_array( $language_home ) || empty( $language_home['host'] ) || ! is_array( $configured ) ) {
			return trailingslashit( home_url( '/' ) );
		}

		$scheme = isset( $language_home['scheme'] ) ? $language_home['scheme'] : ( is_ssl() ? 'https' : 'http' );
		$port   = isset( $language_home['port'] ) ? ':' . absint( $language_home['port'] ) : '';
		$path   = isset( $configured['path'] ) ? trailingslashit( $configured['path'] ) : '/';

		return $scheme . '://' . $language_home['host'] . $port . $path;
	}

	/**
	 * Return a normalized URL origin.
	 *
	 * @param string $url URL to inspect.
	 */
	private static function get_url_origin( string $url ): string {
		$parts = wp_parse_url( $url );
		if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {
			return '';
		}

		$scheme = isset( $parts['scheme'] ) ? $parts['scheme'] : 'https';
		$port   = isset( $parts['port'] ) ? ':' . absint( $parts['port'] ) : '';

		return strtolower( $scheme . '://' . $parts['host'] . $port );
	}

	/**
	 * Build a cache name that changes with plugin and PWA settings revisions.
	 *
	 * @param string $segment Cache segment name.
	 */
	private static function get_cache_name( string $segment ): string {
		$version  = sanitize_key( str_replace( '.', '-', FOODORD_VERSION ) );
		$revision = absint( get_option( self::OPTION_REVISION, 1 ) );

		return 'foodord-pwa-' . $version . '-' . $revision . '-' . sanitize_key( $segment );
	}

	/**
	 * Keep install promotion away from checkout and tokenized customer flows.
	 */
	private static function is_transactional_screen(): bool {
		if ( ( function_exists( 'is_cart' ) && is_cart() ) || ( function_exists( 'is_checkout' ) && is_checkout() ) ) {
			return true;
		}

		if ( function_exists( 'is_account_page' ) && is_account_page() ) {
			return true;
		}

		$blocked_keys = array(
			'foodord_reservation_manage',
			'foodord_reservation_token',
			'foodord_reservation_preorder_edit',
			'foodord_reservation_preorder_key',
			'foodord_pro_review_token',
			'key',
			'pay_for_order',
		);

		foreach ( $blocked_keys as $key ) {
			if ( null !== filter_input( INPUT_GET, $key ) ) {
				return true;
			}
		}

		return false;
	}
}
