<?php
/**
 * Asset generator.
 *
 * Generates page-specific CSS and JS files and stores them
 * in the uploads directory with versioned filenames.
 *
 * @package Antimanual_Builder
 * @since   1.0.0
 */

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

/**
 * Class AMBLD_Render_Asset_Generator
 *
 * Handles generation, storage, and versioning of page-specific
 * CSS and JS assets in the uploads directory.
 *
 * @since 1.0.0
 */
class AMBLD_Render_Asset_Generator {

	/**
	 * Constructor. Hooks into page save events.
	 *
	 * @since 1.0.0
	 */
	public function __construct() {
		add_action( 'ambld_page_saved', array( $this, 'generate_assets' ), 10, 2 );
		add_action( 'deleted_post', array( $this, 'cleanup_assets_on_delete' ), 10, 2 );
	}

	/**
	 * Remove all generated asset files and tracking rows when a post is
	 * permanently deleted. Without this, `css/post-<id>.css`, the hashed
	 * `js/page-<id>-*.js` files, per-script `js/imported/page-<id>-s*-*.js`
	 * files, and their `wp_ambld_assets` rows lived forever.
	 *
	 * @since 0.4.0
	 * @param int          $post_id Deleted post ID.
	 * @param WP_Post|null $post    Deleted post object (WP 5.5+).
	 * @return void
	 */
	public function cleanup_assets_on_delete( $post_id, $post = null ) {
		$post_type = $post instanceof WP_Post ? $post->post_type : get_post_type( $post_id );
		if ( ! in_array( $post_type, array( 'page', 'ambld_template', 'ambld_component' ), true ) ) {
			return;
		}

		$this->delete_tracked_asset( $post_id, 'css' );
		$this->delete_tracked_asset( $post_id, 'js' );

		// Per-script files for imported pages (not tracked in wp_ambld_assets;
		// their URL map lives in post meta, which WP deletes with the post).
		$upload_dir = wp_upload_dir();
		$imported   = glob( trailingslashit( $upload_dir['basedir'] ) . AMBLD_UPLOAD_DIR . '/js/imported/page-' . (int) $post_id . '-s*.js' );
		foreach ( $imported ? $imported : array() as $file ) {
			wp_delete_file( $file );
		}
	}

	/**
	 * Get the source file path for a post's custom CSS or JS.
	 *
	 * Source files hold the raw (pre-merge) code authored by the AI
	 * or extracted from imported HTML. They live in a dedicated `src/`
	 * subdirectory so they don't clash with the compiled output files
	 * in `css/` and `js/`.
	 *
	 * @since  2.0.0
	 * @param  int    $post_id Post ID.
	 * @param  string $type    Asset type ('css' or 'js').
	 * @return string Absolute file path.
	 */
	public static function get_source_file_path( $post_id, $type ) {
		$upload_dir = wp_upload_dir();
		$base_dir   = trailingslashit( $upload_dir['basedir'] ) . AMBLD_UPLOAD_DIR . '/src';

		return trailingslashit( $base_dir ) . 'post-' . (int) $post_id . '.' . $type;
	}

	/**
	 * Persist a CSS source for a post.
	 *
	 * Page-scoped CSS is stored in `_ambld_custom_css` post meta and JS in
	 * `_ambld_custom_js`, rather than the legacy `src/post-<id>.<ext>` files,
	 * so they round-trip cleanly through the REST API and survive
	 * `wp_options` exports.
	 *
	 * @since 2.0.0
	 * @param int    $post_id Post ID.
	 * @param string $type    Asset type ('css' or 'js').
	 * @param string $content Source content.
	 * @return bool True when the source is accepted.
	 */
	public static function write_source_file( $post_id, $type, $content ) {
		if ( 'css' !== $type && 'js' !== $type ) {
			return true;
		}

		$meta_key = 'css' === $type ? '_ambld_custom_css' : '_ambld_custom_js';

		$content = is_string( $content ) ? trim( $content ) : '';
		if ( '' === $content ) {
			delete_post_meta( $post_id, $meta_key );
			return true;
		}

		update_post_meta( $post_id, $meta_key, wp_slash( $content ) );
		return true;
	}

	/**
	 * Read the source file (or meta-backed equivalent) for a post.
	 *
	 * Page-scoped CSS (`type === 'css'`) lives in `_ambld_custom_css` and JS
	 * (`type === 'js'`) in `_ambld_custom_js` post meta; both are returned
	 * as-is. Any legacy on-disk source file is deleted on first access.
	 *
	 * @since 2.0.0
	 * @param int    $post_id Post ID.
	 * @param string $type    Asset type ('css' or 'js').
	 * @return string Stored source, or empty string.
	 */
	public static function read_source_file( $post_id, $type ) {
		$filepath = self::get_source_file_path( $post_id, $type );

		if ( file_exists( $filepath ) ) {
			wp_delete_file( $filepath );
		}

		if ( 'css' === $type ) {
			return (string) get_post_meta( $post_id, '_ambld_custom_css', true );
		}

		return (string) get_post_meta( $post_id, '_ambld_custom_js', true );
	}

	/**
	 * Delete any leftover legacy source files for a post.
	 *
	 * @since 2.0.0
	 * @param int $post_id Post ID.
	 * @return void
	 */
	public static function delete_source_files( $post_id ) {
		foreach ( array( 'css', 'js' ) as $type ) {
			$filepath = self::get_source_file_path( $post_id, $type );
			if ( file_exists( $filepath ) ) {
				wp_delete_file( $filepath );
			}
		}
	}

	/**
	 * No-op kept for backward compatibility — there are no source files to copy.
	 *
	 * @since      2.0.0
	 * @deprecated 2.1.0
	 * @param      int $source_post_id Ignored.
	 * @param      int $target_post_id Ignored.
	 * @return     void
	 */
	public static function copy_source_files( $source_post_id, $target_post_id ) {
		unset( $source_post_id, $target_post_id );
	}

	/**
	 * Generate CSS and JS assets for a specific page.
	 *
	 * @since  1.0.0
	 * @param  int     $post_id Post ID.
	 * @param  WP_Post $post    Post object.
	 * @return void
	 */
	public function generate_assets( $post_id, $post = null ) {
		$this->generate_css( $post_id );
		$this->generate_js( $post_id );
		$this->generate_imported_script_files( $post_id );
	}

	/**
	 * Generate page-specific CSS file.
	 *
	 * @since  1.0.0
	 * @param  int $post_id Post ID.
	 * @return bool True on success.
	 */
	public function generate_css( $post_id ) {
		// Defensive: remove any legacy custom CSS/JS source files that may
		// still exist on disk from versions of the plugin prior to 2.1.0.
		self::delete_source_files( $post_id );

		$blocks_json   = get_post_meta( $post_id, '_ambld_blocks', true );
		$rendered_html = get_post_meta( $post_id, '_ambld_rendered_html', true );
		$custom_css    = (string) get_post_meta( $post_id, '_ambld_custom_css', true );

		$css = '';

		// Generate CSS from blocks.
		if ( ! empty( $blocks_json ) ) {
			$blocks = json_decode( $blocks_json, true );
			if ( is_array( $blocks ) ) {
				$css .= $this->blocks_to_css( $blocks );
			}
		}

		// Page-scoped CSS extracted from the AI-generated <style> block.
		// Saved via class-pages-api's extract_styles_from_html flow and
		// sanitized by sanitize_builder_css_value in the meta trait.
		if ( '' !== $custom_css ) {
			$css .= "\n/* Page Styles */\n" . $custom_css . "\n";
		}

		$legacy_inline_css = $this->extract_inline_css( $rendered_html );
		if ( ! empty( $legacy_inline_css ) ) {
			$css .= "\n" . $legacy_inline_css;
		}

		// Generate CSS from styleMap JSON — shared emitter (no esc_attr:
		// HTML-entity-escaping selectors/values inside a .css file turned
		// `.hero > .title` into the dead rule `.hero &gt; .title` on the
		// live frontend only, while the canvas rendered it raw).
		$style_map_css = AMBLD_Render_Pipeline::style_map_css( $post_id );
		if ( '' !== $style_map_css ) {
			$css .= "\n/* Element Styles */\n" . $style_map_css;
		}

		if ( empty( $css ) ) {
			$this->delete_tracked_asset( $post_id, 'css' );
			return false;
		}

		// Minify CSS.
		$css = $this->minify_css( $css );

		if ( '' === $css ) {
			$this->delete_tracked_asset( $post_id, 'css' );
			return false;
		}

		// Write file.
		$result = $this->write_asset_file( $post_id, 'css', $css );
		return (bool) $result;
	}

	/**
	 * Generate page-specific JS file.
	 *
	 * @since  1.0.0
	 * @param  int $post_id Post ID.
	 * @return bool True on success.
	 */
	public function generate_js( $post_id ) {
		$blocks_json = get_post_meta( $post_id, '_ambld_blocks', true );
		$custom_js   = '';

		// Author/AI JS only compiles into the served asset while its saver
		// still holds `unfiltered_html` — the same gate the standalone
		// template applies (`ambld_unfiltered_authorized`). Without it, any
		// `edit_post` user could ship arbitrary JS to every visitor.
		if ( ambld_unfiltered_authorized( $post_id, '_ambld_custom_js_authorized_by' ) ) {
			$custom_js = (string) get_post_meta( $post_id, '_ambld_custom_js', true );
		}

		// Legacy fallback: pages imported before custom JS was unified into
		// `_ambld_custom_js` still carry their inline JS in
		// `_ambld_imported_inline_js`, authorized at import time under the
		// imported-scripts key. Use it only when no custom JS has been
		// saved yet — the editor migrates it into `_ambld_custom_js` on first
		// save, after which this branch no longer applies.
		if ( '' === trim( $custom_js ) && ambld_unfiltered_authorized( $post_id, '_ambld_imported_scripts_authorized_by' ) ) {
			$custom_js = (string) get_post_meta( $post_id, '_ambld_imported_inline_js', true );
		}

		$js = '';

		// Generate JS from blocks — hard-coded interactivity bundled with the
		// plugin (tabs, accordion, counter, etc.).
		if ( ! empty( $blocks_json ) ) {
			$blocks = json_decode( $blocks_json, true );
			if ( is_array( $blocks ) ) {
				$js .= $this->blocks_to_js( $blocks );
			}
		}

		// Author / AI-generated / imported inline JS. Served as a static file
		// via wp_enqueue_script — never echoed inline — so no escaping or
		// kses concerns at render time.
		if ( '' !== trim( $custom_js ) ) {
			$js .= ( '' !== $js ? "\n" : '' ) . trim( $custom_js );
		}

		if ( empty( $js ) ) {
			$this->delete_tracked_asset( $post_id, 'js' );
			return false;
		}

		$result = $this->write_asset_file( $post_id, 'js', $js );
		return (bool) $result;
	}

	/**
	 * Write one served JS file per `local` entry in the imported-script
	 * manifest, so a faithfully-cloned imported page can reference each of its
	 * own script files as a separate `<script src>` (preserving defer/async/
	 * order). Files live in a dedicated `js/imported/` subdir so the main page
	 * JS cleanup glob (`js/page-<id>-*.js`) never deletes them.
	 *
	 * Returns and stores (`_ambld_imported_script_urls`) a map of manifest index
	 * → file URL, consumed by the standalone template.
	 *
	 * @since 2.2.0
	 * @param int $post_id Post ID.
	 * @return array<string,string> Map of manifest index → served file URL.
	 */
	public function generate_imported_script_files( $post_id ) {
		$upload_dir = wp_upload_dir();
		$base_dir   = trailingslashit( $upload_dir['basedir'] ) . AMBLD_UPLOAD_DIR . '/js/imported';
		$base_url   = trailingslashit( $upload_dir['baseurl'] ) . AMBLD_UPLOAD_DIR . '/js/imported';

		$manifest_raw = get_post_meta( $post_id, '_ambld_imported_scripts', true );
		$manifest     = $manifest_raw ? json_decode( (string) $manifest_raw, true ) : array();
		if ( ! is_array( $manifest ) ) {
			$manifest = array();
		}

		$existing = glob( trailingslashit( $base_dir ) . 'page-' . $post_id . '-s*-*.js' );
		$existing = is_array( $existing ) ? $existing : array();

		$url_map    = array();
		$keep_paths = array();

		foreach ( $manifest as $idx => $entry ) {
			if ( ! is_array( $entry ) || 'local' !== ( isset( $entry['kind'] ) ? $entry['kind'] : '' ) ) {
				continue;
			}
			$content = isset( $entry['content'] ) ? (string) $entry['content'] : '';
			if ( '' === trim( $content ) ) {
				continue;
			}

			if ( ! file_exists( $base_dir ) ) {
				wp_mkdir_p( $base_dir );
			}

			$hash     = substr( md5( $content ), 0, 8 );
			$filename = 'page-' . $post_id . '-s' . (int) $idx . '-' . $hash . '.js';
			$filepath = trailingslashit( $base_dir ) . $filename;

			if ( ! file_exists( $filepath ) ) {
				global $wp_filesystem;
				if ( empty( $wp_filesystem ) ) {
					require_once ABSPATH . 'wp-admin/includes/file.php';
					WP_Filesystem();
				}
				if ( $wp_filesystem ) {
					$wp_filesystem->put_contents( $filepath, $content, FS_CHMOD_FILE );
				} else {
					file_put_contents( $filepath, $content ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
				}
			}

			$url_map[ (string) $idx ] = trailingslashit( $base_url ) . $filename;
			$keep_paths[]             = $filepath;
		}

		// Remove stale per-script files (renamed via content hash or dropped).
		foreach ( $existing as $old ) {
			if ( ! in_array( $old, $keep_paths, true ) && file_exists( $old ) ) {
				wp_delete_file( $old );
			}
		}

		if ( ! empty( $url_map ) ) {
			update_post_meta( $post_id, '_ambld_imported_script_urls', wp_slash( wp_json_encode( $url_map ) ) );
		} else {
			delete_post_meta( $post_id, '_ambld_imported_script_urls' );
		}

		return $url_map;
	}

	/**
	 * Convert blocks to CSS.
	 *
	 * @since  1.0.0
	 * @param  array $blocks Block definitions.
	 * @return string Generated CSS.
	 */
	private function blocks_to_css( $blocks ) {
		$css = '';

		foreach ( $blocks as $block ) {
			if ( ! isset( $block['id'] ) || ! isset( $block['styles'] ) ) {
				continue;
			}

			$styles = $block['styles'];
			if ( ! empty( $styles ) ) {
				$css .= $this->block_styles_to_css( $block['id'], $styles );
			}

			// Process responsive styles.
			if ( isset( $block['responsiveStyles'] ) && is_array( $block['responsiveStyles'] ) ) {
				foreach ( $block['responsiveStyles'] as $breakpoint => $responsive_styles ) {
					$media_query = $this->get_media_query( $breakpoint );
					if ( $media_query && ! empty( $responsive_styles ) ) {
						$css .= $media_query . ' { ' . $this->block_styles_to_css( $block['id'], $responsive_styles ) . ' }';
					}
				}
			}

			// Process children recursively.
			if ( isset( $block['children'] ) && is_array( $block['children'] ) ) {
				$css .= $this->blocks_to_css( $block['children'] );
			}
		}

		return $css;
	}

	/**
	 * Convert block styles to CSS selector + properties.
	 *
	 * @since  1.0.0
	 * @param  string $block_id Block ID.
	 * @param  array  $styles   Style properties.
	 * @return string CSS rule.
	 */
	private function block_styles_to_css( $block_id, $styles ) {
		$props = array();
		foreach ( $styles as $prop => $value ) {
			if ( ! empty( $value ) ) {
				$css_prop = strtolower( preg_replace( '/([a-z])([A-Z])/', '$1-$2', $prop ?? '' ) );
				$props[]  = $css_prop . ': ' . $value;
			}
		}

		if ( empty( $props ) ) {
			return '';
		}

		return '[data-amb-id="' . $block_id . '"] { ' . implode( '; ', $props ) . '; } ';
	}

	/**
	 * Get media query for a breakpoint.
	 *
	 * @since  1.0.0
	 * @param  string $breakpoint Breakpoint name.
	 * @return string Media query string.
	 */
	private function get_media_query( $breakpoint ) {
		$breakpoints = array(
			'tablet'   => '@media (max-width: 1024px)',
			'mobile-l' => '@media (max-width: 768px)',
			'mobile-s' => '@media (max-width: 480px)',
		);

		return isset( $breakpoints[ $breakpoint ] ) ? $breakpoints[ $breakpoint ] : '';
	}

	/**
	 * Convert blocks to JS (for interactive blocks).
	 *
	 * @since  1.0.0
	 * @param  array $blocks Block definitions.
	 * @return string Generated JS.
	 */
	private function blocks_to_js( $blocks ) {
		$js          = '';
		$has_tabs     = false;
		$has_accordion = false;
		$has_slider   = false;
		$has_counter  = false;

		foreach ( $blocks as $block ) {
			if ( ! isset( $block['type'] ) ) {
				continue;
			}

			switch ( $block['type'] ) {
				case 'tabs':
					$has_tabs = true;
					break;
				case 'accordion':
					$has_accordion = true;
					break;
				case 'slider':
					$has_slider = true;
					break;
				case 'counter':
					$has_counter = true;
					break;
			}

			// Check children.
			if ( isset( $block['children'] ) && is_array( $block['children'] ) ) {
				$child_js = $this->blocks_to_js( $block['children'] );
				if ( ! empty( $child_js ) ) {
					$js .= $child_js;
				}
			}
		}

		// Add initialization scripts.
		if ( $has_tabs ) {
			$js .= $this->get_tabs_js();
		}

		if ( $has_accordion ) {
			$js .= $this->get_accordion_js();
		}

		if ( $has_counter ) {
			$js .= $this->get_counter_js();
		}

		return $js;
	}

	/**
	 * Get tabs initialization JS.
	 *
	 * @since  1.0.0
	 * @return string JS code.
	 */
	private function get_tabs_js() {
		return "document.querySelectorAll('.ambld-block--tabs').forEach(function(t){var n=t.querySelectorAll('.ambld-tab-btn'),p=t.querySelectorAll('.ambld-tab-panel');n.forEach(function(b,i){b.addEventListener('click',function(){n.forEach(function(x){x.classList.remove('active')});p.forEach(function(x){x.classList.remove('active')});b.classList.add('active');p[i]&&p[i].classList.add('active')})})});\n";
	}

	/**
	 * Get accordion initialization JS.
	 *
	 * @since  1.0.0
	 * @return string JS code.
	 */
	private function get_accordion_js() {
		return "document.querySelectorAll('.ambld-block--accordion .ambld-accordion-header').forEach(function(h){h.addEventListener('click',function(){var item=h.parentElement;item.classList.toggle('active')})});\n";
	}

	/**
	 * Get counter animation JS.
	 *
	 * @since  1.0.0
	 * @return string JS code.
	 */
	private function get_counter_js() {
		return "document.querySelectorAll('.ambld-block--counter').forEach(function(c){var t=parseInt(c.dataset.target)||0,d=parseInt(c.dataset.duration)||2000,s=0,inc=t/(d/16);var timer=setInterval(function(){s+=inc;if(s>=t){s=t;clearInterval(timer)}c.querySelector('.ambld-counter-value').textContent=Math.floor(s)},16)});\n";
	}

	/**
	 * Minify CSS.
	 *
	 * @since  1.0.0
	 * @param  string $css CSS string.
	 * @return string Minified CSS.
	 */
	private function minify_css( $css ) {
		// Remove comments.
		$css = preg_replace( '!/\*.*?\*/!s', '', $css );

		// Remove whitespace.
		$css = preg_replace( '/\s+/', ' ', $css );
		$css = str_replace( array( ' {', '{ ' ), '{', $css );
		$css = str_replace( array( ' }', '} ' ), '}', $css );
		$css = str_replace( '; ', ';', $css );
		$css = str_replace( ': ', ':', $css );

		return trim( $css );
	}

	/**
	 * Extract inline CSS from rendered HTML.
	 *
	 * @since  1.0.0
	 * @param  string $html Rendered HTML content.
	 * @return string Extracted CSS.
	 */
	private function extract_inline_css( $html ) {
		if ( empty( $html ) || ! is_string( $html ) ) {
			return '';
		}

		$css = '';

		if ( preg_match_all( '/<style[^>]*>(.*?)<\/style>/si', $html, $matches ) ) {
			foreach ( $matches[1] as $style_content ) {
				$css .= trim( $style_content ) . "\n";
			}
		}

		return trim( $css );
	}

	/**
	 * Write an asset file to the uploads directory.
	 *
	 * @since  1.0.0
	 * @param  int    $post_id    Post ID.
	 * @param  string $type       Asset type (css or js).
	 * @param  string $content    File content.
	 * @return array|false File info array or false on failure.
	 */
	private function write_asset_file( $post_id, $type, $content ) {
		$upload_dir = wp_upload_dir();

		if ( 'css' === $type ) {
			$base_dir = trailingslashit( $upload_dir['basedir'] ) . AMBLD_UPLOAD_DIR . '/css';
			$base_url = trailingslashit( $upload_dir['baseurl'] ) . AMBLD_UPLOAD_DIR . '/css';
			$filename = 'post-' . $post_id . '.css';
			$version  = null;
		} else {
			$base_dir = trailingslashit( $upload_dir['basedir'] ) . AMBLD_UPLOAD_DIR . '/' . $type;
			$base_url = trailingslashit( $upload_dir['baseurl'] ) . AMBLD_UPLOAD_DIR . '/' . $type;
			$version  = substr( md5( $content ), 0, 8 );
			$filename = 'page-' . $post_id . '-' . $version . '.' . $type;
		}

		if ( ! file_exists( $base_dir ) ) {
			wp_mkdir_p( $base_dir );
		}

		$filepath = trailingslashit( $base_dir ) . $filename;
		$fileurl  = trailingslashit( $base_url ) . $filename;

		// Write content to file (only when changed).
		$existing_content = file_exists( $filepath )
			? file_get_contents( $filepath ) // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
			: false;

		if ( ! file_exists( $filepath ) || $existing_content !== $content ) {
			global $wp_filesystem;
			if ( empty( $wp_filesystem ) ) {
				require_once ABSPATH . 'wp-admin/includes/file.php';
				WP_Filesystem();
			}

			if ( $wp_filesystem ) {
				$written = (bool) $wp_filesystem->put_contents( $filepath, $content, FS_CHMOD_FILE );
			} else {
				$written = false !== file_put_contents( $filepath, $content ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			}

			// Write failed (permissions, disk full): keep the previous file
			// and its DB row serving the last good version. Deleting first
			// and tracking regardless used to leave the page enqueueing a
			// 404 asset indefinitely.
			if ( ! $written ) {
				return false;
			}
		}

		// Only after a confirmed write: remove superseded versions and
		// legacy files.
		$old_files = $this->get_asset_cleanup_paths( $post_id, $type );
		foreach ( $old_files as $old_file ) {
			if ( $old_file !== $filepath && file_exists( $old_file ) ) {
				wp_delete_file( $old_file );
			}
		}

		if ( 'css' === $type ) {
			$file_mtime = file_exists( $filepath ) ? filemtime( $filepath ) : false;
			$version    = false !== $file_mtime ? (string) $file_mtime : (string) current_time( 'timestamp' );
		}

		// Track asset in database.
		$this->track_asset( $post_id, $type, $filepath, $fileurl, $version );

		return array(
			'path'    => $filepath,
			'url'     => $fileurl,
			'version' => $version,
		);
	}

	/**
	 * Get cleanup paths for an asset type.
	 *
	 * @since  1.0.0
	 * @param  int    $post_id Post ID.
	 * @param  string $type    Asset type.
	 * @return array Files eligible for cleanup.
	 */
	private function get_asset_cleanup_paths( $post_id, $type ) {
		$upload_dir = wp_upload_dir();
		$paths      = array();

		if ( 'css' === $type ) {
			$paths[] = trailingslashit( $upload_dir['basedir'] ) . AMBLD_UPLOAD_DIR . '/css/post-' . $post_id . '.css';
			$paths[] = trailingslashit( $upload_dir['basedir'] ) . 'am-builder/css/post-' . $post_id . '.css';
			$paths[] = trailingslashit( $upload_dir['basedir'] ) . 'elementor/css/post-' . $post_id . '.css';
			$legacy  = glob( trailingslashit( $upload_dir['basedir'] ) . AMBLD_UPLOAD_DIR . '/css/page-' . $post_id . '-*.css' );
		} else {
			$legacy = glob( trailingslashit( $upload_dir['basedir'] ) . AMBLD_UPLOAD_DIR . '/' . $type . '/page-' . $post_id . '-*.' . $type );
		}

		if ( $legacy ) {
			$paths = array_merge( $paths, $legacy );
		}

		return array_values( array_unique( array_filter( $paths ) ) );
	}

	/**
	 * Delete tracked files and database records for an asset.
	 *
	 * @since  1.0.0
	 * @param  int    $post_id Post ID.
	 * @param  string $type    Asset type.
	 * @return void
	 */
	private function delete_tracked_asset( $post_id, $type ) {
		foreach ( $this->get_asset_cleanup_paths( $post_id, $type ) as $file_path ) {
			if ( file_exists( $file_path ) ) {
				wp_delete_file( $file_path );
			}
		}

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

		$wpdb->delete(
			$table,
			array(
				'post_id'    => $post_id,
				'asset_type' => $type,
			),
			array( '%d', '%s' )
		);
	}

	/**
	 * Scope all CSS selectors with a page-unique class for frontend isolation.
	 *
	 * Processes the full CSS string top-down, prefixing every regular rule's
	 * selector with the scope class. @media / @supports / @container rules are
	 * recursed into; @keyframes / @font-face and similar pass through unchanged.
	 *
	 * @since  1.0.0
	 * @param  string $css         Raw CSS content.
	 * @param  string $scope_class Scope class name (without leading dot).
	 * @return string Scoped CSS.
	 */
	private function scope_css_for_import( $css, $scope_class ) {
		if ( '' === trim( $css ) || '' === trim( $scope_class ) ) {
			return $css;
		}

		$output      = '';
		$len         = strlen( $css );
		$pos         = 0;
		$depth       = 0;
		$in_string   = false;
		$string_char = '';
		$in_comment  = false;
		$top_start   = 0;
		$current_sel = '';
		$inner_start = 0;

		while ( $pos < $len ) {
			$char = $css[ $pos ];

			if ( ! $in_string && ! $in_comment && '/' === $char && isset( $css[ $pos + 1 ] ) && '*' === $css[ $pos + 1 ] ) {
				$in_comment = true;
				$pos       += 2;
				continue;
			}
			if ( $in_comment ) {
				if ( '*' === $char && isset( $css[ $pos + 1 ] ) && '/' === $css[ $pos + 1 ] ) {
					$in_comment = false;
					$pos       += 2;
				} else {
					$pos++;
				}
				continue;
			}
			if ( ! $in_string && ( '"' === $char || "'" === $char ) ) {
				$in_string   = true;
				$string_char = $char;
				$pos++;
				continue;
			}
			if ( $in_string ) {
				if ( $char === $string_char && ( 0 === $pos || '\\' !== $css[ $pos - 1 ] ) ) {
					$in_string = false;
				}
				$pos++;
				continue;
			}

			if ( '{' === $char ) {
				$depth++;
				if ( 1 === $depth ) {
					$current_sel = trim( substr( $css, $top_start, $pos - $top_start ) );
					$inner_start = $pos + 1;
				}
			} elseif ( '}' === $char ) {
				if ( $depth > 0 ) {
					$depth--;
				}
				if ( 0 === $depth ) {
					$inner   = substr( $css, $inner_start, $pos - $inner_start );
					$output .= $this->scope_css_block( $current_sel, $inner, $scope_class );
					$top_start = $pos + 1;
				}
			}

			$pos++;
		}

		$tail = trim( substr( $css, $top_start ) );
		if ( '' !== $tail ) {
			$output .= $tail;
		}

		return $output;
	}

	/**
	 * Process a single top-level CSS block for scope prefixing.
	 *
	 * @since  1.0.0
	 * @param  string $selector    Rule selector or @-rule keyword.
	 * @param  string $inner       Content between the braces.
	 * @param  string $scope_class Scope class (without dot).
	 * @return string
	 */
	private function scope_css_block( $selector, $inner, $scope_class ) {
		if ( preg_match( '/^\s*@(?:media|supports|container|layer|document)\b/i', $selector ) ) {
			return $selector . '{' . $this->scope_css_for_import( $inner, $scope_class ) . '}';
		}
		if ( preg_match( '/^\s*@/i', $selector ) ) {
			return $selector . '{' . $inner . '}';
		}
		return $this->scope_css_prefix_selectors( $selector, '.' . $scope_class ) . '{' . $inner . '}';
	}

	/**
	 * Prefix each comma-separated selector with the scope class.
	 *
	 * :root passes through unchanged. html/body have the scope appended directly
	 * to the element name. All other selectors are prefixed with the doubled scope
	 * for higher specificity than single-class theme rules.
	 *
	 * @since  1.0.0
	 * @param  string $selector_string Comma-separated selectors.
	 * @param  string $scope           Scope string including leading dot.
	 * @return string
	 */
	private function scope_css_prefix_selectors( $selector_string, $scope ) {
		$selectors = preg_split( '/,(?![^()]*\))/', $selector_string );
		$prefixed  = array();

		foreach ( $selectors as $selector ) {
			$selector = trim( $selector );
			if ( '' === $selector ) {
				continue;
			}
			if ( ':root' === strtolower( $selector ) ) {
				$prefixed[] = $selector;
				continue;
			}
			if ( preg_match( '/^html\b/i', $selector ) ) {
				$rest       = ltrim( preg_replace( '/^html\b/i', '', $selector ) );
				$prefixed[] = 'body' . $scope . $scope . ( '' !== $rest ? ' ' . $rest : '' );
				continue;
			}
			if ( preg_match( '/^body\b/i', $selector ) ) {
				$rest       = ltrim( preg_replace( '/^body\b/i', '', $selector ) );
				$prefixed[] = 'body' . $scope . $scope . ( '' !== $rest ? $rest : '' );
				continue;
			}
			$prefixed[] = $scope . $scope . ' ' . $selector;
		}

		return implode( ', ', $prefixed );
	}

	/**
	 * Track asset in the database.
	 *
	 * @since  1.0.0
	 * @param  int    $post_id  Post ID.
	 * @param  string $type     Asset type.
	 * @param  string $filepath File path.
	 * @param  string $fileurl  File URL.
	 * @param  string $version  Version hash.
	 * @return void
	 */
	private function track_asset( $post_id, $type, $filepath, $fileurl, $version ) {
		global $wpdb;
		$table = $wpdb->prefix . 'ambld_assets';

		// Check if record exists.
		$existing = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT id FROM {$table} WHERE post_id = %d AND asset_type = %s",
				$post_id,
				$type
			)
		);

		if ( $existing ) {
			$wpdb->update(
				$table,
				array(
					'file_path' => $filepath,
					'file_url'  => $fileurl,
					'version'   => $version,
				),
				array(
					'post_id'    => $post_id,
					'asset_type' => $type,
				),
				array( '%s', '%s', '%s' ),
				array( '%d', '%s' )
			);
		} else {
			$wpdb->insert(
				$table,
				array(
					'post_id'    => $post_id,
					'asset_type' => $type,
					'file_path'  => $filepath,
					'file_url'   => $fileurl,
					'version'    => $version,
				),
				array( '%d', '%s', '%s', '%s', '%s' )
			);
		}
	}

}
