<?php
/**
 * Per-instance CSS pipeline — single-handle pool for all Blocksify blocks
 * on a page (mirrors GenerateBlocks `generateblocks_css_output` pattern).
 *
 * Two output modes (SPEC: docs/SPEC-css-output-mode.md):
 *   - 'file'   (DEFAULT) — write one cached `style-{post_id}.css` per page
 *     to `uploads/blocksify/` and enqueue its URL (`?ver=filemtime`). Stable
 *     filename, overwrite in place → cache-plugin safe (no 404 under stale
 *     page cache). Warm requests skip parse_blocks + generation entirely.
 *   - 'inline' — pool into one inline `<style>` in `<head>`.
 *
 * Invalidation (SPEC §6): a global counter `blocksify_css_generation` +
 * per-post meta `_blocksify_css_built`. A page rebuilds when the file is
 * missing OR its meta != the current generation. The counter bumps ONLY on
 * plugin-version change + manual clear/regenerate — NOT on token edits:
 * token *values* self-heal because the `:root` token CSS is re-emitted
 * unconditionally every request (Blocksify_Tokens / Blocksify_Settings /
 * Blocksify_Shadows on wp_head), separate from the cached file, so the
 * baked `var(--x, fallback)` is dead code on the frontend. SPEC §3.1.
 *
 * Static base CSS (`build/blocks/{name}/blocksify-{name}.css`) is read at
 * request time. block.json declares no `style`, so this pipeline is the
 * single point of truth on the frontend; the editor canvas gets the full
 * bundle via `enqueue_block_assets`.
 *
 * Filter hooks (Pro extension surface):
 *   - `blocksify.css.enabled`          (bool)   kill switch.
 *   - `blocksify.css.page_id`          (int)    override which post to scan.
 *   - `blocksify.css.print_method`     (string) 'file' (default, from the
 *                                               css_output_mode setting) or
 *                                               'inline'. Unknown → 'file'.
 *   - `blocksify.css.inline_threshold` (int)    bytes; below this, inline
 *                                               instead of writing a file.
 *   - `blocksify.css.cache_generation` (int)    fold extra invalidation
 *                                               inputs into the counter.
 *                                               Applied via generation()
 *                                               at BOTH build + check time.
 *   - `blocksify.css.collected_blocks` (array)  add custom block entries.
 *   - `blocksify.css.static_block`     (string) per-block static base CSS.
 *   - `blocksify.css.block`            (string) per-block CSS string.
 *   - `blocksify.css.conditional_blocks` (string) feature-gated bundles.
 *   - `blocksify.css.output`           (string) final concatenated CSS.
 *
 * Skipped contexts: admin (editor uses JS preview-css path), REST_REQUEST.
 * Forced inline: preview / customize-preview / AMP (must show live edits).
 *
 * @package Blocksify
 */

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

class Blocksify_CSS_Pipeline {

	/**
	 * Block names whose static base CSS the pipeline owns. Mirrored from
	 * registered Blocksify blocks — keep in sync when new blocks land.
	 *
	 * @var string[]
	 */
	const BLOCK_NAMES = array(
		'blocksify/section',
		'blocksify/container',
		'blocksify/button',
		'blocksify/text',
		'blocksify/icon',
		'blocksify/image',
		'blocksify/tabs',
		'blocksify/tab',
		'blocksify/content-loop',
		'blocksify/accordion',
		'blocksify/accordion-item',
		'blocksify/icon-list',
		'blocksify/icon-list-item',
	);

	const GENERATION_OPTION     = 'blocksify_css_generation';
	const PLUGIN_VERSION_OPTION = 'blocksify_css_plugin_version';
	const BUILT_META            = '_blocksify_css_built';
	const UPLOAD_SUBDIR         = 'blocksify';
	const WRITE_FAIL_TRANSIENT  = 'blocksify_css_write_failed';
	const GC_HOOK               = 'blocksify_css_gc';

	/**
	 * Singleton instance.
	 *
	 * @var self|null
	 */
	private static $instance = null;

	/**
	 * Per-request memoization for static-base CSS reads. Key = block name,
	 * value = file contents (string, possibly empty).
	 *
	 * @var array<string,string>
	 */
	private $static_cache = array();

	/**
	 * Per-request memoized output mode ('file' | 'inline').
	 *
	 * @var string|null
	 */
	private $mode_cache = null;

	/**
	 * Singleton accessor + boot trigger.
	 */
	public static function boot() {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Frontend pipeline + editor canvas bases, plus file-mode invalidation
	 * + maintenance hooks. SPEC §6 / §8.
	 */
	public function __construct() {
		add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_dynamic_css' ), 90 );
		add_action( 'enqueue_block_assets', array( $this, 'enqueue_editor_static_bases' ) );

		// File-mode invalidation + maintenance.
		add_action( 'init', array( $this, 'maybe_bump_on_version_change' ) );
		add_action( 'init', array( $this, 'schedule_gc' ) );
		add_action( self::GC_HOOK, array( $this, 'run_gc' ) );
		add_action( 'save_post', array( $this, 'on_save_post' ), 10, 2 );
		add_action( 'before_delete_post', array( $this, 'on_delete_post' ) );
		add_action( 'wp_trash_post', array( $this, 'on_delete_post' ) );
		add_action( 'blocksify_dashboard_clear_cache', array( $this, 'bump_generation' ) );
		add_filter( 'blocksify_dashboard_clear_cache_count', array( $this, 'report_file_count' ) );

		// Bump generation ONLY when a custom token is DELETED. A block linked
		// to it renders `var(--…, <frozen fallback>)` — inline drops the value,
		// file shows the frozen fallback → they diverge. Value edits self-heal
		// via the always-on `:root` and DON'T bump. SPEC §6.6.
		foreach ( array( 'blocksify_custom_colors', 'blocksify_custom_gradients', 'blocksify_shadow_presets' ) as $token_option ) {
			add_action( "update_option_{$token_option}", array( $this, 'bump_on_token_removal' ), 10, 2 );
		}
	}

	/* ---------------------------------------------------------------------
	 * Static base CSS (shared by frontend + editor canvas)
	 * ------------------------------------------------------------------- */

	/**
	 * Read + cache the static base CSS for a single block. Returns ''
	 * when the build artifact is missing so the pipeline degrades
	 * gracefully.
	 *
	 * @param string $block_name e.g. 'blocksify/button'.
	 * @return string Raw CSS (no `<style>` wrapper) or empty string.
	 */
	public function static_css_for( $block_name ) {
		if ( isset( $this->static_cache[ $block_name ] ) ) {
			return $this->static_cache[ $block_name ];
		}
		$short = preg_replace( '#^blocksify/#', '', (string) $block_name );
		if ( '' === $short ) {
			$this->static_cache[ $block_name ] = '';
			return '';
		}
		$path = BLOCKSIFY_PATH . 'build/blocks/' . $short . '/blocksify-' . $short . '.css';
		$css  = is_readable( $path ) ? (string) file_get_contents( $path ) : '';
		$css  = (string) apply_filters( 'blocksify.css.static_block', $css, $block_name );
		$this->static_cache[ $block_name ] = $css;
		return $css;
	}

	/**
	 * Concat static base CSS for the given block names (deduped).
	 *
	 * @param string[] $names Block name list.
	 * @return string
	 */
	private function static_css_for_names( $names ) {
		$names  = array_values( array_unique( array_filter( (array) $names ) ) );
		$pieces = array();
		foreach ( $names as $name ) {
			$css = $this->static_css_for( $name );
			if ( '' !== trim( $css ) ) {
				$pieces[] = $css;
			}
		}
		return implode( "\n", $pieces );
	}

	/**
	 * Editor canvas hook — emit the static-base bundle in admin only
	 * (frontend has its own targeted pre-pend in enqueue_dynamic_css).
	 */
	public function enqueue_editor_static_bases() {
		if ( ! is_admin() ) {
			return;
		}
		$css  = $this->static_css_for_names( self::BLOCK_NAMES );
		$css .= "\n" . Blocksify_CSS_Generator::button_icon_css();
		if ( '' === trim( $css ) ) {
			return;
		}
		$this->emit_static_bundle( $css );
	}

	/**
	 * Pool the static-base CSS into a dedicated dummy handle.
	 *
	 * @param string $css
	 */
	protected function emit_static_bundle( $css ) {
		$handle = 'blocksify-blocks-base';
		if ( ! wp_style_is( $handle, 'registered' ) ) {
			wp_register_style( $handle, false ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion
		}
		wp_enqueue_style( $handle );
		wp_add_inline_style( $handle, $css );
	}

	/* ---------------------------------------------------------------------
	 * Frontend entry point
	 * ------------------------------------------------------------------- */

	/**
	 * Whether to run the pipeline on the current request.
	 */
	public static function can_enqueue() {
		if ( is_admin() ) {
			return false;
		}
		if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
			return false;
		}
		return (bool) apply_filters( 'blocksify.css.enabled', true );
	}

	/**
	 * Resolve the post ID whose content the pipeline should scan.
	 *
	 * @return int Post ID or 0 when no singular page is queried.
	 */
	private function page_id() {
		$id = is_singular() ? (int) get_queried_object_id() : 0;
		return (int) apply_filters( 'blocksify.css.page_id', $id );
	}

	/**
	 * Output mode for this request. Preview / customize / AMP force
	 * 'inline' so editors see live content, never a cached published file
	 * (SPEC §8). Otherwise the css_output_mode setting drives it, with the
	 * `blocksify.css.print_method` filter on top. Allow-list of recognized
	 * modes; anything else → default 'file' (so legacy stored 'per_page' /
	 * 'global' resolve to 'file', not 'inline'). SPEC §5.1.
	 *
	 * @return string 'file' | 'inline'.
	 */
	public function mode() {
		if ( null !== $this->mode_cache ) {
			return $this->mode_cache;
		}
		if (
			is_preview()
			|| is_customize_preview()
			|| ( function_exists( 'is_amp_endpoint' ) && is_amp_endpoint() )
		) {
			$this->mode_cache = 'inline';
			return $this->mode_cache;
		}
		$mode = apply_filters( 'blocksify.css.print_method', $this->settings_mode() );
		$this->mode_cache = in_array( $mode, array( 'inline', 'file' ), true ) ? $mode : 'file';
		return $this->mode_cache;
	}

	/**
	 * The css_output_mode setting value, clamped to a known mode.
	 *
	 * @return string 'file' | 'inline'.
	 */
	private function settings_mode() {
		if ( class_exists( 'Blocksify_Dashboard_Schema' ) ) {
			$settings = Blocksify_Dashboard_Schema::get_settings();
			$mode     = isset( $settings['performance']['css_output_mode'] )
				? $settings['performance']['css_output_mode']
				: 'file';
			return in_array( $mode, array( 'inline', 'file' ), true ) ? $mode : 'file';
		}
		return 'file';
	}

	/**
	 * Main entry point — resolve mode, then file (warm / serve-stale /
	 * rebuild) or inline. SPEC §5.
	 */
	public function enqueue_dynamic_css() {
		if ( ! self::can_enqueue() ) {
			return;
		}
		$page_id = $this->page_id();
		if ( ! $page_id ) {
			return;
		}
		$post = get_post( $page_id );
		if ( ! $post || empty( $post->post_content ) ) {
			return;
		}

		if ( 'file' === $this->mode() ) {
			$path = $this->file_path( $page_id );
			if ( '' !== $path ) {
				// Warm: current file exists → enqueue + skip generation.
				if ( $this->is_file_fresh( $page_id, $path ) ) {
					$this->enqueue_file( $this->file_url( $page_id ), $path );
					return;
				}

				$lock = false;
				// A stale file is 404-safe → only the rebuild-lock holder
				// regenerates; everyone else serves the stale file
				// (stale-while-revalidate, ≤1 generation behind). SPEC §8.
				if ( is_readable( $path ) && filesize( $path ) > 0 ) {
					if ( $this->acquire_lock( $page_id ) ) {
						$lock = true;
					} else {
						$this->enqueue_file( $this->file_url( $page_id ), $path );
						return;
					}
				}

				$css = $this->build_css( $post );
				if ( '' !== $css ) {
					$writable = strlen( $css ) >= $this->inline_threshold() && ! $this->write_failed();

					// Cold writable page → take the lock JUST before writing
					// (best-effort dedupe of concurrent first-builds). A lock
					// loser inlines this beat; the holder writes the file.
					// Sub-threshold pages never touch the lock at all — avoids
					// transient churn on pages that can never warm.
					if ( $writable && ! $lock && $this->acquire_lock( $page_id ) ) {
						$lock = true;
					}

					if ( $lock && $writable && $this->write_file( $page_id, $css ) ) {
						update_post_meta( $page_id, self::BUILT_META, $this->generation() );
						clearstatcache( true, $path );
						$this->release_lock( $page_id );
						$this->enqueue_file( $this->file_url( $page_id ), $path );
						return;
					}
					if ( $lock && $writable ) {
						// write_file() returned false.
						$this->mark_write_failed();
					}
					if ( $lock ) {
						$this->release_lock( $page_id );
					}
					$this->emit_inline( $css );
					return;
				}
				if ( $lock ) {
					$this->release_lock( $page_id );
				}
				return;
			}
			// Upload dir unavailable → degrade to inline below.
		}

		$css = $this->build_css( $post );
		if ( '' === $css ) {
			return;
		}
		$this->emit_inline( $css );
	}

	/**
	 * Build the pooled CSS string for a post: static bases (deduped) +
	 * conditional bundles + per-instance, through the Pro filter chain.
	 *
	 * @param WP_Post $post
	 * @return string Trimmed CSS (may be empty).
	 */
	private function build_css( $post ) {
		$blocks    = parse_blocks( $post->post_content );
		$collected = array();
		$this->collect_blocks( $blocks, $collected );

		$collected = apply_filters( 'blocksify.css.collected_blocks', $collected );
		if ( empty( $collected ) ) {
			return '';
		}

		$active_names = array();
		foreach ( $collected as $entry ) {
			if ( ! empty( $entry['name'] ) ) {
				$active_names[] = $entry['name'];
			}
		}
		$static_css      = $this->static_css_for_names( $active_names );
		$conditional_css = self::collect_conditional_css( $collected );

		$pieces = array();
		foreach ( $collected as $entry ) {
			$name  = isset( $entry['name'] ) ? $entry['name'] : '';
			$attrs = isset( $entry['attrs'] ) ? $entry['attrs'] : array();
			if ( '' === $name ) {
				continue;
			}
			$attrs = self::merge_block_defaults( $name, $attrs );
			$css   = Blocksify_CSS_Generator::generate_block_css( $name, $attrs );
			$css   = apply_filters( 'blocksify.css.block', $css, $name, $attrs );
			if ( '' !== $css ) {
				$pieces[] = $css;
			}
		}

		$css = $static_css
			. ( '' !== $static_css ? "\n" : '' )
			. $conditional_css
			. ( '' !== $conditional_css ? "\n" : '' )
			. implode( '', $pieces );

		$css = apply_filters( 'blocksify.css.output', $css );

		return trim( (string) $css );
	}

	/**
	 * Inline emit — pool into one dummy handle as inline `<style>`.
	 *
	 * @param string $css
	 */
	private function emit_inline( $css ) {
		$handle = 'blocksify-blocks';
		if ( ! wp_style_is( $handle, 'registered' ) ) {
			wp_register_style( $handle, false ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion
		}
		wp_enqueue_style( $handle );
		wp_add_inline_style( $handle, $css );
	}

	/* ---------------------------------------------------------------------
	 * File mode — paths, freshness, write, enqueue
	 * ------------------------------------------------------------------- */

	/**
	 * Effective generation epoch, filtered. MUST be the single accessor used
	 * at BOTH build-time (storing meta) and check-time (is_file_fresh) so
	 * the two never diverge. SPEC §6.5.
	 *
	 * @return int
	 */
	private function generation() {
		$gen = (int) get_option( self::GENERATION_OPTION, 0 );
		return (int) apply_filters( 'blocksify.css.cache_generation', $gen );
	}

	/**
	 * Bump the global generation → every page rebuilds lazily on next visit.
	 * Hooked to plugin-version change + manual clear/regenerate.
	 */
	public function bump_generation() {
		$gen = (int) get_option( self::GENERATION_OPTION, 0 );
		update_option( self::GENERATION_OPTION, $gen + 1, true );
	}

	/**
	 * Detect a plugin-version change at our own `init` (frontend included)
	 * so the first request after any update path bumps before the pipeline
	 * runs. Window effectively closed vs GB's admin_init. SPEC §6.4.
	 */
	public function maybe_bump_on_version_change() {
		$current = defined( 'BLOCKSIFY_VERSION' ) ? BLOCKSIFY_VERSION : '';
		$stored  = get_option( self::PLUGIN_VERSION_OPTION, null );
		if ( $stored !== $current ) {
			update_option( self::PLUGIN_VERSION_OPTION, $current, true );
			$this->bump_generation();
		}
	}

	/**
	 * Bump the generation when a custom token (color / gradient / shadow) is
	 * DELETED — a block that links to it would otherwise show the cached
	 * file's frozen `var()` fallback while inline drops the property (SPEC
	 * §6.6). Fires on the `update_option_{option}` hooks; only bumps when a
	 * slug present before is gone after. Value edits + additions don't bump
	 * (value edits self-heal via `:root`; a new token has no consumer yet).
	 *
	 * @param mixed $old_value Previous option value.
	 * @param mixed $new_value New option value.
	 */
	public function bump_on_token_removal( $old_value, $new_value ) {
		$removed = array_diff( self::token_slugs( $old_value ), self::token_slugs( $new_value ) );
		if ( ! empty( $removed ) ) {
			$this->bump_generation();
		}
	}

	/**
	 * Extract the custom-token slug set from an option value, handling both
	 * shapes: flat `[{slug,…}]` (colors / gradients) and the shadow option's
	 * `{ defaultEdits, customs:[{slug,…}] }`. Default shadow presets are
	 * always emitted (seeds), so only `customs` slugs can disappear from
	 * `:root` — defaultEdits are ignored here.
	 *
	 * @param mixed $value Option value.
	 * @return string[] Slugs.
	 */
	private static function token_slugs( $value ) {
		if ( ! is_array( $value ) ) {
			return array();
		}
		$list = ( isset( $value['customs'] ) && is_array( $value['customs'] ) )
			? $value['customs']
			: $value;
		$slugs = array();
		foreach ( (array) $list as $entry ) {
			if ( is_array( $entry ) && isset( $entry['slug'] ) && is_string( $entry['slug'] ) ) {
				$slugs[] = $entry['slug'];
			}
		}
		return $slugs;
	}

	/**
	 * Whether a page's file is current (no rebuild needed). SPEC §6.
	 *
	 * @param int    $page_id
	 * @param string $path
	 * @return bool
	 */
	private function is_file_fresh( $page_id, $path ) {
		if ( ! is_readable( $path ) || filesize( $path ) <= 0 ) {
			return false;
		}
		$built = (int) get_post_meta( $page_id, self::BUILT_META, true );
		return $built === $this->generation();
	}

	/**
	 * Uploads dir + URL for Blocksify CSS, created on demand.
	 *
	 * @return array{dir:string,url:string}|false
	 */
	private function upload_base() {
		$up = wp_upload_dir();
		if ( ! empty( $up['error'] ) ) {
			return false;
		}
		return array(
			'dir' => trailingslashit( $up['basedir'] ) . self::UPLOAD_SUBDIR,
			'url' => trailingslashit( $up['baseurl'] ) . self::UPLOAD_SUBDIR,
		);
	}

	/**
	 * Stable per-page filename (one file per page; blog id on multisite).
	 *
	 * @param int $page_id
	 * @return string
	 */
	private function file_basename( $page_id ) {
		$name = 'style-' . (int) $page_id;
		if ( is_multisite() ) {
			$name .= '-blog-' . get_current_blog_id();
		}
		return $name . '.css';
	}

	/**
	 * @param int $page_id
	 * @return string Absolute path, or '' when uploads is unavailable.
	 */
	private function file_path( $page_id ) {
		$base = $this->upload_base();
		return $base ? $base['dir'] . '/' . $this->file_basename( $page_id ) : '';
	}

	/**
	 * @param int $page_id
	 * @return string Public URL, or '' when uploads is unavailable.
	 */
	private function file_url( $page_id ) {
		$base = $this->upload_base();
		return $base ? $base['url'] . '/' . $this->file_basename( $page_id ) : '';
	}

	/**
	 * Write the pooled CSS to the page's file (overwrite in place). Direct
	 * write — WP_Filesystem can demand FTP creds at frontend request time;
	 * uploads is writable since WP writes media there. Caller falls back to
	 * inline on false. SPEC §7.
	 *
	 * @param int    $page_id
	 * @param string $css
	 * @return bool
	 */
	private function write_file( $page_id, $css ) {
		$base = $this->upload_base();
		if ( ! $base ) {
			return false;
		}
		if ( ! is_dir( $base['dir'] ) && ! wp_mkdir_p( $base['dir'] ) ) {
			return false;
		}
		$index = $base['dir'] . '/index.html';
		if ( ! file_exists( $index ) ) {
			@file_put_contents( $index, '' ); // phpcs:ignore WordPress.WP.AlternativeFunctions, WordPress.PHP.NoSilencedErrors
		}
		$path = $base['dir'] . '/' . $this->file_basename( $page_id );
		$ok   = @file_put_contents( $path, $css ); // phpcs:ignore WordPress.WP.AlternativeFunctions, WordPress.PHP.NoSilencedErrors
		return false !== $ok;
	}

	/**
	 * Enqueue the file `<link>` with `?ver=filemtime` (stable filename →
	 * the mtime is the only cache-bust).
	 *
	 * @param string $url
	 * @param string $path
	 */
	private function enqueue_file( $url, $path ) {
		if ( '' === $url ) {
			return;
		}
		$ver = ( '' !== $path && is_readable( $path ) ) ? filemtime( $path ) : null;
		wp_enqueue_style( 'blocksify-blocks', $url, array(), $ver );
	}

	/**
	 * Delete a single post's CSS file (on trash / delete).
	 *
	 * @param int $page_id
	 */
	private function delete_post_files( $page_id ) {
		$path = $this->file_path( $page_id );
		if ( '' !== $path && file_exists( $path ) ) {
			@unlink( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions, WordPress.PHP.NoSilencedErrors
		}
	}

	/* ---------------------------------------------------------------------
	 * Best-effort per-page rebuild lock + write-failure guard (SPEC §8)
	 * ------------------------------------------------------------------- */

	/**
	 * Best-effort only — WP transients/options are not atomic (TOCTOU), so
	 * this reduces but does not eliminate concurrent cold builds. SPEC §8.
	 *
	 * @param int $page_id
	 * @return bool True when this request may rebuild.
	 */
	private function acquire_lock( $page_id ) {
		$key = 'blocksify_css_lock_' . (int) $page_id;
		if ( get_transient( $key ) ) {
			return false;
		}
		set_transient( $key, 1, 30 );
		return true;
	}

	/**
	 * @param int $page_id
	 */
	private function release_lock( $page_id ) {
		delete_transient( 'blocksify_css_lock_' . (int) $page_id );
	}

	/**
	 * Global write-failure guard — one failure suppresses writes site-wide
	 * for a few minutes so a read-only uploads dir doesn't hammer I/O on
	 * every request. SPEC §8.
	 *
	 * @return bool
	 */
	private function write_failed() {
		return (bool) get_transient( self::WRITE_FAIL_TRANSIENT );
	}

	private function mark_write_failed() {
		set_transient( self::WRITE_FAIL_TRANSIENT, 1, 5 * MINUTE_IN_SECONDS );
	}

	/**
	 * Below this many bytes, inline instead of writing a file.
	 *
	 * @return int
	 */
	private function inline_threshold() {
		return (int) apply_filters( 'blocksify.css.inline_threshold', 500 );
	}

	/* ---------------------------------------------------------------------
	 * Invalidation hooks + GC
	 * ------------------------------------------------------------------- */

	/**
	 * Drop the built marker on save so the next visit rebuilds. The file is
	 * KEPT (serve-stale until the rebuild). Guards: only render-singular
	 * post types; skip autosave / revisions. SPEC §8.
	 *
	 * @param int     $post_id
	 * @param WP_Post $post
	 */
	public function on_save_post( $post_id, $post ) {
		if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
			return;
		}
		if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
			return;
		}
		if ( ! $post instanceof WP_Post || ! is_post_type_viewable( $post->post_type ) ) {
			return;
		}
		delete_post_meta( $post_id, self::BUILT_META );
	}

	/**
	 * Remove a post's CSS file on trash / permanent delete.
	 *
	 * @param int $post_id
	 */
	public function on_delete_post( $post_id ) {
		$this->delete_post_files( (int) $post_id );
	}

	/**
	 * Schedule the daily orphan sweep.
	 */
	public function schedule_gc() {
		if ( ! wp_next_scheduled( self::GC_HOOK ) ) {
			wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::GC_HOOK );
		}
	}

	/**
	 * Clear the scheduled orphan sweep so it doesn't linger in WP-Cron after
	 * the plugin is deactivated. Wired from blocksify.php via
	 * register_deactivation_hook.
	 */
	public static function on_deactivate() {
		wp_clear_scheduled_hook( self::GC_HOOK );
	}

	/**
	 * Delete CSS files whose post no longer exists (orphans from direct-DB
	 * deletes that skipped before_delete_post). Stable filenames mean this
	 * is rare; runs daily. SPEC §8.
	 */
	public function run_gc() {
		$base = $this->upload_base();
		if ( ! $base ) {
			return;
		}
		foreach ( (array) glob( $base['dir'] . '/style-*.css' ) as $file ) {
			if ( ! preg_match( '/style-(\d+)/', basename( $file ), $m ) ) {
				continue;
			}
			if ( ! get_post( (int) $m[1] ) ) {
				@unlink( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions, WordPress.PHP.NoSilencedErrors
			}
		}
	}

	/**
	 * Report how many page files exist (they all rebuild after a bump) so
	 * the dashboard "Clear cache" notice shows a meaningful number.
	 *
	 * @param int $count
	 * @return int
	 */
	public function report_file_count( $count ) {
		$base = $this->upload_base();
		if ( ! $base ) {
			return (int) $count;
		}
		$files = glob( $base['dir'] . '/style-*.css' );
		return (int) $count + ( is_array( $files ) ? count( $files ) : 0 );
	}

	/* ---------------------------------------------------------------------
	 * Collection + defaults (shared)
	 * ------------------------------------------------------------------- */

	/**
	 * Collect CSS bundles that ship only when a runtime condition holds
	 * (e.g. a Button has an icon). Rides the same pool. Filterable via
	 * `blocksify.css.conditional_blocks`.
	 *
	 * @param array $collected Parsed block entries [{ name, attrs }].
	 * @return string CSS string (no `<style>` wrapper). May be empty.
	 */
	private static function collect_conditional_css( $collected ) {
		$css = '';

		$has_button_icon = false;
		foreach ( $collected as $entry ) {
			if ( ( $entry['name'] ?? '' ) !== 'blocksify/button' ) {
				continue;
			}
			$icon = $entry['attrs']['icon'] ?? null;
			if ( ! empty( $icon ) ) {
				$has_button_icon = true;
				break;
			}
		}
		if ( $has_button_icon ) {
			$css .= Blocksify_CSS_Generator::button_icon_css();
		}

		return (string) apply_filters(
			'blocksify.css.conditional_blocks',
			$css,
			$collected
		);
	}

	/**
	 * Merge block.json attribute defaults into a parsed `$attrs` array.
	 * `parse_blocks()` omits attributes equal to their block.json default,
	 * so trait emit would skip unauthored defaults; re-instating them keeps
	 * block.json the single source of truth for default visuals.
	 *
	 * @param string $name  Block name.
	 * @param array  $attrs Parsed attrs.
	 * @return array Attrs with block.json defaults filled in.
	 */
	public static function merge_block_defaults( $name, $attrs ) {
		$registry = WP_Block_Type_Registry::get_instance();
		if ( ! $registry ) {
			return $attrs;
		}
		$block_type = $registry->get_registered( $name );
		if ( ! $block_type || empty( $block_type->attributes ) ) {
			return $attrs;
		}
		$defaults = array();
		foreach ( $block_type->attributes as $key => $config ) {
			if ( ! is_array( $config ) || ! array_key_exists( 'default', $config ) ) {
				continue;
			}
			if ( ! array_key_exists( $key, $attrs ) ) {
				$defaults[ $key ] = $config['default'];
			}
		}
		if ( empty( $defaults ) ) {
			return $attrs;
		}
		return array_merge( $defaults, $attrs );
	}

	/**
	 * Recursively walk parsed blocks and append every Blocksify block to
	 * the collector array. Nested innerBlocks flatten to the same list.
	 *
	 * NOTE: does not expand `core/block` (synced pattern) refs or template
	 * parts — those get no per-instance CSS today, in BOTH modes. Tracked
	 * as a separate pre-existing bug (SPEC §12).
	 *
	 * @param array $blocks Parsed blocks tree.
	 * @param array $out    Mutable accumulator (passed by ref).
	 */
	protected function collect_blocks( $blocks, &$out ) {
		if ( ! is_array( $blocks ) ) {
			return;
		}
		foreach ( $blocks as $block ) {
			if ( ! is_array( $block ) ) {
				continue;
			}
			$name = isset( $block['blockName'] ) ? $block['blockName'] : '';
			if ( is_string( $name ) && strpos( $name, 'blocksify/' ) === 0 ) {
				$out[] = array(
					'name'  => $name,
					'attrs' => isset( $block['attrs'] ) && is_array( $block['attrs'] )
						? $block['attrs']
						: array(),
				);
			}
			if ( ! empty( $block['innerBlocks'] ) ) {
				$this->collect_blocks( $block['innerBlocks'], $out );
			}
		}
	}
}
