<?php

if (!defined('ABSPATH')) {
    die('You are not allowed to call this page directly.');
}

/**
 * Handles FSE (Full Site Editing) block templates and template parts registration for MemberPress.
 *
 * The whole features should be limited to WP 6.7+ because we need to use the new template registration API.
 * We could "easily" achieve the same functionality for WP 6.6 and lower by using the legacy hooks,
 * but I think this is a good way to force users to update to WP 6.7+ at least.
 *
 * This controller manages:
 * - Block template registration using WordPress 6.7+ Template Registration API
 * - Template parts support for headers and footers (using legacy hooks for pre-6.7 compatibility)
 * - Template customization and override handling
 * - Integration with WordPress Site Editor
 */
class MeprFseTemplatesCtrl extends MeprBaseCtrl
{
    /**
     * Block templates directory name.
     *
     * @var string
     */
    private const BLOCK_TEMPLATES_DIR = 'block-templates';

    /**
     * Block template parts directory name.
     *
     * @var string
     */
    private const BLOCK_TEMPLATE_PARTS_DIR = 'block-template-parts';

    /**
     * Namespace for template registration.
     *
     * @var string
     */
    private const NAMESPACE = 'memberpress';

    /**
     * Block templates configuration.
     *
     * @var array
     */
    private $block_templates_config;

    /**
     * Whether {@see self::register_fse_mode_dependent_hooks()} has completed (avoids duplicate hooks).
     *
     * @var boolean
     */
    private $fse_dependent_hooks_registered = false;

    /**
     * Load hooks for FSE template functionality.
     *
     * @return void
     */
    public function load_hooks()
    {
        // Return if not running WP 6.7+.
        if (!$this->can_run()) {
            return;
        }

        // FSE mode enable/disable AJAX actions — always registered (needed before opt-in).
        add_action('wp_ajax_mepr_fse_enable_mode', [$this, 'ajax_enable_fse_mode']);
        add_action('wp_ajax_mepr_fse_disable_mode', [$this, 'ajax_disable_fse_mode']);

        // Auto-revert FSE mode when switching to a non-block theme.
        add_action('after_switch_theme', [$this, 'maybe_revert_fse_on_theme_switch']);
        add_action('admin_notices', [$this, 'show_fse_reverted_notice']);

        // Defer options read and hook registration to init so MeprOptions does not trigger __()
        // before the memberpress text domain may load (WP 6.7+).
        add_action('init', [$this, 'register_fse_mode_dependent_hooks'], 1);
        if (did_action('init')) {
            $this->register_fse_mode_dependent_hooks();
        }
    }

    /**
     * Registers FSE template hooks when MemberPress FSE mode is active.
     *
     * Runs on init so {@see MeprOptions::fetch()} does not run during plugins_loaded.
     * Bails early when {@see self::is_fse_mode_active()} is false so a stale opt-in on a
     * classic theme cannot register block templates that the theme cannot render.
     *
     * @return void
     */
    public function register_fse_mode_dependent_hooks(): void
    {
        if ($this->fse_dependent_hooks_registered) {
            return;
        }

        // Guard on is_fse_mode_enabled() (user opt-in) rather than is_fse_mode_active()
        // (opt-in + block theme) so that hooks are registered whenever the admin has turned
        // FSE mode on. Block-template filters (pre_get_block_file_template, get_block_templates,
        // etc.) are never invoked by WordPress on classic themes, so registering them is
        // harmless. Using is_fse_mode_active() here caused a test-environment regression
        // because wp_is_block_theme() returns false with the default classic test theme,
        // which silently prevented all FSE hooks from being registered even after the option
        // was enabled.  The flag is set only when hooks are actually registered, so a
        // no-op call (opt-in off) leaves the door open for a later call that finds opt-in on.
        if (! MeprFseHelper::is_fse_mode_enabled()) {
            return;
        }

        $this->fse_dependent_hooks_registered = true;

        $ctrl = $this;
        add_action(
            'init',
            // Make sure the block templates are configured on init|9 without possibility to unhook.
            static function () use ($ctrl) {
                $ctrl->configure_block_templates();
            },
            9
        );

        add_action('init', [$this, 'register_block_templates']);
        add_action('init', [$this, 'register_block_patterns']);

        // Detach the generic theme header patterns from the Site Editor's header-part swap list while
        // FSE mode is active so the ReadyLaunch Header part can't be swapped for a theme header design
        // that breaks the layout. We only strip the `core/template-part/header` block-type association
        // (which is what surfaces them as swap options) and keep the pattern registered, so theme header
        // parts that reference the same pattern by slug (e.g. parts/header.html) still resolve instead of
        // previewing empty.
        // Two entry points (both editor-only, see the method docblock):
        // - init|11 covers the admin Site Editor page load (after theme patterns register on init|10).
        // - rest_api_init covers the editor's lazy REST pattern fetch (/wp/v2/block-patterns/patterns),
        // where REST_REQUEST is not yet defined at init so the init hook alone would miss it.
        add_action('init', [$this, 'detach_theme_header_patterns'], 11);
        add_action('rest_api_init', [$this, 'detach_theme_header_patterns']);

        add_filter('pre_get_block_file_template', [$this, 'get_block_file_template'], 10, 3);
        add_filter('get_block_templates', [$this, 'add_memberpress_block_template_parts'], 10, 3);
        add_filter('get_block_templates', [$this, 'filter_templates_by_availability'], 20, 3);
        add_filter('block_type_metadata_settings', [$this, 'add_plugin_template_parts_support'], 10, 2);

        add_filter(
            'mepr_cpt_allow_resetting_to_default_template',
            [$this, 'cpt_allow_resetting_to_default_template'],
            10,
            2
        );

        add_filter(
            'template_include',
            static function ($template) use ($ctrl) {
                $ctrl->on_template_include();
                return $template;
            },
            PHP_INT_MAX - 1,
            1
        );

        /**
         * Fix a bug in WordPress 6.8 and lower that caused block hooks not to
         * run in templates registered via the Template Registration API.
         *
         * @see https://github.com/WordPress/gutenberg/issues/71139.
         */
        if (version_compare(wp_get_wp_version(), '6.8', '<=')) {
            add_filter('get_block_templates', [$this, 'run_hooks_on_block_templates'], 10, 3);
        }

        // FSE Template admin AJAX actions.
        add_action('wp_ajax_mepr_fse_apply_template', [$this, 'ajax_apply_template']);
        add_action('wp_ajax_mepr_fse_remove_template', [$this, 'ajax_remove_template']);

        // Auto-assign FSE templates to newly published CPT posts.
        add_action('save_post', [$this, 'maybe_auto_assign_cpt_template'], 20, 2);

        // If init already finished (late-loading plugins), the init|9 configure hook never runs,
        // leaving block_templates_config null and hiding every template — build it now instead.
        if (did_action('init') && ! doing_action('init')) {
            $this->configure_block_templates();
        }
    }

    /**
     * Allow resetting to default template for CPT objects.
     *
     * @param  boolean      $allow      Whether to allow resetting to default template.
     * @param  MeprCptModel $cpt_object The CPT object.
     * @return boolean
     */
    public function cpt_allow_resetting_to_default_template(bool $allow, MeprCptModel $cpt_object): bool
    {
        /**
         * Check if the current template is a registered MemberPress template, in that case we don't allow
         * resetting to default template on save when the option _mepr_use_custom_template is set to false.
         */
        return $this->post_has_memberpress_fse_template($cpt_object->ID) ? false : $allow;
    }

    /**
     * Check if a post uses a MemberPress FSE template and if that template is available.
     *
     * @param  integer|null $post_id Post ID.
     * @return boolean
     */
    public function post_uses_memberpress_fse_template(?int $post_id = null): bool
    {
        if (! $post_id) {
            $post_id = in_the_loop() ? get_the_ID() : get_queried_object_id();
        }

        if (! $post_id) {
            return false;
        }

        $template_slug = get_page_template_slug($post_id);
        foreach ((array) $this->block_templates_config as $namespace => $namespace_templates_config) {
            $wp_template = $namespace_templates_config['wp_template'] ?? [];
            $templates   = is_array($wp_template) ? ($wp_template['templates'] ?? []) : [];
            foreach ($templates as $template_config) {
                if ($template_slug !== $template_config['slug']) {
                    continue;
                }

                // If is_available callback is set, use it to determine availability.
                if (isset($template_config['is_available']) && is_callable($template_config['is_available'])) {
                    return (bool) call_user_func($template_config['is_available'], $post_id);
                }

                // If no callback, it's available by default if slug matches.
                return true;
            }
        }

        return false;
    }

    /**
     * Check if the post has a MemberPress FSE template.
     *
     * @param  integer $post_id The post ID.
     * @return boolean True if the post has a MemberPress FSE template, false otherwise.
     */
    public function post_has_memberpress_fse_template(?int $post_id = null): bool
    {
        if (! $post_id) {
            $post_id = in_the_loop() ? get_the_ID() : get_queried_object_id();
        }

        if (! $post_id) {
            return false;
        }

        $template_slug = get_page_template_slug($post_id);
        foreach ((array) $this->block_templates_config as $namespace_templates_config) {
            $wp_template = $namespace_templates_config['wp_template'] ?? [];
            $templates   = is_array($wp_template) ? ($wp_template['templates'] ?? []) : [];
            if (
                in_array(
                    $template_slug,
                    array_column($templates, 'slug'),
                    true
                )
            ) {
                return true;
            }
        }
        return false;
    }

    /**
     * Slugs of all MemberPress-registered FSE page templates (memberpress block template namespace).
     *
     * @return array<string>
     */
    private function get_memberpress_fse_template_slugs(): array
    {
        $slugs = [];
        foreach ((array) $this->block_templates_config as $namespace_templates_config) {
            $templates = $namespace_templates_config['wp_template']['templates'] ?? [];
            foreach ($templates as $template_config) {
                if (! empty($template_config['slug'])) {
                    $slugs[] = $template_config['slug'];
                }
            }
        }

        return array_values(array_unique($slugs));
    }

    /**
     * Whether the current front request uses MemberPress FSE via CPT-wide mapping (fse_cpt_templates).
     *
     * {@see post_has_memberpress_fse_template()} only sees _wp_page_template. CPT defaults can drive the FSE shell
     * for singular and archive routes before every post has that meta.
     *
     * @return boolean
     */
    public function request_uses_memberpress_fse_cpt_option_template(): bool
    {
        if (! $this->can_run()) {
            return false;
        }

        $mepr_options  = MeprOptions::fetch();
        $cpt_templates = isset($mepr_options->fse_cpt_templates) ? (array) $mepr_options->fse_cpt_templates : [];
        $allowed_slugs = array_flip($this->get_memberpress_fse_template_slugs());

        $post_id = (int) get_queried_object_id();
        if ($post_id > 0 && is_singular()) {
            $post_type = get_post_type($post_id);
            if ($post_type) {
                $slug = $cpt_templates[ $post_type ] ?? '';
                if ($slug !== '' && isset($allowed_slugs[ $slug ])) {
                    return true;
                }
            }
        }

        if (is_post_type_archive()) {
            $qo = get_queried_object();
            if ($qo instanceof WP_Post_Type) {
                $slug = $cpt_templates[ $qo->name ] ?? '';
                if ($slug !== '' && isset($allowed_slugs[ $slug ])) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Add plugin template parts support to core/template-part block.
     *
     * WARNING: this swaps the render_callback globally — render_memberpress_template_part() then
     * wraps EVERY core/template-part render on the page (theme parts included), not just MemberPress
     * ones, so that callback must fall back to core rendering for non-MemberPress parts.
     *
     * @param  array $settings Array of determined settings for registering a block type.
     * @param  array $metadata Metadata provided for registering a block type.
     * @return array
     */
    public function add_plugin_template_parts_support($settings, $metadata)
    {
        if (
            isset($metadata['name'], $settings['render_callback']) &&
            'core/template-part' === $metadata['name'] &&
            in_array(
                $settings['render_callback'],
                [
                    'render_block_core_template_part',
                    'gutenberg_render_block_core_template_part',
                ],
                true
            )
        ) {
            $settings['render_callback'] = [$this, 'render_memberpress_template_part'];
        }

        return $settings;
    }

    /**
     * Add MemberPress block part templates to the template list.
     *
     * @param  WP_Block_Template[] $query_result  Array of found block templates.
     * @param  array               $query         Arguments to retrieve templates.
     * @param  string              $template_type Template type: wp_template or wp_template_part.
     * @return WP_Block_Template[] Templates.
     */
    public function add_memberpress_block_template_parts($query_result, $query, $template_type = 'wp_template_part')
    {
        // Limit to block theme and wp_template_part only.
        if (!wp_is_block_theme() || 'wp_template_part' !== $template_type) {
            return $query_result;
        }

        $slugs     = $query['slug__in'] ?? [];
        $templates = $this->get_block_template_parts($slugs);

        // Merge templates and avoid duplicates.
        $templates = $this->merge_templates_unique_slugs($query_result, $templates);

        return $templates;
    }

    /**
     * Maybe return a blocks template to short-circuit the default query.
     *
     * @param  WP_Block_Template|null $template      Return a block template object to short-circuit.
     * @param  string                 $id            Template unique identifier.
     * @param  string                 $template_type Template type: wp_template or wp_template_part.
     * @return WP_Block_Template|null
     */
    public function get_block_file_template($template, $id, $template_type)
    {
        if ('wp_template_part' !== $template_type) {
            return $template;
        }

        $template_name_parts = explode('//', $id);
        if (count($template_name_parts) < 2) {
            return $template;
        }

        list($namespace, $slug) = $template_name_parts;

        // If we are not dealing with a MemberPress template let's return early and let it continue through the process.
        if (!in_array($namespace, array_keys($this->block_templates_config), true)) {
            return $template;
        }

        // Get available MemberPress templates from filesystem.
        $available_templates = $this->get_block_template_parts([$slug], true);

        // If this blocks template doesn't exist then we should just skip the function and let Gutenberg handle it.
        if (! in_array($slug, wp_list_pluck($available_templates, 'slug'), true)) {
            return $template;
        }

        $template = (is_array($available_templates) && count($available_templates) > 0) ?
            $available_templates[0] : $template;

        // At this point we haven't had any luck finding a template. Give up and let Gutenberg take control again.
        return $template;
    }

    /**
     * Register block templates using WordPress 6.7+ Template Registration API.
     *
     * @return void
     */
    public function register_block_templates()
    {
        foreach ((array) $this->block_templates_config as $namespace => $namespace_templates_config) {
            $wp_template = $namespace_templates_config['wp_template'] ?? [];
            $templates   = is_array($wp_template) ? ($wp_template['templates'] ?? []) : [];
            foreach ($templates as $template) {
                $template_slug = $template['slug'];
                $template_name = $namespace . '//' . $template_slug;

                $args = [
                    'title'       => $template['title'],
                    'description' => $template['description'] ?? sprintf(
                        // Translators: %s: template name.
                        esc_html__('MemberPress template for %s.', 'memberpress'),
                        $template['title']
                    ),
                    // Provide the default (file) content. WordPress still lets a
                    // user-customized template saved in the database take precedence,
                    // so editor customizations persist.
                    'content'     => $template['content'],
                ];

                // Add post_types for specific templates.
                if (isset($template['post_types'])) {
                    $args['post_types'] = $template['post_types'];
                }

                register_block_template($template_name, $args);
            }
        }
    }

    /**
     * Register MemberPress block patterns for the Site Editor inserter.
     *
     * Currently registers `memberpress/readylaunch-shell`, the canonical
     * header / sidebar-slot / main-content / footer skeleton for sidebar-style
     * ReadyLaunch templates (Account, Coaching, etc.). The skeleton lives at
     * `app/views/block-templates/_skeletons/readylaunch-shell.html`; this method
     * loads it, strips developer-only doc comments, substitutes the
     * `{your-name}` placeholder, and hands the cleaned markup to
     * {@see register_block_pattern()}.
     *
     * Gated by {@see register_fse_mode_dependent_hooks()} on FSE mode active —
     * the pattern references `readylaunch-header` / `readylaunch-footer` template
     * parts that only exist on FSE-mode requests, so exposing it earlier would
     * surface a broken pattern in the inserter.
     *
     * @return void
     */
    public function register_block_patterns(): void
    {
        if (! function_exists('register_block_pattern')) {
            return;
        }

        $skeleton_path = MEPR_VIEWS_PATH . '/' . self::BLOCK_TEMPLATES_DIR . '/_skeletons/readylaunch-shell.html';
        if (! is_readable($skeleton_path)) {
            return;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
        $content = file_get_contents($skeleton_path);
        if (false === $content || '' === $content) {
            return;
        }

        // Strip the leading developer doc-block (everything before the first wp:* block marker).
        $first_block_pos = strpos($content, '<!-- wp:');
        if (false !== $first_block_pos && $first_block_pos > 0) {
            $content = substr($content, $first_block_pos);
        }

        // Strip the inline `SHELL BLOCK SLOT` developer instruction so the inserted
        // pattern doesn't carry a how-to comment into the user's template.
        $content = preg_replace('/<!--\s*SHELL BLOCK SLOT[\s\S]*?-->\s*/', '', $content);

        // Substitute the developer placeholder with a sane default. End users
        // can rename `mepr-rl-readylaunch-page` after inserting the pattern.
        $content = str_replace('{your-name}', 'readylaunch', $content);

        register_block_pattern(
            'memberpress/readylaunch-shell',
            [
                'title'       => esc_html__('ReadyLaunch Shell', 'memberpress'),
                'description' => esc_html__('Header, sidebar slot, main content slot, and footer skeleton for ReadyLaunch-style sidebar templates.', 'memberpress'),
                'content'     => $content,
                'categories'  => ['page'],
                'keywords'    => ['memberpress', 'readylaunch', 'sidebar', 'shell'],
                // Restrict to the template editor — the pattern relies on
                // `wp:template-part` blocks (readylaunch-header / -footer) that
                // only render inside `wp_template`, so showing it in the
                // page/post inserter would surface a broken pattern.
                'postTypes'   => ['wp_template'],
            ]
        );
    }

    /**
     * Detach generic theme header patterns from the Site Editor's header-part swap list while FSE
     * mode is active.
     *
     * WordPress offers every pattern tagged `Block Types: core/template-part/header`
     * (e.g. Twenty Twenty-Five's "Centered site header", "Header with columns") as a swap
     * option in the Design panel of *every* header template part — core provides no
     * per–template-part scoping. Those generic theme headers do not match the ReadyLaunch
     * design and let a user replace the ReadyLaunch Header with markup that breaks the
     * membership layout.
     *
     * We do NOT unregister the pattern: many block themes (TT5 included) ship their header
     * template parts as thin wrappers that reference these patterns by slug, e.g.
     * `parts/header.html` is just `<!-- wp:pattern {"slug":"twentytwentyfive/header"} /-->`.
     * Unregistering the pattern would leave those theme header parts with an unresolvable
     * reference, so they preview empty in the Site Editor's template-parts list. Instead we
     * strip only the `core/template-part/header` block-type association — the single piece of
     * metadata that surfaces a pattern as a header swap option — and re-register the pattern
     * with everything else intact. The pattern still resolves by slug, so theme header parts
     * keep their content; it just no longer appears as a swap option.
     *
     * Re-registering is safe: WordPress's hooked-blocks processing is idempotent (it records
     * `ignoredHookedBlocks` metadata so a second `apply_block_hooks_to_content()` pass won't
     * duplicate blocks), and the "registered outside init" tracking populated when this runs on
     * rest_api_init is not consumed anywhere that would emit a deprecation notice.
     *
     * Gated on {@see MeprFseHelper::is_fse_mode_active()} (opt-in + active block theme +
     * WP 6.7+): the association is removed only while ReadyLaunch FSE mode is actually driving
     * the design, and remains fully available for normal site editing when FSE mode is off.
     * MemberPress's own patterns (the `memberpress/*` namespace) are never touched.
     *
     * IMPORTANT: this runs in editor / REST contexts only, never on front-end page renders.
     * Swap options are only surfaced inside the editor, but the front-end `core/pattern` block
     * resolves its referenced pattern from the same global registry at render time. Limiting to
     * admin/REST keeps the registry untouched on the front end as an extra safeguard, even though
     * detaching the block type (rather than unregistering) would no longer blank a theme header.
     *
     * Runs from two hooks (see {@see register_fse_mode_dependent_hooks()}): init|11 for the admin
     * Site Editor page load (after theme patterns register on init|10), and rest_api_init for the
     * editor's lazy REST pattern fetch — REST_REQUEST is not defined yet at init, so the init hook
     * alone would miss the REST request and leave the swap options visible.
     *
     * @return void
     */
    public function detach_theme_header_patterns(): void
    {
        // Editor / REST only — never mutate the pattern registry on a front-end render.
        // (On the rest_api_init hook REST_REQUEST is already defined; at init it is not, which is
        // why the REST request is handled by the dedicated rest_api_init entry point above).
        if (! is_admin() && ! (defined('REST_REQUEST') && REST_REQUEST)) {
            return;
        }

        if (! MeprFseHelper::is_fse_mode_active()) {
            return;
        }

        if (
            ! class_exists('WP_Block_Patterns_Registry')
            || ! function_exists('register_block_pattern')
            || ! function_exists('unregister_block_pattern')
        ) {
            return;
        }

        $registered = WP_Block_Patterns_Registry::get_instance()->get_all_registered();
        foreach ($registered as $pattern) {
            // Defensive: a malformed third-party pattern could be missing (or have a non-string)
            // name. Skip it rather than risk a notice on the string checks below. Core- and
            // theme-registered patterns always carry a string name, so this never skips a real one.
            if (empty($pattern['name']) || ! is_string($pattern['name'])) {
                continue;
            }

            // Never touch MemberPress's own patterns. Bailing first skips the array work below.
            if (str_starts_with($pattern['name'], self::NAMESPACE . '/')) {
                continue;
            }

            if (empty($pattern['blockTypes']) || ! is_array($pattern['blockTypes'])) {
                continue;
            }

            if (! in_array('core/template-part/header', $pattern['blockTypes'], true)) {
                continue;
            }

            // Strip only the header swap association; keep the pattern (and its other block-type
            // associations) registered so theme parts referencing it by slug still resolve.
            $pattern['blockTypes'] = array_values(
                array_diff($pattern['blockTypes'], ['core/template-part/header'])
            );

            unregister_block_pattern($pattern['name']);
            register_block_pattern($pattern['name'], $pattern);
        }
    }

    /**
     * Renders the `core/template-part` block on the server.
     *
     * This is done because the core handling for template parts only supports templates from the current theme, not
     * from a plugin.
     *
     * @param  array $attributes The block attributes.
     * @return string The render.
     */
    public function render_memberpress_template_part($attributes)
    {
        // Configured template part namespaces.
        $template_part_namespaces = array_keys($this->block_templates_config);

        if (isset($attributes['theme']) && in_array($attributes['theme'], $template_part_namespaces, true)) {
            $template_part = get_block_template($attributes['theme'] . '//' . $attributes['slug'], 'wp_template_part');
            if ($template_part) {
                // Template part belongs to our namespace — we own the render entirely.
                // Return early even when content is empty so core never tries to resolve
                // the part itself (which would emit a visible "unavailable" error).
                if (empty($template_part->content)) {
                    return '';
                }

                // Make sure the FSE ReadyLaunch styles are enqueued.
                if (stripos($template_part->slug ?? '', 'readylaunch-') !== false) {
                    self::enqueue_fse_readylaunch_styles();
                }

                $content = do_blocks($template_part->content);

                if (empty($attributes['tagName']) || ! preg_match('/^[a-z0-9]+$/i', $attributes['tagName'])) {
                    $html_tag = 'div';
                } else {
                    $html_tag = esc_attr($attributes['tagName']);
                }
                $wrapper_attributes = get_block_wrapper_attributes();

                return "<$html_tag $wrapper_attributes>" . str_replace(']]>', ']]&gt;', $content) . "</$html_tag>";
            }
        }

        return function_exists('gutenberg_render_block_core_template_part') ? // Gutenberg plugin compatibility.
            gutenberg_render_block_core_template_part($attributes) :
            render_block_core_template_part($attributes);
    }

    /**
     * Run hooks on block templates.
     *
     * @param  array $templates The block templates.
     * @return array The block templates.
     */
    public function run_hooks_on_block_templates($templates)
    {
        // There is a bug in the WordPress implementation that causes block hooks not to run in templates registered
        // via the Template Registration API. Because of this, we run them manually.
        // https://github.com/WordPress/gutenberg/issues/71139.
        foreach ($templates as $template) {
            if (
                'plugin' === $template->source &&
                array_key_exists($template->plugin, $this->block_templates_config)
            ) {
                $template->content = apply_block_hooks_to_content(
                    $template->content,
                    $template,
                    'insert_hooked_blocks_and_set_ignored_hooked_blocks_metadata'
                );
            }
        }

        return $templates;
    }

    /**
     * Filter templates availability based on configuration restrictions.
     *
     * @param  WP_Block_Template[] $templates     Array of found block templates.
     * @param  array               $query         Arguments to retrieve templates.
     * @param  string              $template_type Template type: wp_template or wp_template_part.
     * @return WP_Block_Template[] Filtered templates.
     */
    public function filter_templates_by_availability($templates, $query, $template_type)
    {
        if ($template_type !== 'wp_template') {
            return $templates;
        }

        $current_post_id = $this->get_current_post_id_from_context();

        if (!$current_post_id) {
            return $templates;
        }

        $hidden_slugs = [];

        // Identify templates to hide.
        foreach ($this->block_templates_config as $plugin_namespace => $config) {
            if (empty($config['wp_template']['templates'])) {
                continue;
            }

            foreach ($config['wp_template']['templates'] as $template_config) {
                if (empty($template_config['is_available']) || !is_callable($template_config['is_available'])) {
                    continue;
                }

                // If the callback returns true, the template is available.
                if (call_user_func($template_config['is_available'], $current_post_id)) {
                    continue;
                }

                // Otherwise, mark it for hiding.
                $hidden_slugs[] = $plugin_namespace . '//' . $template_config['slug'];
                $hidden_slugs[] = $template_config['slug'];
            }
        }

        if (empty($hidden_slugs)) {
            return array_values($templates);
        }

        // Filter out hidden templates in a single pass.
        $templates = array_filter($templates, function ($template) use ($hidden_slugs) {
            $slug = $template->slug ?? '';
            $id   = $template->id ?? '';

            if (in_array($slug, $hidden_slugs, true) || in_array($id, $hidden_slugs, true)) {
                return false;
            }

            return true;
        });

        return array_values($templates);
    }

    /**
     * Get current post ID from context.
     *
     * @return integer Current post ID or 0.
     */
    protected function get_current_post_id_from_context(): int
    {
        $current_post_id = 0;
        if (is_admin() && isset($_GET['post'])) {
            $current_post_id = absint($_GET['post']);
        }

        if (! $current_post_id) {
            $ref = wp_get_referer();
            if ($ref) {
                $parts = wp_parse_url($ref);
                if (!empty($parts['query'])) {
                    parse_str($parts['query'], $q);
                    if (!empty($q['post'])) {
                        $current_post_id = absint($q['post']);
                    }
                }
            }
        }

        return $current_post_id;
    }

    /**
     * Build block template part from file.
     *
     * @param  array  $template_config  Template configuration.
     * @param  string $plugin_namespace Template plugin full namespace.
     * @return WP_Block_Template|WP_Error Block template part.
     */
    private function build_block_template_part_from_file(
        array $template_config,
        string $plugin_namespace
    ) {
        $template_slug = $template_config['slug'];
        $template_file = $this->block_templates_config[$plugin_namespace]['wp_template_part']['path'] .
            '/' . $template_slug . '.html';

        $theme = false !== strpos($template_file, get_template_directory()) ? get_template() : get_stylesheet();
        $theme = false !== strpos($template_file, get_stylesheet_directory()) ? $theme : false;

        $template                 = new WP_Block_Template();
        $template->id             = $theme ? $theme . '//' . $template_slug : $plugin_namespace . '//' . $template_slug;
        $template->theme          = $theme ? $theme : $plugin_namespace;
        $template->source         = $theme ? 'theme' : 'plugin'; // Plugin was agreed as a valid source value despite existing inline docs at the time of creating: https://github.com/WordPress/gutenberg/issues/36597#issuecomment-976232909.
        $template->slug           = $template_slug;
        $template->type           = 'wp_template_part';
        $template->title          = $template_config['title'] ?? $this->convert_slug_to_title($template_slug);
        $template->status         = 'publish';
        $template->has_theme_file = true;
        $template->origin         = $theme ? 'theme' : 'plugin';
        $template->is_custom      = false; // Templates loaded from the filesystem aren't custom, ones that have been edited and loaded from the DB are.
        $template->post_types     = [];
        $template->area           = $template_config['area'] ?? 'uncategorized';
        $template->content        = $template_config['content'];

        $template = $this->run_blocks_hooks_on_template($template);
        return $template;
    }

    /**
     * Build a unified template object based on a WP_Post object.
     *
     * @param  WP_Post $post Template post.
     * @return WP_Block_Template|WP_Error Template.
     */
    private function build_block_template_part_from_post($post)
    {
        $terms = get_the_terms($post, 'wp_theme');

        if (is_wp_error($terms)) {
            return $terms;
        }

        if (! $terms) {
            return new \WP_Error(
                'template_part_missing_theme',
                __('No theme is defined for this template part.', 'memberpress')
            );
        }

        $theme = $terms[0]->name;

        $template                 = new WP_Block_Template();
        $template->wp_id          = $post->ID;
        $template->id             = $theme . '//' . $post->post_name;
        $template->theme          = $theme;
        $template->content        = $post->post_content;
        $template->slug           = $post->post_name;
        $template->source         = 'custom';
        $template->type           = $post->post_type;
        $template->description    = $post->post_excerpt;
        $template->title          = $post->post_title;
        $template->status         = $post->post_status;
        $template->has_theme_file = true;
        $template->is_custom      = false;
        $template->post_types     = []; // Don't appear in any Edit Post template selector dropdown.

        $type_terms = get_the_terms($post, 'wp_template_part_area');
        if (! is_wp_error($type_terms) && false !== $type_terms) {
            $template->area = $type_terms[0]->name;
        }

        /**
         * Set the 'plugin' origin if it doesn't come
         * from the current theme (or its parent).
         */
        if (! in_array($theme, [get_template(), get_stylesheet()], true)) {
            $template->origin = 'plugin';
        }

        $template = $this->run_blocks_hooks_on_template($template);

        return $template;
    }

    /**
     * Build block template parts from database (Used in FSE context).
     *
     * @param  array $slugs Array of slugs to retrieve templates for.
     * @return array Templates.
     */
    private function build_block_template_parts_from_db(array $slugs = []): array
    {
        $query_args = [
            'post_status'    => ['auto-draft', 'draft', 'publish'],
            'post_type'      => 'wp_template_part',
            'posts_per_page' => -1,
            'no_found_rows'  => true,
            'tax_query'      => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
                [
                    'taxonomy' => 'wp_theme',
                    'field'    => 'name',
                    'terms'    => array_merge(
                        [get_stylesheet(), get_template()],
                        array_keys($this->block_templates_config)
                    ),
                ],
            ],
        ];

        if (is_array($slugs) && count($slugs) > 0) {
            $query_args['post_name__in'] = $slugs;
        }

        $template_posts = (new WP_Query($query_args))->posts;

        return array_values(
            array_filter(
                array_map(
                    [$this, 'build_block_template_part_from_post'],
                    $template_posts
                ),
                fn ($result) => !is_wp_error($result)
            )
        );
    }

    /**
     * Build template content.
     *
     * @return void
     */
    private function build_templates_content(): void
    {
        // Build templates content.
        foreach (['wp_template', 'wp_template_part'] as $template_type) {
            foreach ($this->block_templates_config as $plugin_namespace => &$templates_config) {
                // Skip if this plugin doesn't have this template type configured.
                if (!isset($templates_config[$template_type]['templates'])) {
                    continue;
                }

                foreach ($templates_config[$template_type]['templates'] as &$template) {
                    /**
                     * If the type is `wp_template_part`, we need to make sure parts defined as files in the current template
                     * take over any content defined in the template part configuration.
                     *
                     * Otherwise, for `wp_template` type, we only need to retrieve the content from the file if this is not already set.
                     * WordPress core will then use the theme file's content by default if any.
                     */
                    if ('wp_template_part' === $template_type) {
                        $template['content'] = $this->get_template_content_from_file(
                            $template['slug'],
                            $plugin_namespace,
                            $template_type
                        ) ?? $template['content'] ?? '';
                    } else {
                        $template['content'] = $template['content'] ??
                            $this->get_template_content_from_file(
                                $template['slug'],
                                $plugin_namespace,
                                $template_type
                            ) ?? '';
                    }
                }
            }
        }
    }

    /**
     * Check if the current version of WordPress is 6.7+.
     *
     * Thin wrapper around {@see MeprFseHelper::can_run()} kept on the controller for
     * backward compatibility with existing call sites and tests.
     *
     * @return boolean True if the current version of WordPress is 6.7+, false otherwise.
     */
    public function can_run(): bool
    {
        return MeprFseHelper::can_run();
    }

    /**
     * Configure block templates settings.
     *
     * @return void
     */
    private function configure_block_templates()
    {
        $templates_path      = MEPR_VIEWS_PATH . '/' . self::BLOCK_TEMPLATES_DIR;
        $template_parts_path = MEPR_VIEWS_PATH . '/' . self::BLOCK_TEMPLATE_PARTS_DIR;

        $this->block_templates_config = [
            self::NAMESPACE => [
                'wp_template'      => [
                    'path'      => $templates_path,
                    'templates' => $this->get_templates_config(),
                ],
                'wp_template_part' => [
                    'path'      => $template_parts_path,
                    'templates' => $this->get_template_parts_config(),
                ],
            ],
        ];

        /**
         * Filters the block templates configuration.
         *
         * Add-Ons and 3rd party plugins can hook here to modify the block templates configuration.
         *
         * @param array $block_templates_config Block templates configuration array.
         */
        $this->block_templates_config = MeprHooks::apply_filters(
            'mepr_block_templates_config',
            $this->block_templates_config
        );

        $this->build_templates_content();
    }

    /**
     * Convert slug to title.
     *
     * @param  string $template_slug Template slug.
     * @return string Human-readable title.
     */
    private function convert_slug_to_title($template_slug): string
    {
        return ucwords(str_replace(['-', '_'], ' ', $template_slug));
    }

    /**
     * Get block templates from filesystem and database.
     *
     * @param  array   $slugs   Array of slugs to retrieve templates for.
     * @param  boolean $fs_only Retrieve template parts from filesystem only.
     * @return WP_Block_Template[] Templates.
     */
    private function get_block_template_parts(
        array $slugs = [],
        bool $fs_only = false
    ): array {
        $filtered_templates = [];

        // Iterate through all configured namespaces.
        foreach ($this->block_templates_config as $plugin_namespace => $plugin_templates_config) {
            $templates_config = $plugin_templates_config['wp_template_part'] ?? [];
            $templates        = $templates_config['templates'] ?? [];
            foreach ($templates as $template) {
                // Filter by requested slugs.
                if (!empty($slugs) && !in_array($template['slug'], $slugs, true)) {
                    continue;
                }

                $filtered_templates[] = $this->build_block_template_part_from_file(
                    $template,
                    $plugin_namespace
                );
            }
        }

        if (!$fs_only) {
            // Get block templates from database.
            $block_templates_from_db = $this->build_block_template_parts_from_db($slugs);
            // Add block templates from database and avoid duplicates.
            $filtered_templates = $this->merge_templates_unique_slugs($filtered_templates, $block_templates_from_db);
        }

        return $filtered_templates;
    }

    /**
     * Get template content from file.
     *
     * @param  string $template_slug    Template slug.
     * @param  string $plugin_namespace Template plugin namespace.
     * @param  string $template_type    Template type.
     * @return string|null Template content.
     */
    private function get_template_content_from_file(
        string $template_slug,
        string $plugin_namespace = self::NAMESPACE,
        string $template_type = 'wp_template'
    ): ?string {

        $template_file = '';
        /**
         * If the type is `wp_template_part`, check if the file exists in the current theme template parts directory first.
         * `wp_template` type doesn't need this treatment because it's already handled by wp core.
         */
        if ('wp_template_part' === $template_type) {
            $template_file = get_theme_file_path('parts/' . $template_slug . '.html');
            $template_file = is_readable($template_file) ? $template_file : '';
        }

        if (empty($template_file)) {
            $template_file = $this->block_templates_config[$plugin_namespace][$template_type]['path']
                . '/' . $template_slug . '.html';
            $template_file = is_readable($template_file) ? $template_file : '';
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
        return $template_file ? file_get_contents($template_file) : null;
    }

    /**
     * Get template parts configuration.
     *
     * @return array
     */
    private function get_template_parts_config()
    {
        return [
            [
                'slug'        => 'readylaunch-header',
                'title'       => esc_html__('ReadyLaunch Header', 'memberpress'),
                'description' => esc_html__('Header template part for MemberPress pages.', 'memberpress'),
                'area'        => 'header',
            ],
            [
                'slug'        => 'readylaunch-footer',
                'title'       => esc_html__('ReadyLaunch Footer', 'memberpress'),
                'description' => esc_html__('Footer template part for MemberPress pages.', 'memberpress'),
                'area'        => 'footer',
            ],
        ];
    }

    /**
     * Get templates configuration.
     *
     * @return array
     */
    private function get_templates_config(): array
    {
        return [
            [
                'slug'           => 'readylaunch-membership',
                'title'          => esc_html__('ReadyLaunch Membership', 'memberpress'),
                'description'    => esc_html__('Template for MemberPress membership pages.', 'memberpress'),
                'post_types'     => ['memberpressproduct'],
                'onTemplateLoad' => static function () {
                    // Remove the registration form from the content when the ReadyLaunch membership template is loaded.
                    remove_filter('the_content', 'MeprProductsCtrl::display_registration_form', 10);
                },
            ],
            [
                'slug'           => 'readylaunch-thankyou',
                'title'          => esc_html__('ReadyLaunch ThankYou Page', 'memberpress'),
                'description'    => esc_html__('ReadyLaunch template for MemberPress thank you pages.', 'memberpress'),
                'post_types'     => ['page'],
                'is_available'   => static function ($post_id) {
                    $options          = MeprOptions::fetch();
                    $thankyou_page_id = !empty($options->thankyou_page_id) ? absint($options->thankyou_page_id) : 0;

                    if (!$thankyou_page_id) {
                        return false;
                    }

                    return $post_id === $thankyou_page_id;
                },
                'onTemplateLoad' => static function () {
                    add_filter('mepr_should_use_readylaunch_template_for_invoice', '__return_true');
                },
            ],
            [
                'slug'        => 'readylaunch-account',
                'title'       => esc_html__('ReadyLaunch Account', 'memberpress'),
                'description' => esc_html__('Template for MemberPress account pages.', 'memberpress'),
                'post_types'  => ['page'],
            ],
            [
                'slug'        => 'readylaunch-login',
                'title'       => esc_html__('ReadyLaunch Login', 'memberpress'),
                'description' => esc_html__('Template for the MemberPress login page.', 'memberpress'),
                'post_types'  => ['page'],
            ],
            [
                'slug'           => 'readylaunch-pricing',
                'title'          => esc_html__('ReadyLaunch Pricing', 'memberpress'),
                'description'    => esc_html__('Template for MemberPress pricing/group pages.', 'memberpress'),
                'post_types'     => ['memberpressgroup'],
                'onTemplateLoad' => static function () {
                    // The block renders the pricing table directly; prevent
                    // the_content from injecting it a second time.
                    remove_filter('the_content', 'MeprGroupsCtrl::render_pricing_boxes', 10);
                },
            ],
            [
                'slug'        => 'readylaunch-courses',
                'title'       => esc_html__('ReadyLaunch Courses', 'memberpress'),
                'description' => esc_html__('ReadyLaunch template for MemberPress course pages (sidebar + curriculum + block content).', 'memberpress'),
                'post_types'  => [],
            ],
            [
                'slug'        => 'readylaunch',
                'title'       => esc_html__('ReadyLaunch', 'memberpress'),
                'description' => esc_html__('Generic ReadyLaunch template for MemberPress pages.', 'memberpress'),
                'post_types'  => [],
            ],
        ];
    }

    /**
     * On template include.
     *
     * @return void
     */
    private function on_template_include(): void
    {
        if (! $this->post_uses_memberpress_fse_template()) {
            return;
        }

        $post_id       = in_the_loop() ? get_the_ID() : get_queried_object_id();
        $template_slug = get_page_template_slug($post_id);

        foreach (
            $this->block_templates_config as $plugin_namespace => $plugin_templates_config
        ) {
            if (empty($plugin_templates_config['wp_template']['templates'])) {
                continue;
            }

            foreach ($plugin_templates_config['wp_template']['templates'] as $template) {
                if (
                    $template['slug'] === $template_slug &&
                    isset($template['onTemplateLoad']) &&
                    is_callable($template['onTemplateLoad'])
                ) {
                    $template['onTemplateLoad']();
                }
            }
        }
    }

    /**
     * Run the block hooks algorithm introduced in WP 6.4 on the template content.
     *
     * @param  WP_Block_Template $template Template.
     * @return WP_Block_Template Template.
     */
    private function run_blocks_hooks_on_template(WP_Block_Template $template): WP_Block_Template
    {
        if (function_exists('inject_ignored_hooked_blocks_metadata_attributes')) {
            $hooked_blocks = get_hooked_blocks();
            if (! empty($hooked_blocks) || has_filter('hooked_block_types')) {
                $before_block_visitor = make_before_block_visitor($hooked_blocks, $template);
                $after_block_visitor  = make_after_block_visitor($hooked_blocks, $template);
                $blocks               = parse_blocks($template->content);
                $template->content    = traverse_and_serialize_blocks(
                    $blocks,
                    $before_block_visitor,
                    $after_block_visitor
                );
            }
        }
        return $template;
    }

    /**
     * Merge templates unique slugs.
     *
     * Removes duplicates from the first array.
     *
     * @param  array $templates          Templates.
     * @param  array $templates_to_merge Templates to merge.
     * @return array Templates.
     */
    private function merge_templates_unique_slugs(array $templates, array $templates_to_merge): array
    {
        $templates_to_merge = array_values(array_filter($templates_to_merge, fn ($t) => !is_wp_error($t)));
        $merge_slugs        = wp_list_pluck($templates_to_merge, 'slug');

        return array_merge(
            array_values(
                array_filter(
                    $templates,
                    function ($template) use ($merge_slugs) {
                        return $template instanceof WP_Block_Template
                            && !in_array($template->slug, $merge_slugs, true);
                    }
                )
            ),
            $templates_to_merge
        );
    }

    /**
     * Enqueue FSE ReadyLaunch styles.
     *
     * @return void
     */
    private static function enqueue_fse_readylaunch_styles(): void
    {
        add_filter('mepr_rl_should_enqueue_fse_readylaunch_styles_on_front', '__return_true');
    }

    /**
     * Get the FSE UI templates configuration for the admin settings page.
     *
     * @return array Associative array of template key => config.
     */
    public function get_fse_ui_templates_config(): array
    {
        $mepr_options = MeprOptions::fetch();

        $config = [
            'registration' => [
                'slug'       => 'readylaunch-membership',
                'label'      => esc_html__('Registration', 'memberpress'),
                'type'       => 'cpt',
                'post_type'  => MeprProduct::$cpt,
                'legacy_key' => 'checkout',
            ],
            'thankyou'     => [
                'slug'       => 'readylaunch-thankyou',
                'label'      => esc_html__('Thank You', 'memberpress'),
                'type'       => 'page',
                'page_id'    => (int) $mepr_options->thankyou_page_id,
                'legacy_key' => 'thankyou',
            ],
            'account'      => [
                'slug'       => 'readylaunch-account',
                'label'      => esc_html__('Account', 'memberpress'),
                'type'       => 'page',
                'page_id'    => (int) $mepr_options->account_page_id,
                'legacy_key' => 'account',
            ],
            'login'        => [
                'slug'       => 'readylaunch-login',
                'label'      => esc_html__('Login', 'memberpress'),
                'type'       => 'page',
                'page_id'    => (int) $mepr_options->login_page_id,
                'legacy_key' => 'login',
            ],
            'pricing'      => [
                'slug'       => 'readylaunch-pricing',
                'label'      => esc_html__('Pricing', 'memberpress'),
                'type'       => 'cpt',
                'post_type'  => MeprGroup::$cpt,
                'legacy_key' => 'pricing',
            ],
        ];

        /**
         * Filters the FSE UI templates configuration.
         *
         * Add-ons can hook here to register additional FSE templates
         * (e.g. CoachKit adding a "Coaching" template).
         *
         * @param array $config Associative array of template key => config.
         */
        return MeprHooks::apply_filters('mepr_fse_ui_templates_config', $config);
    }

    /**
     * Get the status of a specific FSE template.
     *
     * @param  string $key The template key from get_fse_ui_templates_config().
     * @return string 'applied', 'partial', or 'none'.
     */
    public function get_template_status(string $key): string
    {
        $config = $this->get_fse_ui_templates_config();

        if (!isset($config[$key])) {
            return 'none';
        }

        $template = $config[$key];
        $slug     = $template['slug'];

        if ($template['type'] === 'page') {
            return $this->get_page_template_status($template['page_id'] ?? 0, $slug);
        }

        if ($template['type'] === 'cpt') {
            return $this->get_cpt_template_status($template['post_type'], $slug)['status'];
        }

        return 'none';
    }

    /**
     * Get the template status for a page-backed template.
     *
     * @param  integer $page_id The page ID.
     * @param  string  $slug    The template slug.
     * @return string 'applied', 'unconfigured', or 'none'.
     */
    private function get_page_template_status(int $page_id, string $slug): string
    {
        if ($page_id <= 0) {
            return 'unconfigured';
        }

        return get_page_template_slug($page_id) === $slug ? 'applied' : 'none';
    }

    /**
     * Get the template status for a CPT-backed template.
     *
     * @param  string  $post_type The post type.
     * @param  string  $slug      The template slug.
     * @param  integer $total     Total published posts for this post type.
     * @return array{status: string, post_count: int}
     */
    private function get_cpt_template_status(string $post_type, string $slug, ?int $total = null): array
    {
        if ($total === null) {
            $total = (int) wp_count_posts($post_type)->publish;
        }

        $with_template = (new \WP_Query([
            'post_type'      => $post_type,
            'post_status'    => 'publish',
            'posts_per_page' => 1,
            'fields'         => 'ids',
            'no_found_rows'  => false,
            'meta_query'     => [
                [
                    'key'   => '_wp_page_template',
                    'value' => $slug,
                ],
            ],
        ]))->found_posts;

        if ($total === 0) {
            $mepr_options  = MeprOptions::fetch();
            $cpt_templates = $mepr_options->fse_cpt_templates;
            $status        = (isset($cpt_templates[$post_type]) && $cpt_templates[$post_type] === $slug) ? 'applied' : 'none';

            return [
                'status'     => $status,
                'post_count' => 0,
            ];
        }

        if ($with_template === $total) {
            $status = 'applied';
        } else {
            $status = $with_template > 0 ? 'partial' : 'none';
        }

        return [
            'status'     => $status,
            'post_count' => $total,
        ];
    }

    /**
     * Get the Site Editor URL for a template.
     *
     * @param  string $slug The template slug.
     * @return string The Site Editor URL.
     */
    public function get_template_edit_url(string $slug): string
    {
        // WP's registry rewrites a plugin template's id to {active theme}//{slug} (the plugin
        // namespace is kept only in ->plugin), so the Site Editor route must use the stylesheet.
        return admin_url(
            'site-editor.php?p=%2Fwp_template%2F'
            . rawurlencode(get_stylesheet())
            . '%2F%2F'
            . rawurlencode($slug)
            . '&canvas=edit'
        );
    }

    /**
     * Check if a template slug is registered in get_templates_config().
     *
     * @param  string $slug The template slug to check.
     * @return boolean
     */
    public function is_template_registered(string $slug): bool
    {
        $registered_slugs = [];

        foreach ((array) $this->block_templates_config as $namespace_config) {
            if (!empty($namespace_config['wp_template']['templates'])) {
                $registered_slugs = array_merge(
                    $registered_slugs,
                    array_column($namespace_config['wp_template']['templates'], 'slug')
                );
            }
        }

        return in_array($slug, $registered_slugs, true);
    }

    /**
     * Get all template statuses for the admin UI.
     *
     * Admin-only: each CPT-backed template runs get_cpt_template_status(), which issues two SQL
     * queries, so this is not intended for (uncached) front-end use.
     *
     * @return array Associative array of template key => status data.
     */
    public function get_all_template_statuses(): array
    {
        $config   = $this->get_fse_ui_templates_config();
        $statuses = [];

        foreach ($config as $key => $template) {
            $status_data = [
                'slug'          => $template['slug'],
                'label'         => $template['label'],
                'type'          => $template['type'],
                'edit_url'      => $this->get_template_edit_url($template['slug']),
                'is_registered' => $this->is_template_registered($template['slug']),
            ];

            if ($template['type'] === 'cpt') {
                $cpt_status    = $this->get_cpt_template_status($template['post_type'], $template['slug']);
                $post_type_obj = get_post_type_object($template['post_type']);

                $status_data['status']          = $cpt_status['status'];
                $status_data['post_count']      = $cpt_status['post_count'];
                $status_data['post_type_label'] = $post_type_obj
                    ? $post_type_obj->labels->name
                    : $template['post_type'];
            } else {
                $status_data['status'] = $this->get_template_status($key);
            }

            $statuses[$key] = $status_data;
        }

        return $statuses;
    }

    /**
     * Auto-assign FSE template to a newly published CPT post if the admin
     * has applied the template globally for this post type.
     *
     * @param  integer  $post_id The post ID.
     * @param  \WP_Post $post    The post object.
     * @return void
     */
    public function maybe_auto_assign_cpt_template(int $post_id, \WP_Post $post): void
    {
        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
            return;
        }

        if ($post->post_status !== 'publish' || $post->post_type === 'revision') {
            return;
        }

        if (!function_exists('wp_is_block_theme') || !wp_is_block_theme()) {
            return;
        }

        $mepr_options  = MeprOptions::fetch();
        $cpt_templates = $mepr_options->fse_cpt_templates;
        $slug          = $cpt_templates[$post->post_type] ?? '';

        if (empty($slug)) {
            return;
        }

        // Don't override if the post already has a MemberPress template.
        if ($this->post_has_memberpress_fse_template($post_id)) {
            return;
        }

        // Back up the existing (non-MemberPress) template so Remove can restore it; the guard above
        // guarantees $original isn't one of our slugs. Mirrors apply_fse_template().
        $original = get_post_meta($post_id, '_wp_page_template', true);
        update_post_meta($post_id, '_mepr_original_page_template', $original ?: '__none__');

        update_post_meta($post_id, '_wp_page_template', $slug);
    }

    /**
     * Apply an FSE template to the appropriate posts/pages.
     *
     * @param  string  $key                     The template key.
     * @param  boolean $skip_registration_check Skip the registration check (used for bulk enable).
     * @param  boolean $defer_store             Skip calling MeprOptions::store(); caller is responsible for flushing.
     * @return true|WP_Error
     */
    private function apply_fse_template(string $key, bool $skip_registration_check = false, bool $defer_store = false)
    {
        $config = $this->get_fse_ui_templates_config();

        if (!isset($config[$key])) {
            return new \WP_Error('invalid_template', esc_html__('Invalid template key.', 'memberpress'));
        }

        $template = $config[$key];
        $slug     = $template['slug'];

        if (!$skip_registration_check && !$this->is_template_registered($slug)) {
            return new \WP_Error('not_registered', esc_html__('This template is not yet available.', 'memberpress'));
        }

        if ($template['type'] === 'page') {
            $page_id = $template['page_id'] ?? 0;
            if ($page_id <= 0) {
                return new \WP_Error(
                    'no_page',
                    sprintf(
                        // Translators: %s: template label.
                        esc_html__('The %s page is not configured in MemberPress settings.', 'memberpress'),
                        $template['label']
                    )
                );
            }

            // Skip the backup when the current value is already one of ours — otherwise re-apply
            // overwrites the real original with our slug and Remove can't revert.
            $original         = get_post_meta($page_id, '_wp_page_template', true);
            $registered_slugs = array_flip($this->get_memberpress_fse_template_slugs());
            if (! isset($registered_slugs[$original])) {
                update_post_meta($page_id, '_mepr_original_page_template', $original ?: '__none__');
            }

            update_post_meta($page_id, '_wp_page_template', $slug);
        } elseif ($template['type'] === 'cpt') {
            global $wpdb;

            $post_type = $template['post_type'];
            $post_ids  = $wpdb->get_col($wpdb->prepare(
                "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'publish'",
                $post_type
            ));

            $registered_slugs = array_flip($this->get_memberpress_fse_template_slugs());

            if (!empty($post_ids)) {
                // Process in chunks to stay well under MySQL max_allowed_packet on very large catalogs.
                foreach (array_chunk($post_ids, 500) as $chunk) {
                    $placeholders = implode(',', array_fill(0, count($chunk), '%d'));

                    // 1. Back up existing template values so we can restore on revert.
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                    $existing = $wpdb->get_results($wpdb->prepare(
                        "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE post_id IN ($placeholders) AND meta_key = '_wp_page_template'", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                        $chunk
                    ));

                    $existing_map = [];
                    foreach ($existing as $row) {
                        $existing_map[(int) $row->post_id] = $row->meta_value;
                    }

                    // Skip re-backup for posts already on one of our slugs — otherwise re-apply
                    // overwrites the real original saved by the first apply.
                    $backup_ids = array_values(array_filter(
                        $chunk,
                        static function ($id) use ($existing_map, $registered_slugs) {
                            return ! isset($registered_slugs[$existing_map[(int) $id] ?? '']);
                        }
                    ));

                    // Steps 1-3 must be atomic: a failure between the DELETE and the bulk
                    // INSERT would otherwise leave posts half-migrated (backup row without a
                    // _wp_page_template mapping, or vice versa).
                    $committed = $this->write_postmeta_chunk_atomically(
                        function () use ($wpdb, $chunk, $placeholders, $backup_ids, $existing_map, $slug) {
                            // 1. Back up the original template (or '__none__' if no meta existed).
                            if (!empty($backup_ids)) {
                                $backup_placeholders = implode(',', array_fill(0, count($backup_ids), '%d'));

                                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                                if (
                                    false === $wpdb->query($wpdb->prepare(
                                        "DELETE FROM {$wpdb->postmeta} WHERE post_id IN ($backup_placeholders) AND meta_key = '_mepr_original_page_template'", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                                        $backup_ids
                                    ))
                                ) {
                                    return false;
                                }

                                $backup_values = [];
                                $backup_params = [];
                                foreach ($backup_ids as $id) {
                                    $original        = $existing_map[(int) $id] ?? '__none__';
                                    $backup_values[] = '(%d, "_mepr_original_page_template", %s)';
                                    $backup_params[] = $id;
                                    $backup_params[] = $original;
                                }

                                $backup_query = "INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value) VALUES " . implode(',', $backup_values);
                                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                                if (false === $wpdb->query($wpdb->prepare($backup_query, $backup_params))) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
                                    return false;
                                }
                            }

                            // 2. Remove any existing template mappings for these posts in one query.
                            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                            if (
                                false === $wpdb->query($wpdb->prepare(
                                    "DELETE FROM {$wpdb->postmeta} WHERE post_id IN ($placeholders) AND meta_key = '_wp_page_template'", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                                    $chunk
                                ))
                            ) {
                                return false;
                            }

                            // 3. Bulk insert the new template mapping for all posts in one query.
                            $values = [];
                            $params = [];
                            foreach ($chunk as $id) {
                                $values[] = '(%d, "_wp_page_template", %s)';
                                $params[] = $id;
                                $params[] = $slug;
                            }

                            $query = "INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value) VALUES " . implode(',', $values);

                            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                            return false !== $wpdb->query($wpdb->prepare($query, $params)); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
                        }
                    );

                    if (!$committed) {
                        return new \WP_Error(
                            'mepr_fse_apply_failed',
                            esc_html__('Failed to apply the template; the changes were rolled back.', 'memberpress')
                        );
                    }

                    // 4. Clear the object cache for all affected posts in one call per group.
                    wp_cache_delete_multiple($chunk, 'posts');
                    wp_cache_delete_multiple($chunk, 'post_meta');
                }
            }

            $mepr_options                          = MeprOptions::fetch();
            $cpt_templates                         = $mepr_options->fse_cpt_templates;
            $cpt_templates[$template['post_type']] = $slug;
            $mepr_options->fse_cpt_templates       = $cpt_templates;
            if (!$defer_store) {
                $mepr_options->store(false);
            }
        }

        return true;
    }

    /**
     * Run a chunk's postmeta writes inside a DB transaction.
     *
     * Wrapping the per-chunk DELETE/INSERT sequence keeps it atomic: a failure partway
     * through can no longer leave posts with a _mepr_original_page_template backup row but
     * no _wp_page_template mapping (or vice versa). Object-cache invalidation is the
     * caller's responsibility and must run only after this returns true.
     *
     * Note: postmeta is InnoDB on supported installs; on a (legacy) MyISAM table the
     * transaction statements are no-ops and the sequence simply runs as before.
     *
     * @param  callable $writes Performs the $wpdb writes and returns false to roll back.
     * @return boolean True when the transaction committed, false when it was rolled back.
     */
    private function write_postmeta_chunk_atomically(callable $writes): bool
    {
        global $wpdb;

        // The WordPress test harness wraps each test in its own DB transaction; issuing our
        // own START TRANSACTION there would implicitly commit it and break test isolation.
        // Skip the explicit transaction control under test — the writes below still run and
        // the failure contract is preserved; the harness owns commit/rollback.
        $use_transaction = ! defined('TESTS_RUNNING');

        if ($use_transaction) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->query('START TRANSACTION');
        }

        if ($writes() === false) {
            if ($use_transaction) {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $wpdb->query('ROLLBACK');
            }
            return false;
        }

        if ($use_transaction) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->query('COMMIT');
        }
        return true;
    }

    /**
     * Remove an FSE template from the appropriate posts/pages.
     *
     * @param  string  $key         The template key.
     * @param  boolean $defer_store Skip calling MeprOptions::store(); caller is responsible for flushing.
     * @return true|WP_Error
     */
    private function remove_fse_template(string $key, bool $defer_store = false)
    {
        $config = $this->get_fse_ui_templates_config();

        if (!isset($config[$key])) {
            return new \WP_Error('invalid_template', esc_html__('Invalid template key.', 'memberpress'));
        }

        $template         = $config[$key];
        $registered_slugs = array_flip($this->get_memberpress_fse_template_slugs());

        if ($template['type'] === 'page') {
            $page_id = $template['page_id'] ?? 0;
            if ($page_id > 0) {
                $original = get_post_meta($page_id, '_mepr_original_page_template', true);

                // Self-heal: treat a backup that is one of our own slugs as "no original",
                // defending against corrupted rows from a pre-fix install.
                if (
                    $original !== ''
                    && $original !== '__none__'
                    && ! isset($registered_slugs[$original])
                ) {
                    update_post_meta($page_id, '_wp_page_template', $original);
                } else {
                    update_post_meta($page_id, '_wp_page_template', '');
                }

                delete_post_meta($page_id, '_mepr_original_page_template');
            }
        } elseif ($template['type'] === 'cpt') {
            global $wpdb;

            $post_type = $template['post_type'];
            $slug      = $template['slug'];

            // 1. Get IDs of posts that currently have this template assigned.
            $post_ids = $wpdb->get_col($wpdb->prepare(
                "SELECT post_id FROM {$wpdb->postmeta} pm
                 INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID
                 WHERE p.post_type = %s AND pm.meta_key = '_wp_page_template' AND pm.meta_value = %s AND p.post_status = 'publish'",
                $post_type,
                $slug
            ));

            if (!empty($post_ids)) {
                // Process in chunks to stay well under MySQL max_allowed_packet on very large catalogs.
                foreach (array_chunk($post_ids, 500) as $chunk) {
                    $placeholders = implode(',', array_fill(0, count($chunk), '%d'));

                    // Read the backups first (a SELECT, safe outside the transaction), then run
                    // the remove + restore + cleanup writes atomically so a mid-sequence failure
                    // can't strip the mapping without restoring the original (or vice versa).
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                    $backups = $wpdb->get_results($wpdb->prepare(
                        "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE post_id IN ($placeholders) AND meta_key = '_mepr_original_page_template'", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                        $chunk
                    ));

                    $restore_values = [];
                    $restore_params = [];
                    foreach ($backups as $row) {
                        // Self-heal: skip restoring a backup that contains one of our own slugs.
                        // See the page-branch guard above for the rationale.
                        if (
                            $row->meta_value !== ''
                            && $row->meta_value !== '__none__'
                            && ! isset($registered_slugs[$row->meta_value])
                        ) {
                            $restore_values[] = '(%d, "_wp_page_template", %s)';
                            $restore_params[] = $row->post_id;
                            $restore_params[] = $row->meta_value;
                        }
                    }

                    $committed = $this->write_postmeta_chunk_atomically(
                        function () use ($wpdb, $chunk, $placeholders, $restore_values, $restore_params) {
                            // 2. Remove the readylaunch template mappings.
                            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                            if (
                                false === $wpdb->query($wpdb->prepare(
                                    "DELETE FROM {$wpdb->postmeta} WHERE post_id IN ($placeholders) AND meta_key = '_wp_page_template'", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                                    $chunk
                                ))
                            ) {
                                return false;
                            }

                            // 3. Restore original template values from backup.
                            if (!empty($restore_values)) {
                                $restore_query = "INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value) VALUES " . implode(',', $restore_values);
                                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                                if (false === $wpdb->query($wpdb->prepare($restore_query, $restore_params))) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
                                    return false;
                                }
                            }

                            // 4. Cleanup backup meta.
                            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                            return false !== $wpdb->query($wpdb->prepare(
                                "DELETE FROM {$wpdb->postmeta} WHERE post_id IN ($placeholders) AND meta_key = '_mepr_original_page_template'", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                                $chunk
                            ));
                        }
                    );

                    if (!$committed) {
                        return new \WP_Error(
                            'mepr_fse_remove_failed',
                            esc_html__('Failed to remove the template; the changes were rolled back.', 'memberpress')
                        );
                    }

                    // 5. Clear the object cache for all affected posts in one call per group.
                    wp_cache_delete_multiple($chunk, 'posts');
                    wp_cache_delete_multiple($chunk, 'post_meta');
                }
            }

            $mepr_options  = MeprOptions::fetch();
            $cpt_templates = $mepr_options->fse_cpt_templates;
            unset($cpt_templates[$template['post_type']]);
            $mepr_options->fse_cpt_templates = $cpt_templates;
            if (!$defer_store) {
                $mepr_options->store(false);
            }
        }

        return true;
    }

    /**
     * AJAX handler: Enable FSE mode and apply all templates.
     *
     * @return void
     */
    public function ajax_enable_fse_mode()
    {
        check_ajax_referer('mepr_fse_templates', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(['message' => esc_html__('Permission denied.', 'memberpress')]);
        }

        $config  = $this->get_fse_ui_templates_config();
        $applied = [];
        $skipped = [];
        $failed  = [];

        foreach ($config as $key => $template) {
            if (!$this->should_auto_apply_template($key, $template)) {
                $skipped[$key] = esc_html__('Skipped: ReadyLaunch template was disabled.', 'memberpress');
                continue;
            }

            $result = $this->apply_fse_template($key, true, true);

            if (is_wp_error($result)) {
                // A rolled-back bulk write is a hard failure; a missing-page config is a soft skip.
                if ($result->get_error_code() === 'mepr_fse_apply_failed') {
                    $failed[$key] = $result->get_error_message();
                } else {
                    $skipped[$key] = $result->get_error_message();
                }
            } else {
                $applied[] = $key;
            }
        }

        // Don't flip into FSE mode (and disable the classic toggles) when a template hard-failed to
        // apply — that would leave a half-migrated, unstyled site. Report so the admin can retry.
        if (!empty($failed)) {
            wp_send_json_error([
                'message' => esc_html__('Failed to apply one or more templates; FSE mode was not enabled. Please try again.', 'memberpress'),
                'applied' => $applied,
                'skipped' => $skipped,
                'failed'  => $failed,
            ]);
        }

        $mepr_options = MeprOptions::fetch();

        // Templates are copied over above; now turn the classic toggles off so
        // switching FSE back off leaves them off. Add-ons register theirs via this filter.
        $classic_toggles = MeprHooks::apply_filters('mepr_fse_classic_toggles_to_clear', [
            'design_enable_pricing_template',
            'design_enable_checkout_template',
            'design_enable_thankyou_template',
            'design_enable_login_template',
            'design_enable_account_template',
        ]);

        foreach ($classic_toggles as $option_name) {
            if (isset($mepr_options->$option_name)) {
                $mepr_options->$option_name = false;
            }
        }

        $mepr_options->fse_mode_enabled = true;
        $mepr_options->store(false);

        /**
         * Fires after FSE mode has been enabled and core templates applied.
         *
         * Add-ons can hook here to apply their own FSE templates.
         *
         * @param array $applied Array of successfully applied template keys.
         * @param array $skipped Array of skipped template keys => error messages.
         */
        MeprHooks::do_action('mepr_fse_mode_enabled', $applied, $skipped);

        // Flag a partial result so the UI can tell the operator some templates were skipped.
        $partial = !empty($skipped);

        wp_send_json_success([
            'message' => $partial
                ? esc_html__('FSE mode enabled. Some templates were skipped.', 'memberpress')
                : esc_html__('FSE mode enabled. Templates applied.', 'memberpress'),
            'applied' => $applied,
            'skipped' => $skipped,
            'partial' => $partial,
        ]);
    }

    /**
     * AJAX handler: Disable FSE mode and remove all templates.
     *
     * @return void
     */
    public function ajax_disable_fse_mode()
    {
        check_ajax_referer('mepr_fse_templates', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(['message' => esc_html__('Permission denied.', 'memberpress')]);
        }

        $config  = $this->get_fse_ui_templates_config();
        $removed = [];
        $failed  = [];

        foreach ($config as $key => $template) {
            $result = $this->remove_fse_template($key, true);

            if (is_wp_error($result)) {
                $failed[$key] = $result->get_error_message();
            } else {
                $removed[] = $key;
            }
        }

        // A failed bulk remove can leave _wp_page_template / backup meta inconsistent. Surface the
        // error and keep FSE mode enabled so the admin can retry rather than report a false success.
        if (!empty($failed)) {
            wp_send_json_error([
                'message' => esc_html__('Failed to remove one or more templates; FSE mode was left enabled. Please try again.', 'memberpress'),
                'removed' => $removed,
                'failed'  => $failed,
            ]);
        }

        $mepr_options                   = MeprOptions::fetch();
        $mepr_options->fse_mode_enabled = false;
        $mepr_options->store(false);

        wp_send_json_success([
            'message' => esc_html__('FSE mode disabled. Templates removed.', 'memberpress'),
            'removed' => $removed,
        ]);
    }

    /**
     * Auto-revert FSE mode when switching to a non-block theme.
     *
     * Unapplies all FSE templates and disables FSE mode so that
     * classic ReadyLaunch works immediately. Sets a transient to
     * show a one-time admin notice informing the user.
     *
     * @return void
     */
    public function maybe_revert_fse_on_theme_switch()
    {
        $mepr_options = MeprOptions::fetch();

        if (empty($mepr_options->fse_mode_enabled)) {
            return;
        }

        // Still a block theme — nothing to do.
        if (function_exists('wp_is_block_theme') && wp_is_block_theme()) {
            return;
        }

        // Unapply all FSE templates.
        $config = $this->get_fse_ui_templates_config();

        foreach ($config as $key => $template) {
            $this->remove_fse_template($key, true);
        }

        $mepr_options->fse_mode_enabled = false;
        $mepr_options->store(false);

        set_transient('mepr_fse_mode_auto_reverted', true, 0);
    }

    /**
     * Show a one-time admin notice after FSE mode was auto-reverted.
     *
     * @return void
     */
    public function show_fse_reverted_notice()
    {
        if (!get_transient('mepr_fse_mode_auto_reverted')) {
            return;
        }

        delete_transient('mepr_fse_mode_auto_reverted');

        printf(
            '<div class="notice notice-info is-dismissible"><p>%s</p></div>',
            esc_html__(
                'Your theme no longer supports Full Site Editing. MemberPress has automatically reverted to ReadyLaunch™ Classic mode.',
                'memberpress'
            )
        );
    }

    /**
     * AJAX handler: Apply an FSE template.
     *
     * @return void
     */
    public function ajax_apply_template()
    {
        check_ajax_referer('mepr_fse_templates', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(['message' => esc_html__('Permission denied.', 'memberpress')]);
        }

        $template_key = isset($_POST['template_key']) ? sanitize_text_field(wp_unslash($_POST['template_key'])) : '';
        $result       = $this->apply_fse_template($template_key);

        if (is_wp_error($result)) {
            wp_send_json_error(['message' => $result->get_error_message()]);
        }

        wp_send_json_success([
            'message'      => esc_html__('Template applied successfully.', 'memberpress'),
            'template_key' => $template_key,
            'status'       => $this->get_template_status($template_key),
            'edit_url'     => $this->get_template_edit_url(
                $this->get_fse_ui_templates_config()[$template_key]['slug']
            ),
        ]);
    }

    /**
     * AJAX handler: Remove an FSE template.
     *
     * @return void
     */
    public function ajax_remove_template()
    {
        check_ajax_referer('mepr_fse_templates', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(['message' => esc_html__('Permission denied.', 'memberpress')]);
        }

        $template_key = isset($_POST['template_key']) ? sanitize_text_field(wp_unslash($_POST['template_key'])) : '';
        $result       = $this->remove_fse_template($template_key);

        if (is_wp_error($result)) {
            wp_send_json_error(['message' => $result->get_error_message()]);
        }

        wp_send_json_success([
            'message'      => esc_html__('Template removed successfully.', 'memberpress'),
            'template_key' => $template_key,
            'status'       => $this->get_template_status($template_key),
        ]);
    }

    /**
     * Determine whether a template should be auto-applied when enabling FSE mode.
     *
     * Checks the legacy ReadyLaunch toggle state for core templates.
     * Add-ons can filter 'mepr_fse_should_auto_apply_template' to control
     * their own templates based on their ReadyLaunch state.
     *
     * Rules:
     * 1. If the legacy toggle is explicitly disabled, skip (preserve disabled state).
     * 2. If no legacy value exists, enable by default.
     * 3. If the add-on was just activated, enable by default.
     *
     * @param  string $key      The template key (e.g. 'checkout', 'directory').
     * @param  array  $template The template config from get_fse_ui_templates_config().
     * @return boolean
     */
    private function should_auto_apply_template(string $key, array $template): bool
    {
        $should_apply = true;

        // Core templates: check the ReadyLaunch design_enable_{legacy_key}_template option.
        if (!empty($template['legacy_key'])) {
            $legacy_key   = $template['legacy_key'];
            $mepr_options = MeprOptions::fetch();
            $option_name  = 'design_enable_' . $legacy_key . '_template';

            if (isset($mepr_options->$option_name)) {
                $should_apply = filter_var($mepr_options->$option_name, FILTER_VALIDATE_BOOLEAN);
            }
            // If not set, default to true (rule 2).
        }

        /**
         * Filters whether a template should be auto-applied when enabling FSE mode.
         *
         * Add-ons can use this to check their own ReadyLaunch toggle state and
         * skip applying the FSE template if it was explicitly disabled.
         *
         * @param bool   $should_apply Whether the template should be applied.
         * @param string $key          The template key (e.g. 'directory', 'profiles', 'circles').
         * @param array  $template     The template config array.
         */
        return (bool) MeprHooks::apply_filters('mepr_fse_should_auto_apply_template', $should_apply, $key, $template);
    }
}
