<?php
/**
 * Template loader.
 *
 * Overrides WordPress template hierarchy to load builder pages
 * with their custom templates, headers, and footers.
 *
 * @package Antimanual_Builder
 * @since   1.0.0
 */

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Class AMBLD_Render_Template_Loader
 *
 * Handles frontend template loading for builder pages.
 *
 * @since 1.0.0
 */
class AMBLD_Render_Template_Loader {

	/**
	 * Constructor. Registers template loading hooks.
	 *
	 * @since 1.0.0
	 */
	public function __construct() {
		add_filter( 'template_include', array( $this, 'load_page_template' ) );
		add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_frontend_assets' ) );
		add_action( 'save_post_' . AMBLD_Post_Type_Builder_Template::POST_TYPE, array( $this, 'handle_template_saved' ), 10, 3 );
		// Late-priority isolation pass — runs after every other plugin and
		// the active theme have had a chance to enqueue their styles.
		// Drops everything that isn't an AMB-prefixed handle so builder
		// pages render with the exact CSS the editor previewed, free of
		// third-party / theme style conflicts.
		add_action( 'wp_enqueue_scripts', array( $this, 'isolate_frontend_styles' ), 9999 );
		// Belt-and-suspenders: catch styles that other plugins enqueue
		// inside `wp_print_styles` (after wp_enqueue_scripts has finished).
		add_action( 'wp_print_styles', array( $this, 'isolate_frontend_styles' ), 9999 );
		// Print AM Builder stylesheets LAST so builder CSS wins source-order
		// ties against any theme/plugin CSS that survives (e.g. on default /
		// canvas modes where the isolation pass is intentionally skipped).
		// Pairs with the section-scoped selectors the generator emits (which
		// win on specificity). Runs at 10000 so it reorders the final queue
		// after the isolation pass has trimmed it.
		add_action( 'wp_enqueue_scripts', array( $this, 'prioritize_frontend_styles' ), 10000 );
		add_action( 'wp_print_styles', array( $this, 'prioritize_frontend_styles' ), 10000 );
	}

	/**
	 * Load custom template for builder pages.
	 *
	 * @since  1.0.0
	 * @param  string $template Default template path.
	 * @return string Modified template path.
	 */
	public function load_page_template( $template ) {
		global $post;

		if ( ! $post ) {
			return $template;
		}

		if ( ! $this->is_builder_enabled_post( $post ) ) {
			return $template;
		}

		// Decision tree on the user's chosen page template:
		//   - 'default'     → defer to WordPress / theme template hierarchy.
		//   - '<file>.php'  → defer to WordPress so `_wp_page_template` drives.
		//   - 'canvas'/''   → builder's canvas template (current default).
		//   - 'full-width'  → canvas template (the body class is added in page.php).
		//
		// Theme-driven modes ('default' / '.php') take precedence over the
		// standalone clone below: selecting a theme template intentionally opts
		// an imported page OUT of verbatim standalone rendering so the active
		// theme's header/footer wrap the content (the builder HTML is injected
		// via the `the_content` filter in AMBLD_Page_Integration). The builder's
		// own header/footer templates are not rendered in this mode — the theme
		// owns that chrome.
		$mode         = $this->resolve_page_template_mode( $post );
		$theme_driven = ( 'default' === $mode || self::is_php_template_file( $mode ) );
		if ( $theme_driven ) {
			return $template;
		}

		// Faithfully-cloned imported pages render as a standalone document with
		// no theme wrapper and no wp_head()/wp_footer(). Because those hooks
		// never fire, the template emits the page's CSS/JS directly — so make
		// sure the generated assets exist before handing off. Reached only for
		// builder-owned modes (canvas / full-width); a theme selection above
		// already took the theme path.
		if ( $this->is_standalone_render( $post ) ) {
			$standalone = AMBLD_PLUGIN_DIR . 'templates/frontend/page-standalone.php';
			if ( file_exists( $standalone ) ) {
				$this->maybe_generate_page_assets( $post->ID );
				return $standalone;
			}
		}

		$custom_template = AMBLD_PLUGIN_DIR . 'templates/frontend/page.php';
		if ( file_exists( $custom_template ) ) {
			return $custom_template;
		}

		return $template;
	}

	/**
	 * Read the chosen page-template mode from `_ambld_page_settings`.
	 *
	 * @param  WP_Post $post Builder page post.
	 * @return string One of `default`, `canvas`, `full-width`, or a `.php` filename. Falls back to `canvas` (the legacy implicit default).
	 */
	public static function resolve_page_template_mode( $post ) {
		if ( ! ( $post instanceof \WP_Post ) ) {
			return 'canvas';
		}
		$settings = json_decode( (string) get_post_meta( $post->ID, '_ambld_page_settings', true ), true );
		if ( ! is_array( $settings ) ) {
			return 'canvas';
		}
		$mode = isset( $settings['pageTemplate'] ) ? (string) $settings['pageTemplate'] : '';
		if ( '' === $mode ) {
			return 'canvas';
		}
		if ( in_array( $mode, array( 'default', 'canvas', 'full-width' ), true ) ) {
			return $mode;
		}
		if ( self::is_php_template_file( $mode ) ) {
			return $mode;
		}
		return 'canvas';
	}

	private static function is_php_template_file( $value ) {
		return is_string( $value ) && '.php' === substr( $value, -4 );
	}

	/**
	 * Whether a page should render as a faithful standalone document.
	 *
	 * Set on imported pages (`pageSettings.renderMode === 'standalone'`); the
	 * standalone template emits the original document verbatim without the
	 * theme wrapper.
	 *
	 * @param  WP_Post $post Builder page post.
	 * @return bool
	 */
	private function is_standalone_render( $post ) {
		if ( ! ( $post instanceof \WP_Post ) ) {
			return false;
		}
		$settings = json_decode( (string) get_post_meta( $post->ID, '_ambld_page_settings', true ), true );
		return is_array( $settings ) && isset( $settings['renderMode'] ) && 'standalone' === $settings['renderMode'];
	}

	/**
	 * Enqueue frontend assets for builder pages.
	 *
	 * @since  1.0.0
	 * @return void
	 */
	public function enqueue_frontend_assets() {
		global $post;

		if ( ! $post ) {
			return;
		}

		if ( ! $this->is_builder_enabled_post( $post ) ) {
			return;
		}

		$this->maybe_generate_page_assets( $post->ID );
		$is_imported_page = $this->is_imported_project_page( $post->ID );

		// External CDN resources attached in the editor (CDN tab / AI). CSS
		// links always load; CDN <script src> execution is gated on the same
		// unfiltered_html authorization as custom JS. CSS handles become deps
		// of the page CSS (so page styles win); JS handles become deps of the
		// page JS (so libraries load before the page's own code).
		$cdn_css_handles = array();
		$cdn_js_handles  = array();
		$cdn_raw         = get_post_meta( $post->ID, '_ambld_cdn_resources', true );
		if ( ! empty( $cdn_raw ) ) {
			$cdn_resources = json_decode( $cdn_raw, true );
			if ( is_array( $cdn_resources ) ) {
				$cdn_js_allowed = ambld_unfiltered_authorized( $post->ID, '_ambld_cdn_resources_authorized_by' );
				foreach ( $cdn_resources as $i => $res ) {
					if ( ! is_array( $res ) || empty( $res['url'] ) ) {
						continue;
					}
					$res_type = isset( $res['type'] ) ? $res['type'] : '';
					if ( 'css' === $res_type ) {
						$handle = 'ambld-cdn-css-' . $post->ID . '-' . $i;
						wp_enqueue_style( $handle, $res['url'], array(), null );
						$cdn_css_handles[] = $handle;
					} elseif ( 'js' === $res_type && $cdn_js_allowed ) {
						$handle    = 'ambld-cdn-js-' . $post->ID . '-' . $i;
						$in_footer = ( isset( $res['position'] ) && 'body' === $res['position'] );
						wp_enqueue_script( $handle, $res['url'], array(), null, $in_footer );
						if ( ! empty( $res['async'] ) ) {
							wp_script_add_data( $handle, 'strategy', 'async' );
						} elseif ( ! empty( $res['defer'] ) ) {
							wp_script_add_data( $handle, 'strategy', 'defer' );
						}
						$cdn_js_handles[] = $handle;
					}
				}
			}
		}

		if ( ! $is_imported_page ) {
			// Base frontend CSS is useful for native builder pages, but imported
			// HTML projects already ship their own document-level styling.
			wp_enqueue_style(
				'ambld-frontend',
				AMBLD_PLUGIN_URL . 'assets/css/frontend.css',
				array(),
				AMBLD_VERSION
			);
		}

		// Page-specific CSS from uploads.
		$page_css = $this->get_asset_url( $post->ID, 'css' );
		if ( $page_css ) {
			wp_enqueue_style(
				'ambld-page-' . $post->ID,
				$page_css['url'],
				array_merge(
					$is_imported_page ? array() : array( 'ambld-frontend' ),
					$cdn_css_handles
				),
				$page_css['version']
			);
		}

		// Imported-page JS. A ZIP import homes ALL of its scripts in the
		// `_ambld_imported_scripts` manifest (inline blocks, the page's own local
		// script files, and remote/CDN/module references — each preserving its
		// original head/body position and load attributes). The standalone
		// clone template reads that manifest directly; on this theme-template
		// path wp_head()/wp_footer() fire, so we print the SAME tags there via
		// the shared emitter. Without this, switching an imported page to a
		// theme template dropped every inline and local script and degraded
		// module scripts (the path used to read only the remote-only
		// `_ambld_imported_js_urls` subset).
		$import_handles = array();
		$manifest_raw   = get_post_meta( $post->ID, '_ambld_imported_scripts', true );
		$has_manifest   = false;
		if ( ! empty( $manifest_raw ) ) {
			$decoded_manifest = json_decode( (string) $manifest_raw, true );
			$has_manifest     = is_array( $decoded_manifest ) && ! empty( $decoded_manifest );
		}

		if ( $has_manifest ) {
			$this->register_imported_script_manifest_output( $post->ID );
		} else {
			// Legacy fallback: pages imported before the per-script manifest
			// existed only carry their remote/CDN references in
			// `_ambld_imported_js_urls`. Enqueued BEFORE the page's own JS so that
			// page code depending on these libraries finds them defined; loaded
			// in the footer so the DOM is ready when they execute.
			$imported_js_urls_raw = get_post_meta( $post->ID, '_ambld_imported_js_urls', true );
			if ( ! empty( $imported_js_urls_raw ) ) {
				$imported_js_urls = json_decode( $imported_js_urls_raw, true );
				if ( is_array( $imported_js_urls ) ) {
					foreach ( $imported_js_urls as $idx => $js_url ) {
						if ( is_string( $js_url ) && '' !== $js_url ) {
							$handle = 'ambld-import-js-' . $post->ID . '-' . $idx;
							wp_enqueue_script(
								$handle,
								$js_url,
								array(),
								null, // version handled by filename hash from ZIP
								true
							);
							$import_handles[] = $handle;
						}
					}
				}
			}
		}

		// Page-specific JS (block interactivity + the page's own inline/local
		// JS, compiled from customJs). Declared dependent on the imported library
		// handles so it always loads after them.
		$page_js = $this->get_asset_url( $post->ID, 'js' );
		if ( $page_js ) {
			wp_enqueue_script(
				'ambld-page-' . $post->ID,
				$page_js['url'],
				array_merge( $import_handles, $cdn_js_handles ),
				$page_js['version'],
				true
			);
		}

		$template_payloads = array(
			AMBLD_Render_Template_Resolver::resolve( $post->ID, 'header' ),
			AMBLD_Render_Template_Resolver::resolve( $post->ID, 'footer' ),
		);
		$enqueued_template_ids = array();
		$template_style_deps   = $is_imported_page ? array() : array( 'ambld-frontend' );

		foreach ( $template_payloads as $payload ) {
			if ( ! is_array( $payload ) || empty( $payload['id'] ) ) {
				continue;
			}

			$template_id = absint( $payload['id'] );
			if ( $template_id <= 0 || isset( $enqueued_template_ids[ $template_id ] ) ) {
				continue;
			}

			$enqueued_template_ids[ $template_id ] = true;

			$template_css = $this->get_asset_url( $template_id, 'css' );
			if ( $template_css ) {
				wp_enqueue_style(
					'ambld-tpl-' . $template_id,
					$template_css['url'],
					$template_style_deps,
					$template_css['version']
				);
			}

			$template_js = $this->get_asset_url( $template_id, 'js' );
			if ( $template_js ) {
				wp_enqueue_script(
					'ambld-tpl-' . $template_id,
					$template_js['url'],
					array(),
					$template_js['version'],
					true
				);
			}
		}

	}

	/**
	 * Print an imported page's `_ambld_imported_scripts` manifest on the
	 * theme-template render path.
	 *
	 * Imported pages home all their JS in the manifest, which only the
	 * standalone clone template reads directly. When the page is switched to a
	 * theme `.php`/Default template it renders through wp_head()/wp_footer()
	 * instead, so the manifest's scripts have to be printed on those hooks.
	 * We emit the SAME tags the standalone template produces (shared emitter
	 * AMBLD_Render_Pipeline::imported_script_tags), at the matching document
	 * position: head-position scripts on wp_head, body-position scripts in the
	 * footer ahead of the page's own compiled JS (mirrors the standalone
	 * template's ordering, where the manifest's body scripts precede the
	 * `customJs` layer).
	 *
	 * @since  2.3.0
	 * @param  int $post_id Page post ID.
	 * @return void
	 */
	private function register_imported_script_manifest_output( $post_id ) {
		$post_id = (int) $post_id;

		// Head-position scripts. Priority 20 keeps them after core's
		// wp_print_head_scripts (priority 9) and the page's enqueued styles.
		add_action(
			'wp_head',
			static function () use ( $post_id ) {
				if ( ! class_exists( 'AMBLD_Render_Pipeline' ) ) {
					return;
				}
				$tags = AMBLD_Render_Pipeline::imported_script_tags( $post_id );
				if ( empty( $tags['head'] ) ) {
					return;
				}
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- faithful clone: tags are built + escaped by the shared emitter and capability-gated there.
				echo "\n" . implode( "\n", $tags['head'] ) . "\n";
			},
			20
		);

		// Body-position scripts. Priority 5 prints them before
		// wp_print_footer_scripts (priority 20), so the page's compiled JS
		// (`ambld-page-<id>`, which carries the customJs layer) still runs last.
		add_action(
			'wp_footer',
			static function () use ( $post_id ) {
				if ( ! class_exists( 'AMBLD_Render_Pipeline' ) ) {
					return;
				}
				$tags = AMBLD_Render_Pipeline::imported_script_tags( $post_id );
				if ( empty( $tags['body'] ) ) {
					return;
				}
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- faithful clone: tags are built + escaped by the shared emitter and capability-gated there.
				echo "\n" . implode( "\n", $tags['body'] ) . "\n";
			},
			5
		);
	}

	/**
	 * Handle template saves by invalidating dependent list/version caches.
	 *
	 * @since  1.0.0
	 * @param  int      $post_id Template post ID.
	 * @param  WP_Post  $post    Saved post.
	 * @param  bool     $update  Whether this is an existing post update.
	 * @return void
	 */
	public function handle_template_saved( $post_id, $post, $update ) {
		if ( wp_is_post_revision( $post_id ) ) {
			return;
		}

		if ( ! ( $post instanceof \WP_Post ) || AMBLD_Post_Type_Builder_Template::POST_TYPE !== $post->post_type ) {
			return;
		}

		update_option( 'ambld_pages_list_cache_version', time(), false );
		AMBLD_Render_Template_Resolver::flush_template_cache( $post_id );
	}

	/**
	 * Dequeue every style handle that doesn't belong to AM Builder when
	 * we're rendering a builder page.
	 *
	 * The custom `ambld-page` template renders pages without theme chrome,
	 * so theme stylesheets and other plugins' CSS only ever cause visual
	 * conflicts on builder pages — they bring resets, color tokens, font
	 * stacks, and utility classes that override the AI-generated design.
	 *
	 * Allowlist (everything kept):
	 *   - Any handle that starts with `ambld-` (our own)
	 *   - Anything explicitly returned by the
	 *     `ambld_frontend_allowed_style_handles` filter
	 *
	 * Master toggle: filter `ambld_frontend_isolate_styles` to `false` to
	 * disable the entire isolation pass on a per-request or per-page
	 * basis (e.g. some sites that intentionally rely on theme tokens).
	 *
	 * @since  1.5.0
	 * @return void
	 */
	public function isolate_frontend_styles() {
		global $post, $wp_styles;

		// Frontend-only — the editor, the pages list, every admin screen
		// and every wp-admin REST request must keep its full set of
		// styles. `wp_enqueue_scripts` doesn't fire in admin but
		// `wp_print_styles` does, so we need this guard for the second
		// hook to stay safe.
		if ( is_admin() ) {
			return;
		}

		// Skip during AJAX, REST and CLI contexts so we never strip
		// styles from JSON responses, REST views, or background jobs
		// that might piggyback on the wp_print_styles cycle.
		if ( ( defined( 'DOING_AJAX' ) && DOING_AJAX )
			|| ( defined( 'REST_REQUEST' ) && REST_REQUEST )
			|| ( defined( 'WP_CLI' ) && WP_CLI ) ) {
			return;
		}

		if ( ! $post || ! $this->is_builder_enabled_post( $post ) ) {
			return;
		}

		// For all builder-managed modes (canvas / full-width) as well as
		// theme-driven modes (default / .php), wp_head must fire normally
		// so that theme fonts, plugin CSS, and hook callbacks are not
		// stripped. Style isolation is available via the
		// `ambld_frontend_isolate_styles` filter for sites that opt in.
		$mode = self::resolve_page_template_mode( $post );
		if ( 'default' === $mode || 'canvas' === $mode || 'full-width' === $mode || self::is_php_template_file( $mode ) ) {
			return;
		}

		/**
		 * Allow opting out of the third-party style isolation pass.
		 *
		 * @param bool    $isolate Whether to dequeue non-AMB styles. Default true.
		 * @param WP_Post $post    The current post being rendered.
		 */
		$isolate = apply_filters( 'ambld_frontend_isolate_styles', true, $post );
		if ( ! $isolate ) {
			return;
		}

		if ( ! ( $wp_styles instanceof \WP_Styles ) ) {
			return;
		}

		// Always-keep core handles. The WordPress admin bar — when it's
		// showing for a logged-in user — needs `admin-bar` + `dashicons`
		// to render correctly. Without them the floating toolbar is an
		// unstyled list at the top of the page. This is independent of
		// the third-party isolation goal: the admin bar is core UI, not
		// a plugin asset.
		$core_allowed = array();
		if ( function_exists( 'is_admin_bar_showing' ) && is_admin_bar_showing() ) {
			$core_allowed[] = 'admin-bar';
			$core_allowed[] = 'dashicons';
			// Some block themes additionally enqueue these via the
			// admin-bar dependency chain.
			$core_allowed[] = 'open-sans';
		}

		/**
		 * Whitelist additional style handles that should survive the
		 * isolation pass. Useful when a builder page genuinely depends
		 * on a third-party stylesheet (e.g. a payment-processor SDK).
		 *
		 * @param string[] $handles Style handles to keep.
		 * @param WP_Post  $post    The current post.
		 */
		$extra_allowed = (array) apply_filters(
			'ambld_frontend_allowed_style_handles',
			array(),
			$post
		);
		$extra_lookup = array_flip( array_merge( $core_allowed, $extra_allowed ) );

		// `queue` holds the handles that are about to print; we copy it
		// because `wp_dequeue_style` mutates the array we're iterating.
		$queue = array_values( (array) $wp_styles->queue );

		foreach ( $queue as $handle ) {
			$handle = (string) $handle;
			if ( '' === $handle ) {
				continue;
			}
			// Keep our own handles (e.g. `ambld-frontend`, `ambld-page-123`).
			if ( 0 === strpos( $handle, 'ambld-' ) ) {
				continue;
			}
			// Keep anything explicitly whitelisted.
			if ( isset( $extra_lookup[ $handle ] ) ) {
				continue;
			}
			wp_dequeue_style( $handle );
		}
	}

	/**
	 * Move every AM Builder style handle to the END of the print queue so the
	 * builder's CSS is emitted after any theme / plugin stylesheets that are
	 * still on the page.
	 *
	 * The isolation pass ({@see isolate_frontend_styles()}) only strips
	 * third-party styles for a subset of template modes; on `default` /
	 * `canvas` / `full-width` / `.php` modes it deliberately bows out so theme
	 * fonts and hook callbacks keep working. On those pages theme + plugin CSS
	 * coexists with builder CSS, and a same-specificity rule would be decided
	 * by source order. Printing our handles last makes the builder design win
	 * those ties — together with the section-scoped selectors the generator
	 * emits (which win on specificity), builder CSS is never silently
	 * overridden.
	 *
	 * Unlike the isolation pass this runs on ALL builder modes (no mode gate),
	 * and it only re-orders — it never removes a handle — so it is safe even
	 * when style isolation is disabled.
	 *
	 * @since  2.2.0
	 * @return void
	 */
	public function prioritize_frontend_styles() {
		global $post, $wp_styles;

		// Frontend page renders only — never reorder admin / AJAX / REST / CLI
		// style queues (mirrors the isolation pass guards).
		if ( is_admin() ) {
			return;
		}
		if ( ( defined( 'DOING_AJAX' ) && DOING_AJAX )
			|| ( defined( 'REST_REQUEST' ) && REST_REQUEST )
			|| ( defined( 'WP_CLI' ) && WP_CLI ) ) {
			return;
		}

		if ( ! $post || ! $this->is_builder_enabled_post( $post ) ) {
			return;
		}

		if ( ! ( $wp_styles instanceof \WP_Styles ) ) {
			return;
		}

		/**
		 * Allow opting out of moving builder styles to the end of the queue.
		 *
		 * @since 2.2.0
		 *
		 * @param bool    $prioritize Whether to print ambld- styles last. Default true.
		 * @param WP_Post $post       The current post being rendered.
		 */
		if ( ! apply_filters( 'ambld_frontend_prioritize_styles', true, $post ) ) {
			return;
		}

		$amb   = array();
		$rest  = array();
		foreach ( array_values( (array) $wp_styles->queue ) as $handle ) {
			$handle = (string) $handle;
			if ( '' === $handle ) {
				continue;
			}
			if ( 0 === strpos( $handle, 'ambld-' ) ) {
				$amb[] = $handle;
			} else {
				$rest[] = $handle;
			}
		}

		// Nothing of ours queued, or nothing to jump ahead of — leave it alone.
		if ( empty( $amb ) || empty( $rest ) ) {
			return;
		}

		// ambld- handles depend only on other ambld- handles, so re-grouping them
		// at the tail preserves their internal dependency order while moving
		// the whole group after every third-party stylesheet.
		$wp_styles->queue = array_merge( $rest, $amb );
	}

	/**
	 * Get asset URL and version from database.
	 *
	 * @since  1.0.0
	 * @param  int    $post_id Post ID.
	 * @param  string $type    Asset type (css or js).
	 * @return array|false Asset info or false.
	 */
	private function get_asset_url( $post_id, $type ) {
		return self::get_asset_descriptor( $post_id, $type );
	}

	/**
	 * Resolve the generated asset URL + version for a page or template post.
	 *
	 * Public + static so render contexts that bypass the normal enqueue
	 * pipeline can emit the assets directly. The standalone clone template
	 * (page-standalone.php) never calls wp_head()/wp_footer(), so header/footer
	 * template CSS/JS that would normally be enqueued has to be printed inline
	 * via this descriptor instead.
	 *
	 * @since  1.0.0
	 * @param  int    $post_id Page or template post ID.
	 * @param  string $type    Asset type (css or js).
	 * @return array{url:string,version:string}|false Asset info or false.
	 */
	public static function get_asset_descriptor( $post_id, $type ) {
		if ( 'css' === $type ) {
			foreach ( self::get_generated_css_candidates( $post_id ) as $asset ) {
				if ( file_exists( $asset['path'] ) ) {
					$file_mtime = filemtime( $asset['path'] );

					return array(
						'url'     => $asset['url'],
						'version' => false !== $file_mtime ? (string) $file_mtime : (string) current_time( 'timestamp' ),
					);
				}
			}
		}

		global $wpdb;
		$table = $wpdb->prefix . 'ambld_assets';

		// Guard against a missing table (e.g. fresh install where the
		// activator hasn't run yet, or accidental table drop). The main
		// plugin class recreates the table on plugins_loaded, but there is
		// a brief window on the very first request where the table may not
		// exist yet. Returning false here lets the caller fall back
		// gracefully instead of emitting a visible DB error on the frontend.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		if ( ! $wpdb->get_var( "SHOW TABLES LIKE '{$table}'" ) ) {
			return false;
		}

		$result = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT file_url, version FROM {$table} WHERE post_id = %d AND asset_type = %s",
				$post_id,
				$type
			),
			ARRAY_A
		);

		if ( ! $result ) {
			return false;
		}

		return array(
			'url'     => $result['file_url'],
			'version' => $result['version'],
		);
	}

	/**
	 * Generate page assets on demand when builder data exists but the CSS file is missing.
	 *
	 * @since  1.0.0
	 * @param  int $post_id Post ID.
	 * @return void
	 */
	private function maybe_generate_page_assets( $post_id ) {
		if ( empty( $post_id ) ) {
			return;
		}

		$css_path   = $this->get_generated_css_path( $post_id );
		$css_exists = file_exists( $css_path );

		// Canonical CSS/JS sources live in post meta — the legacy
		// `src/post-<id>.<ext>` files were removed in 2.1.0, so checking
		// those with file_exists() was always false: the JS branch could
		// never trigger, and a site restored without its generated `js/`
		// files permanently lost page interactivity until a manual re-save.
		$blocks_json   = get_post_meta( $post_id, '_ambld_blocks', true );
		$has_js_source = ! empty( $blocks_json )
			|| '' !== trim( (string) get_post_meta( $post_id, '_ambld_custom_js', true ) )
			|| '' !== trim( (string) get_post_meta( $post_id, '_ambld_imported_inline_js', true ) );

		// Check whether the compiled JS asset exists (any version hash).
		$js_asset_exists = false;
		if ( $has_js_source ) {
			global $wpdb;
			$assets_table = $wpdb->prefix . 'ambld_assets';
			// Guard against a missing table — same pattern as get_asset_descriptor().
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			if ( $wpdb->get_var( "SHOW TABLES LIKE '{$assets_table}'" ) ) {
				$js_asset_exists = (bool) $wpdb->get_var( $wpdb->prepare(
					"SELECT id FROM {$wpdb->prefix}ambld_assets WHERE post_id = %d AND asset_type = 'js'",
					$post_id
				) );
			}
		}

		// No work to do if both CSS and JS are already compiled.
		if ( $css_exists && ( ! $has_js_source || $js_asset_exists ) ) {
			return;
		}

		$has_css_source = '' !== trim( (string) get_post_meta( $post_id, '_ambld_custom_css', true ) )
			|| '' !== trim( (string) get_post_meta( $post_id, '_ambld_style_map', true ) );
		$rendered_html  = get_post_meta( $post_id, '_ambld_rendered_html', true );
		$has_inline_css = is_string( $rendered_html ) && preg_match( '/<style[^>]*>.*?<\/style>/si', $rendered_html );

		if ( empty( $blocks_json ) && ! $has_css_source && ! $has_js_source && ! $has_inline_css ) {
			return;
		}

		do_action( 'ambld_page_saved', $post_id, get_post( $post_id ) );
	}

	/**
	 * Get the deterministic generated CSS path for a page.
	 *
	 * @since  1.0.0
	 * @param  int $post_id Post ID.
	 * @return string
	 */
	private function get_generated_css_path( $post_id ) {
		$candidates = self::get_generated_css_candidates( $post_id );

		return $candidates[0]['path'];
	}

	/**
	 * Determine whether a page was imported from an external HTML project.
	 *
	 * Imported pages should render with their own CSS without the builder's
	 * base frontend stylesheet overriding document-level defaults.
	 *
	 * @since  1.0.0
	 * @param  int $post_id Post ID.
	 * @return bool
	 */
	private function is_imported_project_page( $post_id ) {
		$page_settings = json_decode( (string) get_post_meta( $post_id, '_ambld_page_settings', true ), true );

		if ( ! is_array( $page_settings ) ) {
			return false;
		}

		foreach ( array( 'importedHeadMarkup', 'importedFooterMarkup', 'importedHtmlAttributes', 'importedBodyAttributes' ) as $setting_key ) {
			if ( ! empty( $page_settings[ $setting_key ] ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Determine whether the given post should render with the builder template.
	 *
	 * @since  1.0.0
	 * @param  WP_Post|null $post Post object.
	 * @return bool
	 */
	private function is_builder_enabled_post( $post ) {
		if ( ! ( $post instanceof \WP_Post ) || ! is_singular() ) {
			return false;
		}

		if ( AMBLD_Page_Integration::POST_TYPE !== $post->post_type ) {
			return false;
		}

		$meta_keys = array(
			'_ambld_rendered_html',
			'_ambld_blocks',
		);

		foreach ( $meta_keys as $meta_key ) {
			$value = get_post_meta( $post->ID, $meta_key, true );

			if ( is_string( $value ) && '' !== trim( $value ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Get candidate CSS asset locations, preferring the documented upload path.
	 *
	 * @since 1.0.0
	 * @param int $post_id Post ID.
	 * @return array<int,array{path:string,url:string}>
	 */
	private static function get_generated_css_candidates( $post_id ) {
		$upload_dir = wp_upload_dir();

		return array(
			array(
				'path' => trailingslashit( $upload_dir['basedir'] ) . AMBLD_UPLOAD_DIR . '/css/post-' . $post_id . '.css',
				'url'  => trailingslashit( $upload_dir['baseurl'] ) . AMBLD_UPLOAD_DIR . '/css/post-' . $post_id . '.css',
			),
			array(
				'path' => trailingslashit( $upload_dir['basedir'] ) . 'am-builder/css/post-' . $post_id . '.css',
				'url'  => trailingslashit( $upload_dir['baseurl'] ) . 'am-builder/css/post-' . $post_id . '.css',
			),
		);
	}

}
