<?php
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Asset isolation on pages built with Aura.
 *
 * An Aura page carries all its own CSS, scoped per section: Gutenberg's styles and the theme's are
 * no use to it and in fact contaminate it (resets, widths, typography, inherited margins). On those
 * pages both are removed; on every other page — Gutenberg, archives, the theme's home — nothing
 * changes at all.
 *
 * Two hooks, because one is not enough:
 *  - `wp_enqueue_scripts` at a very high priority removes what is already queued;
 *  - `print_styles_array` acts on `to_do` AFTER dependency resolution, so it also catches the
 *    styles that come back because somebody else declares them as a dependency.
 *
 * IT GOES HAND IN HAND WITH THE CANVAS. Aura's pages are served by templates/canvas.php, which does
 * not print the theme's chrome at all: removing the CSS is therefore safe, because no theme markup
 * is left to dress.
 *
 * The one case where the two come apart is the `aipb_canvas` filter, used to put a page back inside
 * the theme: there the header and footer return to the markup and would be left naked. If you take
 * that road, switch isolation off too with `aipb_isolate_assets`, or save the theme's handles with
 * `aipb_isolate_keep`.
 */
class AIPB_Assets {

	public static function init() {
		add_action( 'wp_enqueue_scripts', array( __CLASS__, 'dequeue' ), 9999 );
		add_filter( 'print_styles_array', array( __CLASS__, 'filter_queue' ) );

		// The theme's chrome is not only header.php and footer.php: many themes print pieces of
		// interface by hooking wp_head/wp_footer (popups, loaders, debug bars). The canvas does not
		// catch those, because it calls wp_footer() anyway. We go BEFORE everybody.
		foreach ( self::chrome_hooks() as $hook ) {
			add_action( $hook, array( __CLASS__, 'strip_theme_chrome' ), -PHP_INT_MAX );
		}

		// The admin bar is NOT the theme's chrome: it is the desk of whoever is working, and on the
		// canvas it has to stay whole. Some themes, though, rebuild it their own way — removing the
		// core's items on `wp_before_admin_bar_render` and redrawing them with their own markup and
		// CSS on wp_head/wp_footer. On an Aura page that markup is rightly removed along with the
		// rest of the chrome, and without this line a half-bar would be left: the theme's items
		// switched off and WordPress's never put back. So we clean this hook too, and the bar goes
		// back to the standard one (logo, site name, New, Edit).
		add_action( 'wp_before_admin_bar_render', array( __CLASS__, 'strip_theme_chrome' ), -PHP_INT_MAX );
	}

	/**
	 * Does this request need isolating?
	 *
	 * Only singular content, owned by Aura, with something actually built: a page that has been
	 * claimed but is still empty has to look exactly as it did before, otherwise a blank page would
	 * appear for no reason.
	 */
	public static function isolating() {
		$on = false;
		$id = 0;

		if ( ! is_admin() && is_singular() ) {
			$id = (int) get_queried_object_id();
			$on = $id && AIPB_Ownership::is_aura( $id ) && AIPB_Ownership::has_aura_content( $id );
		}

		// The content may not be Aura's and still be drawn by an Aura TEMPLATE: an ordinary product
		// page under a template built out of sections. There too the page holds nothing of the
		// theme — the sections carry the header and footer — so the theme's clothing is only
		// contamination.
		if ( ! $on && ! is_admin() && class_exists( 'AIPB_Render' ) ) {
			$on = (bool) AIPB_Render::aura_template_id();
		}

		return (bool) apply_filters( 'aipb_isolate_assets', $on, $id );
	}

	/** The final list to remove, minus whatever is meant to be kept. */
	public static function targets() {
		$keep = (array) apply_filters( 'aipb_isolate_keep', array() );

		// An Aura template that prints the content's body ({{h:post.content}}) brings that
		// content's Gutenberg blocks into the page: there the block CSS is still NEEDED, and
		// removing it would strip the product description. The theme's goes either way.
		$handles = self::theme_handles();
		$template = class_exists( 'AIPB_Render' ) ? AIPB_Render::aura_template_id() : 0;

		if ( ! $template || ! AIPB_Render::template_embeds_content( $template ) ) {
			$handles = array_merge( self::block_handles(), $handles );
		}

		return array_values( array_diff( array_unique( $handles ), $keep ) );
	}

	public static function dequeue() {
		if ( ! self::isolating() ) {
			return;
		}

		foreach ( self::targets() as $handle ) {
			wp_dequeue_style( $handle );
		}
	}

	/** The safety net: styles that came back as somebody else's dependency pass through here. */
	public static function filter_queue( $handles ) {
		if ( ! is_array( $handles ) || ! self::isolating() ) {
			return $handles;
		}

		$drop = array_flip( self::targets() );

		return array_values( array_filter( $handles, function ( $handle ) use ( $drop ) {
			return ! isset( $drop[ $handle ] );
		} ) );
	}

	/**
	 * A minimal reset, printed only when isolating.
	 *
	 * Without the theme's CSS the normalisations every site takes for granted are missing too.
	 * Deliberately tiny — the sections carry their own scoped CSS, which wins on specificity over
	 * these selectors anyway.
	 */
	public static function reset_css() {
		return '*,*::before,*::after{box-sizing:border-box}'
			. 'body{margin:0}'
			. 'img,svg,video{max-width:100%;height:auto}';
	}

	/**
	 * Removes from the current hook the callbacks DEFINED INSIDE THE THEME.
	 *
	 * Recognition is by file, not by name: the same criterion as theme_handles(), which recognises
	 * styles by their src. It works with child and parent themes, with closures and with methods,
	 * without having to know the names each theme picks for itself.
	 *
	 * It runs at minimum priority ON the hook itself — not before — because many callbacks are
	 * registered late (inside wp_enqueue_scripts, which itself runs inside wp_head).
	 *
	 * WordPress's own callbacks and other plugins' are left intact: this is about the theme's
	 * chrome, not about what the page needs.
	 */
	public static function strip_theme_chrome() {
		if ( ! class_exists( 'AIPB_Render' ) ) {
			return;
		}

		// The condition is the CANVAS, not isolation: if a page chose to stay inside the theme
		// (meta '0'), its chrome has to stay complete, popups included. It applies both to canvas
		// pages and to pages drawn by an Aura template: in both the theme's chrome is absent, and
		// whatever hooks wp_head/wp_footer would land in a page that is not expecting it.
		if ( ! AIPB_Render::use_canvas( get_queried_object_id() ) && ! AIPB_Render::aura_template_id() ) {
			return;
		}

		$hook = current_action();

		global $wp_filter;
		if ( empty( $wp_filter[ $hook ] ) || empty( $wp_filter[ $hook ]->callbacks ) ) {
			return;
		}

		$roots = self::theme_roots( 'get_stylesheet_directory', 'get_template_directory' );
		if ( ! $roots ) {
			return;
		}

		// A copy: we are removing while the hook is running.
		$snapshot = $wp_filter[ $hook ]->callbacks;

		foreach ( $snapshot as $priority => $callbacks ) {
			foreach ( $callbacks as $entry ) {
				$file = self::callback_file( $entry['function'] );
				if ( '' === $file ) {
					continue;
				}

				foreach ( $roots as $root ) {
					if ( 0 !== strpos( $file, $root ) ) {
						continue;
					}

					/**
					 * The last word before removing: useful for saving a theme callback that is
					 * genuinely needed on the canvas too (an analytics snippet, say).
					 */
					if ( apply_filters( 'aipb_strip_theme_callback', true, $file, $hook, $priority ) ) {
						remove_action( $hook, $entry['function'], $priority );
					}
					break;
				}
			}
		}
	}

	/** The hooks to clean on canvas pages. */
	protected static function chrome_hooks() {
		return (array) apply_filters( 'aipb_chrome_hooks', array( 'wp_head', 'wp_body_open', 'wp_footer' ) );
	}

	/** Gutenberg's styles, and the core ones tied to the block editor. */
	protected static function block_handles() {
		return apply_filters( 'aipb_block_style_handles', array(
			'wp-block-library',
			'wp-block-library-theme',
			'wp-blocks',
			'wp-components',
			'wp-reset-editor-styles',
			'wp-editor-classic-layout-styles',
			'wp-block-editor-content',
			// Styles generated by theme.json and by the block supports (colours, spacing, layout).
			'global-styles',
			'core-block-supports',
			'core-block-supports-duotone',
			// Compatibility: the reset WordPress adds to classic themes from 6.1.
			'classic-theme-styles',
			// Ecosystem.
			'wc-blocks-style',
			'wp-emoji-styles',
		) );
	}

	/**
	 * The handles that belong to the theme, recognised by their src: covers child and parent
	 * themes without having to guess the names, which every theme picks for itself.
	 */
	protected static function theme_handles() {
		$styles = wp_styles();
		if ( ! $styles || empty( $styles->registered ) ) {
			return array();
		}

		$roots = self::theme_roots( 'get_stylesheet_directory_uri', 'get_template_directory_uri' );
		if ( ! $roots ) {
			return array();
		}

		$out = array();
		foreach ( $styles->registered as $handle => $dependency ) {
			$src = isset( $dependency->src ) ? (string) $dependency->src : '';
			if ( '' === $src ) {
				continue; // inline-only styles: they belong to no folder
			}

			// Sources relative to the site root ("/wp-content/…"), but not the "//host" form.
			if ( 0 === strpos( $src, '/' ) && 0 !== strpos( $src, '//' ) ) {
				$src = site_url( $src );
			}

			foreach ( $roots as $root ) {
				if ( 0 === strpos( $src, $root ) ) {
					$out[] = $handle;
					break;
				}
			}
		}

		return $out;
	}

	/**
	 * The child and parent theme roots, as paths or as URLs.
	 *
	 * @param string $child  the function giving the child theme's root
	 * @param string $parent the function giving the parent theme's root
	 */
	protected static function theme_roots( $child, $parent ) {
		$roots = array( call_user_func( $child ), call_user_func( $parent ) );

		if ( 'get_stylesheet_directory' === $child ) {
			$roots = array_map( 'wp_normalize_path', $roots );
		}

		return array_filter( array_unique( $roots ) );
	}

	/** The file a callback is defined in, whatever shape it takes. '' when it cannot be worked out. */
	protected static function callback_file( $callback ) {
		try {
			if ( $callback instanceof Closure ) {
				$reflection = new ReflectionFunction( $callback );
			} elseif ( is_string( $callback ) && false !== strpos( $callback, '::' ) ) {
				$reflection = new ReflectionMethod( $callback );
			} elseif ( is_string( $callback ) ) {
				if ( ! function_exists( $callback ) ) {
					return '';
				}
				$reflection = new ReflectionFunction( $callback );
			} elseif ( is_array( $callback ) && 2 === count( $callback ) ) {
				$reflection = new ReflectionMethod( $callback[0], $callback[1] );
			} elseif ( is_object( $callback ) && method_exists( $callback, '__invoke' ) ) {
				$reflection = new ReflectionMethod( $callback, '__invoke' );
			} else {
				return '';
			}

			return wp_normalize_path( (string) $reflection->getFileName() );
		} catch ( Throwable $e ) {
			// A callback that is only half introspectable (internal functions, odd objects): when in
			// doubt we leave it where it is. Better one popup too many than a broken page.
			return '';
		}
	}
}
