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

/**
 * The admin side.
 *  - The "Aura" menu: Design Tokens, Studio (chat/editor), Content model, Settings.
 *  - "Build with Aura": a side box, a row action and an entry in the site's admin bar on the
 *    contents NOT yet claimed by Gutenberg.
 *    The LAYOUT of Aura's contents is always edited in the Studio; their fields (ACF, SEO,
 *    taxonomies) stay on the classic screen.
 *  - The Studio is universal: it creates reusable sections, pages or posts, and edits an existing
 *    content when opened with ?post=ID.
 */
class AIPB_Admin {

	const STUDIO_SLUG = 'aipb-studio';

	/** Hook suffix of the Studio page, to limit the asset enqueue. */
	protected static $studio_hook = '';

	/** Hook suffix of the Brand page, to load the palette editor. */
	protected static $brand_hook = '';

	/** Hook suffix of the Content model page, to load the model editor. */
	protected static $model_hook = '';

	/** Hook suffix of the Settings page, to load its dashboard. */
	protected static $settings_hook = '';

	public static function init() {
		add_action( 'admin_menu', array( __CLASS__, 'menu' ) );
		// Removes the CPT's auto-generated entries ("Aura" and "New AI section"): a late priority so
		// it runs after WP has registered the post type's submenus.
		add_action( 'admin_menu', array( __CLASS__, 'prune_cpt_submenus' ), 999 );
		add_action( 'admin_head', array( __CLASS__, 'hide_studio_menu' ) );
		add_action( 'admin_init', array( 'AIPB_Admin_Settings', 'register_settings' ) );
		add_action( 'admin_post_aipb_switch_editor', array( __CLASS__, 'handle_switch_editor' ) );

		// The design system's versions and its portability: they live outside the settings form,
		// because a download and a file upload are not options.
		add_action( 'admin_post_aipb_brand_export', array( 'AIPB_Admin_Brand', 'handle_export' ) );
		add_action( 'admin_post_aipb_brand_versions', array( 'AIPB_Admin_Brand', 'handle_versions' ) );
		add_action( 'wp_ajax_aipb_server_health', array( 'AIPB_Admin_Settings', 'ajax_server_health' ) );
		// Once the key changes, the cached usage and health belong to another account.
		add_action( 'update_option_' . AIPB_Remote::OPT_KEY, array( 'AIPB_Remote', 'flush_health' ) );
		add_action( 'update_option_' . AIPB_I18n::OPT_LOCALE, array( 'AIPB_I18n', 'flush' ) );
		add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue' ) );
		add_action( 'add_meta_boxes', array( __CLASS__, 'add_open_box' ) );

		// Aura's ENTITIES (sections, patterns, templates) have no classic screen: intercept post.php
		// and the Site Editor's deep links and take them to the Studio. Pages and posts stay on
		// post.php instead — that is where ACF, Yoast and the rest are filled in.
		add_action( 'admin_init', array( __CLASS__, 'maybe_redirect_to_studio' ), 1 );
		add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_site_editor_guard' ) );

		// On that screen the content area must not be shown: the layout belongs to the Studio.
		add_action( 'load-post.php', array( __CLASS__, 'hide_editor_for_aura' ) );

		// The "Open in the builder" row action in the lists of the post types we handle.
		add_filter( 'post_row_actions', array( __CLASS__, 'row_action' ), 10, 2 );
		add_filter( 'page_row_actions', array( __CLASS__, 'row_action' ), 10, 2 );

		// The admin bar, on the site: the way into the builder for the page in front of you, and
		// what THAT page is made of. 81 puts the first right after WordPress's own «Edit page»,
		// which registers at 80.
		add_action( 'admin_bar_menu', array( __CLASS__, 'admin_bar_editor' ), 81 );
		add_action( 'admin_bar_menu', array( __CLASS__, 'admin_bar_sources' ), 100 );
	}

	/* ------------------- The bar: the way into the builder ------------------- */

	/**
	 * «Edit with Aura» on the page being looked at.
	 *
	 * WHAT IT SOLVES. The way in existed twice — a box in post.php and a row action in the lists —
	 * and neither is where you are when you decide to use it. Looking at a page and wanting to
	 * change it, the bar offers «Edit page», which lands on the classic screen with the content area
	 * deliberately hidden (see hide_editor_for_aura): a screen that looks broken to whoever does not
	 * already know that the layout lives somewhere else, and from which the Studio is one more
	 * click and one more thing to have learnt.
	 *
	 * THE SAME THREE ANSWERS AS EVERYWHERE ELSE, because meeting a different offer on a different
	 * screen is how a tool starts to feel arbitrary: Aura's page opens in the Studio, a free page is
	 * an invitation, and a Gutenberg page can be moved across with the warning that move deserves —
	 * the same one the box and the row action already put in front of it.
	 *
	 * Only on the SITE: inside wp-admin these two roads already exist, and a third would just be a
	 * duplicate of whichever screen you are on.
	 */
	public static function admin_bar_editor( $bar ) {
		if ( is_admin() || ! is_admin_bar_showing() || ! is_singular() || ! AIPB_Config::user_can_build() ) {
			return;
		}

		$id   = (int) get_queried_object_id();
		$post = $id ? get_post( $id ) : null;
		if ( ! $post || ! AIPB_Config::is_builder_post_type( $post->post_type ) || ! current_user_can( 'edit_post', $id ) ) {
			return;
		}

		// On a translated page this is the TRANSLATION's id, and that is the right one: it is the
		// page in front of the eyes, and it is the page whose words a save there hands back to the
		// translation plugin (see AIPB_WPML::push_translation).
		if ( AIPB_Ownership::is_aura( $id ) ) {
			self::add_editor_node( $bar, __( 'Edit with Aura', 'aipb' ), self::studio_url( $id ), array(
				'title' => __( 'Open this page in the Studio', 'aipb' ),
			) );

			return;
		}

		$gutenberg = AIPB_Ownership::is_gutenberg( $id );

		self::add_editor_node(
			$bar,
			$gutenberg ? __( 'Move to Aura', 'aipb' ) : __( 'Build with Aura', 'aipb' ),
			self::switch_url( $id, AIPB_Ownership::AURA ),
			$gutenberg
				? array( 'onclick' => 'return confirm(' . wp_json_encode( self::to_aura_warning() ) . ');' )
				: array( 'title' => __( 'Build this page with the AI', 'aipb' ) )
		);
	}

	/** One node, with the bar's own markup for an icon and a label. */
	protected static function add_editor_node( $bar, $label, $url, $meta ) {
		$bar->add_node( array(
			'id'    => 'aipb-edit',
			// The ✨ is Aura's mark on every other way in — the box, the row actions — and it is
			// what somebody looking for it up here is looking for.
			'title' => '<span class="ab-icon" style="top:2px">✨</span><span class="ab-label">' . esc_html( $label ) . '</span>',
			'href'  => $url,
			'meta'  => $meta,
		) );
	}

	/* --------------------- The bar: this page's sources ---------------------- */

	/**
	 * Adds a «Page structure» menu to the bar with the entities that make up the page being looked
	 * at: the FSE template and its parts, the synced patterns used by Aura's sections. Every entry
	 * leads to the RIGHT editor for that entity — the Studio if it belongs to Aura, the site editor
	 * if it belongs to Gutenberg — so nobody has to guess which screen edits the header in front of
	 * their eyes.
	 */
	public static function admin_bar_sources( $bar ) {
		if ( is_admin() || ! is_admin_bar_showing() || ! AIPB_Config::user_can_build() ) {
			return;
		}

		$items = self::page_sources();
		if ( ! $items ) {
			return;
		}

		$bar->add_node( array(
			'id'    => 'aipb-sources',
			'title' => '<span class="ab-icon dashicons dashicons-layout" style="top:2px"></span><span class="ab-label">' . esc_html__( 'Page structure', 'aipb' ) . '</span>',
			'href'  => $items[0]['url'],
			'meta'  => array( 'title' => __( 'Templates, parts and patterns this page is made of', 'aipb' ) ),
		) );

		foreach ( $items as $i => $item ) {
			$bar->add_node( array(
				'parent' => 'aipb-sources',
				'id'     => 'aipb-source-' . $i,
				'title'  => esc_html( $item['label'] ),
				'href'   => $item['url'],
				'meta'   => array( 'title' => $item['hint'] ),
			) );
		}
	}

	/**
	 * The entities the current page is made of, in the order it makes sense to read them.
	 *
	 * On a CANVAS page the theme's template is not used (Aura's sections already carry a header and
	 * a footer): listing it would point at a place where there is nothing to edit, so only the
	 * patterns are listed. Elsewhere the opposite holds.
	 */
	protected static function page_sources() {
		$out     = array();
		$queried = get_queried_object_id();
		$canvas  = is_singular() && class_exists( 'AIPB_Render' ) && AIPB_Render::use_canvas( $queried );

		$template_id = 0;

		if ( ! $canvas ) {
			global $_wp_current_template_id, $_wp_current_template_content;
			if ( ! empty( $_wp_current_template_id ) ) {
				$node = self::block_template_node( (string) $_wp_current_template_id, 'wp_template' );
				if ( $node ) {
					$out[]       = $node;
					$template_id = (int) $node['post_id'];
				}
				foreach ( self::template_part_slugs( (string) $_wp_current_template_content ) as $slug ) {
					$node = self::block_template_node( get_stylesheet() . '//' . $slug, 'wp_template_part' );
					if ( $node ) {
						$out[] = $node;
					}
				}
			}
		}

		// Synced patterns used by Aura's sections. They can be in the content (a page built with
		// Aura) or in the TEMPLATE that shows it: on a product page the header you see belongs to
		// the template, not to the product.
		$seen = array();
		foreach ( array( $template_id, $queried ) as $source_id ) {
			$out = array_merge( $out, self::pattern_nodes( (int) $source_id, $seen ) );
		}

		return $out;
	}

	/** Entries for the synced patterns inserted in the sections of an Aura content. */
	protected static function pattern_nodes( $post_id, &$seen ) {
		if ( $post_id <= 0 ) {
			return array();
		}
		$blocks = json_decode( (string) get_post_meta( $post_id, AIPB_META_BLOCKS, true ), true );
		if ( ! is_array( $blocks ) ) {
			return array();
		}

		$out = array();
		foreach ( $blocks as $b ) {
			if ( empty( $b['ref'] ) || ( isset( $b['type'] ) && 'ref' !== $b['type'] ) ) {
				continue;
			}
			$ref = (int) $b['ref'];
			if ( isset( $seen[ $ref ] ) ) {
				continue;
			}
			$seen[ $ref ] = true;

			$post = get_post( $ref );
			if ( ! $post || ! current_user_can( 'edit_post', $ref ) ) {
				continue;
			}
			$aura  = AIPB_Ownership::is_aura( $ref );
			$out[] = array(
				/* translators: %s: the pattern's title. */
				'label'   => sprintf( __( 'Pattern: %s', 'aipb' ), $post->post_title ? $post->post_title : __( 'untitled', 'aipb' ) ),
				'url'     => $aura ? self::studio_url( $ref ) : self::site_editor_url( $ref ),
				'hint'    => $aura ? __( 'Opens in the Studio', 'aipb' ) : __( 'Opens in the site editor', 'aipb' ),
				'post_id' => $ref,
			);
		}
		return $out;
	}

	/** An entry for an FSE template or one of its parts. null when the template does not exist. */
	protected static function block_template_node( $id, $post_type ) {
		if ( ! function_exists( 'get_block_template' ) ) {
			return null;
		}
		$tpl = get_block_template( $id, $post_type );
		if ( ! $tpl ) {
			return null;
		}

		// Materialised in the DB? Only then can it belong to Aura and be opened in the Studio.
		$pid  = ! empty( $tpl->wp_id ) ? (int) $tpl->wp_id : 0;
		$aura = $pid && AIPB_Ownership::is_aura( $pid );
		if ( $pid && ! current_user_can( 'edit_post', $pid ) ) {
			return null;
		}
		if ( ! $pid && ! current_user_can( 'edit_theme_options' ) ) {
			return null;
		}

		$title = $tpl->title ? $tpl->title : $tpl->slug;

		return array(
			'label'   => sprintf(
				/* translators: %s: the template's (or part's) title. */
				'wp_template_part' === $post_type ? __( 'Part: %s', 'aipb' ) : __( 'Template: %s', 'aipb' ),
				$title
			),
			'url'     => $aura
				? self::studio_url( $pid )
				: admin_url( 'site-editor.php?p=' . rawurlencode( '/' . $post_type . '/' . $id ) . '&canvas=edit' ),
			'hint'    => $aura ? __( 'Opens in the Studio', 'aipb' ) : __( 'Opens in the site editor', 'aipb' ),
			'post_id' => $pid,
		);
	}

	/** Slugs of the template parts a template's markup calls for. */
	protected static function template_part_slugs( $content ) {
		if ( '' === trim( $content ) || ! function_exists( 'parse_blocks' ) ) {
			return array();
		}
		$slugs = array();
		self::collect_part_slugs( parse_blocks( $content ), $slugs );
		return array_values( array_unique( $slugs ) );
	}

	protected static function collect_part_slugs( $blocks, &$slugs ) {
		foreach ( (array) $blocks as $block ) {
			if ( isset( $block['blockName'] ) && 'core/template-part' === $block['blockName'] && ! empty( $block['attrs']['slug'] ) ) {
				$slugs[] = sanitize_key( $block['attrs']['slug'] );
			}
			if ( ! empty( $block['innerBlocks'] ) ) {
				self::collect_part_slugs( $block['innerBlocks'], $slugs );
			}
		}
	}

	/* ---------------------- Patterns/Templates -> Studio --------------------- */

	/**
	 * Resolves a Site Editor entity's postId into an editable post ID.
	 *  - wp_block / ai_section: already numeric.
	 *  - wp_template / wp_template_part: an id shaped "theme//slug"; if it is still a theme file (no
	 *    post in the DB) it MATERIALISES it into a wp_template post so the Studio can open it.
	 */
	public static function resolve_component_id( $post_type, $raw_id ) {
		if ( ! AIPB_Config::is_template_type( $post_type ) ) {
			return (int) $raw_id;
		}
		if ( is_numeric( $raw_id ) ) {
			return (int) $raw_id;
		}
		if ( ! function_exists( 'get_block_template' ) ) {
			return 0;
		}
		$tpl = get_block_template( (string) $raw_id, $post_type );
		if ( ! $tpl ) {
			return 0;
		}
		if ( ! empty( $tpl->wp_id ) ) {
			return (int) $tpl->wp_id;
		}
		// A template that is still a theme file: create it in the DB (as the Site Editor does on the
		// first edit).
		$post_id = wp_insert_post( array(
			'post_type'    => $post_type,
			'post_status'  => 'publish',
			'post_title'   => $tpl->title ? $tpl->title : $tpl->slug,
			'post_name'    => $tpl->slug,
			'post_content' => $tpl->content,
			'tax_input'    => array( 'wp_theme' => get_stylesheet() ),
		), true );
		return is_wp_error( $post_id ) ? 0 : (int) $post_id;
	}

	/**
	 * Redirects to the Studio the requests to edit the ENTITIES Aura owns.
	 *
	 * Sections, patterns and FSE templates have nothing but their layout: their editing screen is
	 * the Studio, and post.php would show an empty form. Pages and posts are the opposite: there
	 * post.php is the only place where the ACF fields, the Yoast analysis, the taxonomies and the
	 * featured image live. From there you enter the builder with "Edit with Aura".
	 */
	public static function maybe_redirect_to_studio() {
		if ( ! AIPB_Config::user_can_build() ) {
			return;
		}
		global $pagenow;

		// post.php?post=ID&action=edit on an entity that belongs to Aura.
		if ( 'post.php' === $pagenow && isset( $_GET['action'], $_GET['post'] ) && 'edit' === $_GET['action'] ) {
			$pid = (int) $_GET['post'];
			if ( AIPB_Config::is_studio_native( get_post_type( $pid ) )
				&& AIPB_Ownership::is_aura( $pid )
				&& current_user_can( 'edit_post', $pid ) ) {
				wp_safe_redirect( self::studio_url( $pid ) );
				exit;
			}
		}

		if ( 'site-editor.php' === $pagenow ) {
			self::maybe_redirect_site_editor();
		}
	}

	/**
	 * The Site Editor's deep links/refreshes. Two possible shapes depending on the WP version:
	 *  - by query: ?postType=wp_block&postId=123
	 *  - by path:  ?p=/wp_block/123  (or /wp_template/theme//slug)
	 */
	protected static function maybe_redirect_site_editor() {
		$pt  = '';
		$raw = '';

		if ( isset( $_GET['postType'], $_GET['postId'] ) ) {
			$pt  = sanitize_key( wp_unslash( $_GET['postType'] ) );
			$raw = wp_unslash( $_GET['postId'] );
		} elseif ( isset( $_GET['p'] ) ) {
			$path = wp_unslash( $_GET['p'] );
			if ( preg_match( '#^/(wp_block|wp_template|wp_template_part|' . preg_quote( AIPB_CPT::POST_TYPE, '#' ) . ')/(.+)$#', $path, $m ) ) {
				$pt  = $m[1];
				$raw = $m[2];
			}
		}

		if ( ! $pt || '' === $raw || ! AIPB_Config::is_studio_native( $pt ) ) {
			return;
		}

		// Only if the entity already belongs to Aura: a pattern or a template born in Gutenberg
		// stays in Gutenberg. No materialisation here, otherwise the mere act of opening a theme
		// template would create a post and tear it away from the Site Editor.
		$pid = is_numeric( $raw ) ? (int) $raw : self::existing_template_id( $pt, $raw );
		if ( $pid && AIPB_Ownership::is_aura( $pid ) && current_user_can( 'edit_post', $pid ) ) {
			wp_safe_redirect( self::studio_url( $pid ) );
			exit;
		}
	}

	/**
	 * On the classic screen of an Aura page, removes the content area.
	 *
	 * That page's layout lives in Aura's meta; post_content is only its SNAPSHOT, regenerated on
	 * every save from the Studio (see AIPB_Render::sync_post_content). Leaving the editor visible
	 * would invite editing a copy that is going to be overwritten — and worse: TinyMCE rewrites that
	 * HTML and throws its <style> tags away, so one "Update" would be enough to ruin the preview and
	 * the page served with the plugin off.
	 *
	 * With the support removed, the field is not even submitted: post_content stays intact even when
	 * saving from the classic screen. Everything else — title, ACF, Yoast, taxonomies, featured
	 * image — works as on any other page.
	 */
	public static function hide_editor_for_aura() {
		$pid = isset( $_GET['post'] ) ? (int) $_GET['post'] : ( isset( $_POST['post_ID'] ) ? (int) $_POST['post_ID'] : 0 );
		if ( ! $pid ) {
			return;
		}
		$pt = get_post_type( $pid );
		if ( $pt && ! AIPB_Config::is_studio_native( $pt ) && AIPB_Ownership::is_aura( $pid ) ) {
			remove_post_type_support( $pt, 'editor' );
		}
	}

	/**
	 * The site editor's URL for an entity that lives there: templates, template parts and patterns.
	 * '' for everything else (pages, posts), which is edited in post.php.
	 *
	 * Templates are addressed with "theme//slug", not with the numeric ID: it is the form the Site
	 * Editor uses in its own URLs and the only one that also resolves when the post has just been
	 * materialised from the theme's file.
	 */
	public static function site_editor_url( $post_id ) {
		$pt = get_post_type( (int) $post_id );

		if ( AIPB_Config::is_template_type( $pt ) ) {
			$theme = get_stylesheet();
			$terms = get_the_terms( (int) $post_id, 'wp_theme' );
			if ( $terms && ! is_wp_error( $terms ) ) {
				$theme = $terms[0]->name;
			}
			$id = $theme . '//' . get_post_field( 'post_name', (int) $post_id );
			return admin_url( 'site-editor.php?p=' . rawurlencode( '/' . $pt . '/' . $id ) . '&canvas=edit' );
		}

		if ( 'wp_block' === $pt ) {
			return admin_url( 'site-editor.php?p=' . rawurlencode( '/wp_block/' . (int) $post_id ) . '&canvas=edit' );
		}

		return '';
	}

	/** The post ID of an FSE template already materialised in the DB, 0 if it still lives in the theme. */
	protected static function existing_template_id( $post_type, $raw_id ) {
		if ( ! function_exists( 'get_block_template' ) ) {
			return 0;
		}
		$tpl = get_block_template( (string) $raw_id, $post_type );
		return ( $tpl && ! empty( $tpl->wp_id ) ) ? (int) $tpl->wp_id : 0;
	}

	/* ------------------------ Switching editor (the way out) ----------------- */

	/** A signed URL that assigns a content to an editor and opens it there. */
	public static function switch_url( $post_id, $to ) {
		$url = admin_url( 'admin-post.php?action=aipb_switch_editor&post=' . (int) $post_id . '&to=' . rawurlencode( $to ) );
		return wp_nonce_url( $url, 'aipb_switch_' . (int) $post_id );
	}

	/**
	 * Assigns the content to the requested editor and takes us inside it.
	 *
	 * The move is reversible: Aura's meta stay where they are, and post_content keeps the HTML
	 * snapshot of the last save from the Studio, so switching to Gutenberg does not empty the page.
	 */
	public static function handle_switch_editor() {
		$pid = isset( $_GET['post'] ) ? (int) $_GET['post'] : 0;
		$to  = isset( $_GET['to'] ) ? sanitize_key( wp_unslash( $_GET['to'] ) ) : '';

		check_admin_referer( 'aipb_switch_' . $pid );

		if ( ! $pid || ! AIPB_Config::user_can_build() || ! current_user_can( 'edit_post', $pid ) ) {
			wp_die( esc_html__( 'Permission denied', 'aipb' ), '', array( 'response' => 403 ) );
		}
		if ( ! AIPB_Config::is_builder_post_type( get_post_type( $pid ) ) ) {
			wp_die( esc_html__( 'This content cannot be handled by the builder', 'aipb' ), '', array( 'response' => 400 ) );
		}

		if ( AIPB_Ownership::AURA === $to ) {
			AIPB_Ownership::claim( $pid, AIPB_Ownership::AURA );
			// "Build with Aura" clicked on a page just created: WordPress keeps it as an auto-draft
			// until it saves it itself. Promoting it to a draft keeps it in the lists even if the
			// user leaves the Studio without saving.
			if ( 'auto-draft' === get_post_status( $pid ) ) {
				wp_update_post( array(
					'ID'          => $pid,
					'post_status' => 'draft',
					'post_title'  => '' !== trim( (string) get_post_field( 'post_title', $pid ) )
						? get_post_field( 'post_title', $pid )
						: __( 'New content', 'aipb' ),
				) );
			}
			wp_safe_redirect( self::studio_url( $pid ) );
			exit;
		}

		if ( AIPB_Ownership::GUTENBERG === $to ) {
			AIPB_Ownership::claim( $pid, AIPB_Ownership::GUTENBERG );
			// FSE templates and patterns are not edited in post.php: their editor is the site's.
			// Sending them there would mean handing them back to "Gutenberg" inside a screen that
			// does not work for them.
			$se = self::site_editor_url( $pid );
			wp_safe_redirect( $se ? $se : admin_url( 'post.php?post=' . $pid . '&action=edit' ) );
			exit;
		}

		wp_safe_redirect( admin_url() );
		exit;
	}

	/**
	 * The version stamped on an asset's URL — which is what decides whether the browser fetches it
	 * again or serves the copy it already has.
	 *
	 * In production it is the plugin's version, which is right: the files change together with it.
	 * In DEVELOPMENT that is exactly wrong, and silently so. `AIPB_VERSION` does not move while the
	 * plugin is being worked on, so an edited `studio.js` keeps the same `?ver=` it had before, the
	 * browser answers out of its cache, and the new code never runs. Nothing errors. The feature you
	 * just wrote is simply not there — and the reasonable conclusion, that it does not work, is the
	 * wrong one, which is what makes the hour that follows so expensive.
	 *
	 * So with WP_DEBUG or SCRIPT_DEBUG on, the file's own modification time is used: it changes
	 * exactly when the file does, which is the definition of the thing being asked.
	 *
	 * @param string $file the path relative to the plugin's root, e.g. 'assets/studio.js'.
	 *
	 * @return string
	 */
	protected static function asset_version( $file ) {
		$dev = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) || ( defined( 'WP_DEBUG' ) && WP_DEBUG );
		if ( ! $dev ) {
			return AIPB_VERSION;
		}

		$path = AIPB_PATH . $file;
		$time = is_readable( $path ) ? filemtime( $path ) : 0;

		return $time ? AIPB_VERSION . '.' . $time : AIPB_VERSION;
	}

	/**
	 * The Site Editor is an SPA: clicking a pattern/template does not reload the page, so the PHP
	 * redirect never fires. This script watches the URL and, when it enters the editing of an AURA
	 * entity, performs a full navigation to the resolver -> redirect to the Studio. Everything else
	 * goes on opening normally in the Site Editor.
	 */
	public static function enqueue_site_editor_guard( $hook ) {
		global $pagenow;
		if ( 'site-editor.php' !== $hook && 'site-editor.php' !== $pagenow ) {
			return;
		}
		if ( ! AIPB_Config::user_can_build() ) {
			return;
		}
		wp_enqueue_script( 'aipb-site-editor-guard', AIPB_URL . 'assets/site-editor-guard.js', array(), self::asset_version( 'assets/site-editor-guard.js' ), true );
		wp_localize_script( 'aipb-site-editor-guard', 'AIPB_SE', array(
			'studio' => esc_url_raw( self::studio_url() ),
			'types'  => array_values( AIPB_Config::studio_native_types() ),
			// Only these entities are diverted: patterns and templates owned by Aura.
			'owned'  => AIPB_Ownership::studio_entity_keys(),
			// For all the OTHERS the choice is offered, instead of deciding for the user.
			'i18n'   => array(
				'title' => __( 'Edit it with Aura?', 'aipb' ),
				// Honest about the point that matters: it is not a conversion, it is a rebuild.
				'desc'  => __( 'The Studio does not convert the blocks that are there now: you start again from an empty canvas and rebuild it in sections, with the AI. The template passes to Aura and you can hand it back to Gutenberg whenever you like.', 'aipb' ),
				'open'  => __( 'Open in Aura', 'aipb' ),
				'stay'  => __( 'Stay here', 'aipb' ),
			),
		) );
	}

	/* --------------------------------- URLs ---------------------------------- */

	/** The Studio's URL, optionally pointed at a specific content. */
	public static function studio_url( $post_id = 0 ) {
		$url = admin_url( 'edit.php?post_type=' . AIPB_CPT::POST_TYPE . '&page=' . self::STUDIO_SLUG );
		return $post_id ? add_query_arg( 'post', (int) $post_id, $url ) : $url;
	}

	public static function settings_url() {
		return admin_url( 'edit.php?post_type=' . AIPB_CPT::POST_TYPE . '&page=aipb-settings' );
	}

	/** The Design Tokens page. The Studio needs it to send people there, not only to mention it. */
	public static function tokens_url() {
		return admin_url( 'edit.php?post_type=' . AIPB_CPT::POST_TYPE . '&page=aipb-brand' );
	}

	/* --------------------------------- Menu ---------------------------------- */

	public static function menu() {
		$parent = 'edit.php?post_type=' . AIPB_CPT::POST_TYPE;

		// Design Tokens is registered FIRST: with the CPT's list entry removed, WordPress points the
		// top-level "Aura" link at the first submenu. That way a click on the main entry opens Design
		// Tokens directly instead of an empty Studio / the Sections list.
		self::$brand_hook = add_submenu_page(
			$parent,
			__( 'Design Tokens', 'aipb' ),
			__( 'Design Tokens', 'aipb' ),
			'manage_options',
			'aipb-brand',
			array( 'AIPB_Admin_Brand', 'render' )
		);

		self::$studio_hook = add_submenu_page(
			$parent,
			__( 'Studio', 'aipb' ),
			__( 'Studio', 'aipb' ),
			AIPB_Config::required_cap(),
			self::STUDIO_SLUG,
			array( __CLASS__, 'render_studio' )
		);

		self::$model_hook = add_submenu_page(
			$parent,
			__( 'Content model', 'aipb' ),
			__( 'Contents', 'aipb' ),
			'manage_options',
			AIPB_Admin_Model::SLUG,
			array( 'AIPB_Admin_Model', 'render' )
		);

		// The form is handled on `load-`, before a single byte of the page is printed: that is what
		// lets a successful save end in a redirect, so a refresh does not re-submit the whole model.
		if ( self::$model_hook ) {
			add_action( 'load-' . self::$model_hook, array( 'AIPB_Admin_Model', 'handle' ) );
		}

		self::$settings_hook = add_submenu_page(
			$parent,
			__( 'Aura settings', 'aipb' ),
			__( 'Settings', 'aipb' ),
			'manage_options',
			'aipb-settings',
			array( 'AIPB_Admin_Settings', 'render' )
		);
	}

	/** Removes the CPT's auto-generated menu entries: the list and "New AI section". */
	public static function prune_cpt_submenus() {
		$parent = 'edit.php?post_type=' . AIPB_CPT::POST_TYPE;
		remove_submenu_page( $parent, $parent );
		remove_submenu_page( $parent, 'post-new.php?post_type=' . AIPB_CPT::POST_TYPE );
	}

	/**
	 * Hides the "Studio" entry in the menu: opened with no context it would be empty and useless
	 * (to create something you start from a pattern or a template part, which redirect us there).
	 * CSS only: the page stays REGISTERED under its parent, otherwise the routing of
	 * `edit.php?…&page=aipb-studio` and the capability check (get_admin_page_parent) would fail with
	 * "you are not allowed".
	 */
	public static function hide_studio_menu() {
		// Hide ONLY the "Studio" submenu entry. The first submenu (Design Tokens) determines the
		// top-level "Aura" link, so the Studio has to be hidden without touching the main entry: an
		// unscoped selector would hit that one too.
		// Matching on a substring because esc_url() encodes "&" as "&#038;" in the href.
		$slug = esc_attr( self::STUDIO_SLUG );
		printf(
			'<style>#adminmenu .wp-submenu li:has(> a[href*="page=%1$s"]){display:none;}'
			. '#adminmenu .wp-submenu a[href*="page=%1$s"]{display:none;}</style>',
			$slug
		);
	}

	/* ------------------------------ Build with Aura -------------------------- */

	public static function add_open_box() {
		if ( ! AIPB_Config::user_can_build() ) {
			return;
		}
		foreach ( AIPB_Config::builder_post_types() as $pt ) {
			// FSE templates do not go through post.php: no box.
			if ( AIPB_Config::is_template_type( $pt ) ) {
				continue;
			}
			add_meta_box(
				'aipb-open',
				__( 'Aura', 'aipb' ),
				array( __CLASS__, 'render_open_box' ),
				$pt,
				'side',
				'high'
			);
		}
	}

	/**
	 * The side box in WordPress's editor. Three situations, three different things to say:
	 *  - an AURA page: this is where the fields are filled in, the layout is edited in the Studio;
	 *  - a Gutenberg page: it can move to Aura, but we warn first;
	 *  - a page still free: an invitation to build it with the AI.
	 */
	public static function render_open_box( $post ) {
		// The layout belongs to the Studio: from here you go there, nothing is converted.
		if ( AIPB_Ownership::is_aura( $post->ID ) ) {
			?>
			<p style="margin-top:0;"><?php echo esc_html__( 'This page\'s layout is built with Aura. Here you fill in the page\'s fields — title, ACF, SEO, taxonomies — while the structure is edited in the Studio.', 'aipb' ); ?></p>
			<a href="<?php echo esc_url( self::studio_url( $post->ID ) ); ?>"
				class="button button-primary button-large"
				style="width:100%;text-align:center;box-sizing:border-box;">
				<?php echo '✨ ' . esc_html__( 'Edit with Aura', 'aipb' ); ?>
			</a>
			<?php
			return;
		}

		$is_gutenberg = AIPB_Ownership::is_gutenberg( $post->ID );
		$url          = self::switch_url( $post->ID, AIPB_Ownership::AURA );
		?>
		<?php if ( $is_gutenberg ) : ?>
			<p style="margin-top:0;"><?php echo esc_html__( 'This page is handled by the WordPress editor.', 'aipb' ); ?></p>
		<?php else : ?>
			<p style="margin-top:0;"><?php echo esc_html__( 'Build this page with the AI. Once you start, the WordPress editor will no longer be used on this page.', 'aipb' ); ?></p>
		<?php endif; ?>
		<a href="<?php echo esc_url( $url ); ?>"
			class="button <?php echo $is_gutenberg ? '' : 'button-primary'; ?> button-large"
			style="width:100%;text-align:center;box-sizing:border-box;"
			<?php if ( $is_gutenberg ) : ?>onclick="return confirm(<?php echo esc_attr( wp_json_encode( self::to_aura_warning() ) ); ?>);"<?php endif; ?>>
			<?php echo $is_gutenberg ? esc_html__( 'Move to Aura', 'aipb' ) : esc_html__( 'Build with Aura', 'aipb' ); ?>
		</a>
		<?php
	}

	/** What is being agreed to when a page moves from Gutenberg to Aura. */
	protected static function to_aura_warning() {
		return __( 'This page will move to Aura and will no longer be editable with Gutenberg. The current content stays saved and you can go back at any time. Proceed?', 'aipb' );
	}

	/**
	 * Row actions in the lists: opening in the Studio what belongs to Aura, offering the conversion
	 * (both ways) on the rest. They are the only two ways of changing editor.
	 */
	public static function row_action( $actions, $post ) {
		if ( ! AIPB_Config::is_builder_post_type( $post->post_type )
			|| ! AIPB_Config::user_can_build()
			|| ! current_user_can( 'edit_post', $post->ID ) ) {
			return $actions;
		}

		if ( AIPB_Ownership::is_aura( $post->ID ) ) {
			$actions['aipb_open'] = '<a href="' . esc_url( self::studio_url( $post->ID ) ) . '">✨ ' . esc_html__( 'Edit with Aura', 'aipb' ) . '</a>';

			// The sections CPT has no alternative editor: there is no way out to offer.
			if ( AIPB_CPT::POST_TYPE !== $post->post_type ) {
				$confirm                = __( 'This page will go back to the WordPress editor, with Aura\'s content converted into an HTML block. Aura\'s project stays saved and you can reopen it. Proceed?', 'aipb' );
				$actions['aipb_switch'] = '<a href="' . esc_url( self::switch_url( $post->ID, AIPB_Ownership::GUTENBERG ) ) . '"'
					. ' onclick="return confirm(' . esc_attr( wp_json_encode( $confirm ) ) . ');">'
					. esc_html__( 'Back to Gutenberg', 'aipb' ) . '</a>';
			}
			return $actions;
		}

		$attr = AIPB_Ownership::is_gutenberg( $post->ID )
			? ' onclick="return confirm(' . esc_attr( wp_json_encode( self::to_aura_warning() ) ) . ');"'
			: '';

		$actions['aipb_open'] = '<a href="' . esc_url( self::switch_url( $post->ID, AIPB_Ownership::AURA ) ) . '"' . $attr . '>✨ '
			. esc_html__( 'Build with Aura', 'aipb' ) . '</a>';

		return $actions;
	}

	/* -------------------------------- Studio --------------------------------- */

	public static function render_studio() {
		echo '<div id="aipb-studio" class="aipb-studio-root"></div>';
	}

	/**
	 * Turns off the emoji replacement, on our screens only.
	 *
	 * WordPress replaces every emoji in the DOM with an <img> from the s.w.org CDN when the browser
	 * does not pass its support test — and the test uses an emoji from the very latest Unicode
	 * release, which operating systems receive months later. So the test fails even on an up-to-date
	 * Mac, and every ✏️/✅/🌐 in the chat becomes a remote image: if that request fails (offline, an
	 * extension blocking s.w.org, a timeout) the broken-image icon appears in its place. Our emoji
	 * are interface text, not content: we want them drawn by the system font, which already has them.
	 *
	 * It runs on `admin_enqueue_scripts`, which comes before `admin_print_scripts`: the moment works.
	 */
	protected static function disable_emoji_replacement() {
		remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
		remove_action( 'admin_print_styles', 'print_emoji_styles' );
	}

	public static function enqueue( $hook ) {
		// Our screens use emoji as interface text: no replacement.
		if ( $hook === self::$brand_hook || $hook === self::$studio_hook || $hook === self::$settings_hook ) {
			self::disable_emoji_replacement();
		}

		if ( $hook === self::$brand_hook ) {
			self::enqueue_brand();
			return;
		}

		if ( $hook === self::$model_hook ) {
			self::enqueue_model();
			return;
		}

		if ( $hook === self::$studio_hook ) {
			self::enqueue_studio();
			return;
		}

		if ( $hook === self::$settings_hook ) {
			self::enqueue_settings();
		}
	}

	/** The Settings page: the usage dashboard and the service connection. */
	protected static function enqueue_settings() {
		wp_enqueue_style( 'aipb-settings', AIPB_URL . 'assets/settings.css', array(), self::asset_version( 'assets/settings.css' ) );
		wp_enqueue_script( 'aipb-settings', AIPB_URL . 'assets/settings.js', array( 'wp-i18n' ), self::asset_version( 'assets/settings.js' ), true );
		wp_set_script_translations( 'aipb-settings', 'aipb', AIPB_PATH . 'languages' );
		wp_localize_script( 'aipb-settings', 'AIPB_SETTINGS', array(
			'nonce' => wp_create_nonce( 'aipb_server_health' ),
		) );
	}

	/** The Content model page: the editor for types, taxonomies and fields. */
	protected static function enqueue_model() {
		wp_enqueue_style( 'aipb-model', AIPB_URL . 'assets/model.css', array( 'dashicons' ), self::asset_version( 'assets/model.css' ) );
		wp_enqueue_script( 'aipb-model', AIPB_URL . 'assets/model.js', array( 'wp-i18n' ), self::asset_version( 'assets/model.js' ), true );
		wp_set_script_translations( 'aipb-model', 'aipb', AIPB_PATH . 'languages' );

		// wp_add_inline_script() and not wp_localize_script(): the second one casts the top-level
		// scalars to strings, and this state is read as data — a `false` that arrives as "" is
		// survivable, an integer that arrives as a string is the kind of thing that only shows up as
		// a wrong comparison three screens later.
		wp_add_inline_script(
			'aipb-model',
			'window.AIPB_MODEL = ' . wp_json_encode( AIPB_Admin_Model::state() ) . ';',
			'before'
		);
	}

	/** The Brand page: the visual editor for the palette and the fonts. */
	protected static function enqueue_brand() {
		wp_enqueue_style( 'aipb-brand', AIPB_URL . 'assets/brand.css', array(), self::asset_version( 'assets/brand.css' ) );
		wp_enqueue_script( 'aipb-brand', AIPB_URL . 'assets/brand.js', array( 'wp-i18n' ), self::asset_version( 'assets/brand.js' ), true );
		wp_set_script_translations( 'aipb-brand', 'aipb', AIPB_PATH . 'languages' );
		wp_localize_script( 'aipb-brand', 'AIPB_BRAND', array(
			'palette'     => AIPB_Design::palette(),
			'option'      => AIPB_Design::OPT_PALETTE,
			'fonts'       => AIPB_Design::fonts(),
			'fontsOption' => AIPB_Design::OPT_FONTS,
			'fontCatalog' => AIPB_Design::google_font_catalog(),
			// To check/install fonts outside the catalogue over REST.
			'rest'        => esc_url_raw( rest_url( AIPB_REST::NS ) ),
			'nonce'       => wp_create_nonce( 'wp_rest' ),
		) );
	}

	protected static function enqueue_studio() {
		// WordPress's native code editor (CodeMirror). The text/html type loads the "htmlmixed" mode,
		// which already includes css and javascript.
		wp_enqueue_code_editor( array( 'type' => 'text/html' ) );

		// The native Media Library, for the image picker in the editable fields.
		wp_enqueue_media();

		wp_enqueue_style( 'aipb-studio', AIPB_URL . 'assets/studio.css', array( 'wp-codemirror' ), self::asset_version( 'assets/studio.css' ) );
		wp_enqueue_script( 'aipb-studio', AIPB_URL . 'assets/studio.js', array( 'wp-codemirror', 'code-editor', 'wp-i18n' ), self::asset_version( 'assets/studio.js' ), true );
		wp_set_script_translations( 'aipb-studio', 'aipb', AIPB_PATH . 'languages' );

		// The Style panel: it mounts AFTER the Studio, whose window.AIPBStudio bridge it uses.
		wp_enqueue_script( 'aipb-studio-style', AIPB_URL . 'assets/studio-style.js', array( 'aipb-studio' ), self::asset_version( 'assets/studio-style.js' ), true );
		wp_set_script_translations( 'aipb-studio-style', 'aipb', AIPB_PATH . 'languages' );

		$target_id = self::studio_target_id();

		wp_localize_script( 'aipb-studio', 'AIPB', array(
			'root'  => esc_url_raw( rest_url( AIPB_REST::NS ) ),
			'nonce' => wp_create_nonce( 'wp_rest' ),
			// Is the service configured? Without it the Studio still opens — the existing sections
			// can be looked at and edited by hand — but generation does not, and saying so straight
			// away is worth more than letting the problem be discovered on the first attempt.
			'configured' => AIPB_Remote::configured(),
			// What the service accepts, from /health: the attachments it cannot read are not even
			// offered. Empty if it does not answer: in that case nothing is promised.
			'features'   => array_values( (array) ( AIPB_Remote::health()['features'] ?? array() ) ),
			// How many sections to generate in parallel. The limit is the SERVICE's, and it is the
			// service that declares it in /health: asking for more than it grants does not go
			// faster, it ends the page with the sections beyond the cap refused — which is exactly
			// what happened while this number was a local constant of three against a service that
			// allowed two. Three stays only as the fallback for a service older than this plugin,
			// which does not declare it yet.
			'genConcurrency' => max( 1, (int) apply_filters( 'aipb_gen_concurrency', (int) ( AIPB_Remote::health()['max_concurrent'] ?? 3 ) ) ),
			// Can they create content types? Registering one changes the site's structure — it
			// appears in WordPress's menu and in the permalinks — so it wants manage_options. To
			// whoever does not have it the Studio does not even offer the choice: a button that
			// answers 403 is worse than a button that is not there.
			'canModel' => current_user_can( 'manage_options' ),
			// Does the site already have a character written by somebody? If not, at the end of a
			// page the Studio offers to keep the one the plan decided — see /direction.
			'hasDirection' => '' !== trim( AIPB_Design::direction() ),
			'settings'     => esc_url_raw( self::settings_url() ),
			// Where a design system value is changed. The Studio sends people there when the request
			// is about a token: changing it there holds for the whole site and costs no generation.
			'tokens'    => esc_url_raw( self::tokens_url() ),
			// The Studio's base URL (with no post): the client appends &post=ID to it to open a
			// specific pattern in a new window ("Edit the pattern").
			'studioUrl' => esc_url_raw( self::studio_url() ),
			'types'     => self::studio_types(),
			'targetId'  => $target_id,
			'exitUrl'   => esc_url_raw( self::exit_url_for( $target_id ) ),
			'tokensCss' => AIPB_Design::css_variables(),
			'fontsUrl'  => AIPB_Design::google_fonts_url(),
			// The sections runtime for the preview (it runs the <script>s confined to the section).
			'runtimeJs' => AIPB_Render::runtime_js(),
			// The scroll reveals. In the preview too, otherwise an entrance could only be judged
			// after publishing — which is where it is least convenient to find out it is wrong.
			// The brand effects ride along for the same reason, with the fallbacks sewn in.
			'motionCss' => AIPB_Render::motion_css() . "\n" . AIPB_Design::add_fallbacks( AIPB_Effects::compiled_css() ),
		) );
	}

	/**
	 * The content types the Studio can create (for the "Save as" selector).
	 *
	 * ai_section is legacy: it is no longer offered; the new reusable ones are Patterns (wp_block).
	 */
	protected static function studio_types() {
		$labels = array( 'wp_block' => __( 'Pattern', 'aipb' ) );
		$types  = array();

		foreach ( AIPB_Config::builder_post_types() as $pt ) {
			if ( AIPB_CPT::POST_TYPE === $pt ) {
				continue;
			}
			$obj = get_post_type_object( $pt );
			if ( $obj ) {
				$types[] = array(
					'type'  => $pt,
					'label' => isset( $labels[ $pt ] ) ? $labels[ $pt ] : $obj->labels->singular_name,
				);
			}
		}

		return $types;
	}

	/**
	 * Which content the Studio opens on: the one passed by "Open in the builder", or the
	 * pattern/template arriving from the Site Editor (the interceptor passes se_pt/se_id).
	 *
	 * There the materialisation is intended: it is the explicit route for bringing an FSE template
	 * into Aura, so the resolved entity is claimed as well.
	 */
	protected static function studio_target_id() {
		$target_id = isset( $_GET['post'] ) ? (int) $_GET['post'] : 0;

		if ( ! $target_id && isset( $_GET['se_pt'], $_GET['se_id'] ) ) {
			$se_pt = sanitize_key( wp_unslash( $_GET['se_pt'] ) );
			if ( AIPB_Config::is_studio_native( $se_pt ) ) {
				$target_id = self::resolve_component_id( $se_pt, wp_unslash( $_GET['se_id'] ) );
				if ( $target_id && current_user_can( 'edit_post', $target_id ) ) {
					AIPB_Ownership::claim( $target_id, AIPB_Ownership::AURA );
				}
			}
		}

		return ( $target_id && ! current_user_can( 'edit_post', $target_id ) ) ? 0 : $target_id;
	}

	/**
	 * Where the Studio's "Exit" button goes back to: always a list, never post.php (which would send
	 * us to the Studio again).
	 *
	 * The Studio is full screen (it hides the menu and the toolbar): a way out is needed.
	 */
	protected static function exit_url_for( $target_id ) {
		$pt = $target_id ? get_post_type( $target_id ) : '';
		if ( ! $pt || AIPB_CPT::POST_TYPE === $pt ) {
			return admin_url( 'edit.php?post_type=' . AIPB_CPT::POST_TYPE );
		}
		if ( AIPB_Config::is_template_type( $pt ) ) {
			return admin_url( 'site-editor.php' );
		}
		return admin_url( 'edit.php?post_type=' . $pt );
	}
}
