<?php
defined( 'ABSPATH' ) || exit;

/**
 * Theme Builder — manages header, footer, single, archive, search, and 404
 * templates with priority-based condition matching.
 *
 * Templates are stored as private 'jalpi_theme_tpl' CPT posts; their Jalpi
 * Page Builder page data lives in the shared wp_jalpi_pages table (by post_id).
 */
class Jalpi_Theme_Builder {

    private static ?self $instance = null;

    /** CPT slug. */
    public const CPT = 'jalpi_theme_tpl';

    /** All supported template locations. */
    public const LOCATIONS = [
        'header'  => [
            'label'       => 'Header',
            'icon'        => 'header',
            'description' => 'Displayed at the top of every page (above the main content).',
        ],
        'footer'  => [
            'label'       => 'Footer',
            'icon'        => 'footer',
            'description' => 'Displayed at the bottom of every page (below the main content).',
        ],
        'single'  => [
            'label'       => 'Single Post',
            'icon'        => 'single',
            'description' => 'Replaces the theme template for individual posts and pages.',
        ],
        'archive' => [
            'label'       => 'Archive',
            'icon'        => 'archive',
            'description' => 'Replaces category, tag, author, and date archive templates.',
        ],
        'search'  => [
            'label'       => 'Search Results',
            'icon'        => 'search',
            'description' => 'Replaces the search results page template.',
        ],
        '404'     => [
            'label'       => '404 Page',
            'icon'        => '404',
            'description' => 'Replaces the theme\'s 404 / page-not-found template.',
        ],
    ];

    /**
     * Return translated location data for admin display and JS localization.
     * The LOCATIONS const holds English keys for internal use; this method
     * wraps labels and descriptions with __() for proper i18n.
     */
    public static function get_locations(): array {
        return [
            'header'  => [
                'label'       => __( 'Header', 'jalpi-page-builder' ),
                'icon'        => 'header',
                'description' => __( 'Displayed at the top of every page (above the main content).', 'jalpi-page-builder' ),
            ],
            'footer'  => [
                'label'       => __( 'Footer', 'jalpi-page-builder' ),
                'icon'        => 'footer',
                'description' => __( 'Displayed at the bottom of every page (below the main content).', 'jalpi-page-builder' ),
            ],
            'single'  => [
                'label'       => __( 'Single Post', 'jalpi-page-builder' ),
                'icon'        => 'single',
                'description' => __( 'Replaces the theme template for individual posts and pages.', 'jalpi-page-builder' ),
            ],
            'archive' => [
                'label'       => __( 'Archive', 'jalpi-page-builder' ),
                'icon'        => 'archive',
                'description' => __( 'Replaces category, tag, author, and date archive templates.', 'jalpi-page-builder' ),
            ],
            'search'  => [
                'label'       => __( 'Search Results', 'jalpi-page-builder' ),
                'icon'        => 'search',
                'description' => __( 'Replaces the search results page template.', 'jalpi-page-builder' ),
            ],
            '404'     => [
                'label'       => __( '404 Page', 'jalpi-page-builder' ),
                'icon'        => '404',
                'description' => __( 'Replaces the theme\'s 404 / page-not-found template.', 'jalpi-page-builder' ),
            ],
        ];
    }

    /** Content template matched by template_include for the current request. */
    private ?WP_Post $active_content_tpl = null;

    /** True when theme-builder/template.php is handling the current request. */
    private bool $using_jalpi_template = false;

    /** Re-entry guard — prevents recursive calls inside render_template(). */
    private bool $rendering = false;

    /**
     * Template id → rendered HTML, populated during wp_enqueue_scripts.
     *
     * Templates must render before wp_head() so the CSS their elements emit can
     * still be enqueued; the markup is held here until output time.
     *
     * @var array<int,string>
     */
    private array $rendered_html = [];

    public static function instance(): self {
        if ( null === self::$instance ) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    private function __construct() {
        add_action( 'init',                  [ $this, 'register_cpt' ] );
        add_action( 'admin_menu',            [ $this, 'register_admin_menu' ] );
        add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_assets' ] );

        // Starter Sites live preview (AJAX — serves a full HTML document)
        add_action( 'wp_ajax_jalpi_site_preview', [ $this, 'handle_site_preview' ] );

        // Frontend hooks
        add_filter( 'template_include',   [ $this, 'maybe_override_template' ], 99 );
        // Priority 5: render_to_string() calls reset_state(), which clears the
        // renderer's accumulated CSS. Running before Jalpi_Renderer's own
        // enqueue_frontend_assets() (priority 10) means the page's CSS is
        // compiled last and cannot be wiped by a template render.
        add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_template_assets' ], 5 );
        add_action( 'wp_body_open',       [ $this, 'render_header_template' ] );
        add_action( 'wp_footer',          [ $this, 'render_footer_template' ], 1 );

        // Allow the Jalpi Page Builder editor to open jalpi_theme_tpl posts.
        add_filter( 'jalpi_internal_editor_post_types', fn( $t ) => array_merge( (array) $t, [ self::CPT ] ) );
    }

    // ── CPT ───────────────────────────────────────────────────────────────────

    public function register_cpt(): void {
        register_post_type( self::CPT, [
            'labels'          => [
                'name'          => __( 'Theme Templates', 'jalpi-page-builder' ),
                'singular_name' => __( 'Theme Template', 'jalpi-page-builder' ),
            ],
            'public'          => false,
            'show_ui'         => false,
            'show_in_rest'    => false,
            'supports'        => [ 'title', 'author' ],
            'has_archive'     => false,
            'rewrite'         => false,
            'capabilities'    => [
                'edit_post'          => 'edit_theme_options',
                'read_post'          => 'read',
                'delete_post'        => 'edit_theme_options',
                'edit_posts'         => 'edit_theme_options',
                'edit_others_posts'  => 'edit_theme_options',
                'publish_posts'      => 'edit_theme_options',
                'read_private_posts' => 'edit_theme_options',
            ],
            'map_meta_cap'    => false,
        ] );
    }

    // ── Admin ─────────────────────────────────────────────────────────────────

    public function register_admin_menu(): void {
        add_submenu_page(
            'jalpi-page-builder-editor',
            __( 'Theme Builder', 'jalpi-page-builder' ),
            __( 'Theme Builder', 'jalpi-page-builder' ),
            'edit_theme_options',
            'jalpi-theme-builder',
            [ $this, 'render_admin_page' ]
        );

        add_submenu_page(
            'jalpi-page-builder-editor',
            __( 'Starter Sites', 'jalpi-page-builder' ),
            __( 'Starter Sites', 'jalpi-page-builder' ),
            'manage_options',
            'jalpi-website-templates',
            [ $this, 'render_website_templates_page' ]
        );
    }

    public function enqueue_admin_assets( string $hook ): void {
        // ── Theme Builder ────────────────────────────────────────────────────
        if ( false !== strpos( $hook, 'jalpi-theme-builder' ) ) {
            wp_enqueue_style(
                'jalpi-theme-builder',
                JALPI_URL . 'admin/theme-builder/theme-builder.css',
                [],
                JALPI_VERSION
            );
            wp_enqueue_script(
                'jalpi-theme-builder',
                JALPI_URL . 'admin/theme-builder/theme-builder.js',
                [],
                JALPI_VERSION,
                true
            );
            wp_localize_script( 'jalpi-theme-builder', 'jalpiTB', [
                'apiUrl'    => esc_url_raw( rest_url( 'jalpi-page-builder/v1' ) ),
                'nonce'     => wp_create_nonce( 'wp_rest' ),
                'editorUrl' => esc_url_raw( admin_url( 'admin.php?page=jalpi-page-builder-editor&post_id=' ) ),
                'locations' => self::get_locations(),
                'i18n'      => [
                    'createFirstTemplate'  => __( 'Create First Template', 'jalpi-page-builder' ),
                    'active'               => __( '● Active', 'jalpi-page-builder' ),
                    'inactive'             => __( '○ Inactive', 'jalpi-page-builder' ),
                    'activate'             => __( 'Activate', 'jalpi-page-builder' ),
                    'deactivate'           => __( 'Deactivate', 'jalpi-page-builder' ),
                    'conditions'           => __( 'Conditions', 'jalpi-page-builder' ),
                    'edit'                 => __( 'Edit', 'jalpi-page-builder' ),
                    'creating'             => __( 'Creating…', 'jalpi-page-builder' ),
                    'createEdit'           => __( 'Create & Edit', 'jalpi-page-builder' ),
                    'noConditions'         => __( 'No conditions added yet. Click "+ Add Condition" to start.', 'jalpi-page-builder' ),
                    'entireSite'           => __( 'Entire Site', 'jalpi-page-builder' ),
                    'allSingular'          => __( 'All Singular', 'jalpi-page-builder' ),
                    'allArchives'          => __( 'All Archives', 'jalpi-page-builder' ),
                    'postType'             => __( 'Post Type…', 'jalpi-page-builder' ),
                    'category'             => __( 'Category…', 'jalpi-page-builder' ),
                    'tag'                  => __( 'Tag…', 'jalpi-page-builder' ),
                    'postId'               => __( 'Post ID…', 'jalpi-page-builder' ),
                    'author'               => __( 'Author…', 'jalpi-page-builder' ),
                    'searchResults'        => __( 'Search Results', 'jalpi-page-builder' ),
                    'notFoundPage'         => __( '404 Page', 'jalpi-page-builder' ),
                    'is'                   => __( 'Is', 'jalpi-page-builder' ),
                    'isNot'                => __( 'Is Not', 'jalpi-page-builder' ),
                    'postIdPlaceholder'    => __( 'Post ID', 'jalpi-page-builder' ),
                    'authorPlaceholder'    => __( 'Author login or ID', 'jalpi-page-builder' ),
                    'valuePlaceholder'     => __( 'Value', 'jalpi-page-builder' ),
                    'error'                => __( 'Error: ', 'jalpi-page-builder' ),
                    'noCategoriesFound'    => __( 'No categories found', 'jalpi-page-builder' ),
                    'noTagsFound'          => __( 'No tags found', 'jalpi-page-builder' ),
                    'saving'               => __( 'Saving…', 'jalpi-page-builder' ),
                    'saveConditions'       => __( 'Save Conditions', 'jalpi-page-builder' ),
                    /* translators: %s: template name */
                    'deleteConfirm'        => __( 'Delete "%s"? This cannot be undone.', 'jalpi-page-builder' ),
                    /* translators: %d: priority number */
                    'priority'             => __( 'Priority %d', 'jalpi-page-builder' ),
                    'condition'            => __( 'condition', 'jalpi-page-builder' ),
                    'conditions_plural'    => __( 'conditions', 'jalpi-page-builder' ),
                    'condNoDisplay'        => __( 'No conditions — template won\'t display', 'jalpi-page-builder' ),
                ],
            ] );
        }

        // ── Website Templates ────────────────────────────────────────────────
        if ( false !== strpos( $hook, 'jalpi-website-templates' ) ) {
            // Version by file mtime so edits to page.css/page.js bust the browser
            // cache immediately (JALPI_VERSION does not change across edits).
            $css_path = JALPI_DIR . 'admin/website-templates/page.css';
            $js_path  = JALPI_DIR . 'admin/website-templates/page.js';
            $css_ver  = ( $m = @filemtime( $css_path ) ) ? JALPI_VERSION . '.' . $m : JALPI_VERSION;
            $js_ver   = ( $m = @filemtime( $js_path ) )  ? JALPI_VERSION . '.' . $m : JALPI_VERSION;
            wp_enqueue_style(
                'jalpi-website-templates',
                JALPI_URL . 'admin/website-templates/page.css',
                [],
                $css_ver
            );
            wp_enqueue_script(
                'jalpi-website-templates',
                JALPI_URL . 'admin/website-templates/page.js',
                [],
                $js_ver,
                true
            );
            wp_localize_script( 'jalpi-website-templates', 'jalpiWT', [
                'apiUrl'       => esc_url_raw( rest_url( 'jalpi-page-builder/v1' ) ),
                'nonce'        => wp_create_nonce( 'wp_rest' ),
                'ajaxUrl'      => esc_url_raw( admin_url( 'admin-ajax.php' ) ),
                'previewNonce' => wp_create_nonce( 'jalpi_preview_site' ),
                'i18n'         => [
                    /* translators: %d: number of pages */
                    'pagesWillBeCreated' => __( '%d pages will be created', 'jalpi-page-builder' ),
                    'pageCreated'        => __( 'page created successfully.', 'jalpi-page-builder' ),
                    'pagesCreated'       => __( 'pages created successfully.', 'jalpi-page-builder' ),
                    'edit'               => __( 'Edit', 'jalpi-page-builder' ),
                    'view'               => __( 'View', 'jalpi-page-builder' ),
                ],
            ] );
        }
    }

    public function render_admin_page(): void {
        include JALPI_DIR . 'admin/theme-builder/admin-page.php';
    }

    public function render_website_templates_page(): void {
        include JALPI_DIR . 'admin/website-templates/page.php';
    }

    // ── Template override ─────────────────────────────────────────────────────

    public function maybe_override_template( string $template ): string {
        if ( is_admin() || wp_doing_ajax() ) return $template;

        // Live preview: ?jalpi_preview_tpl=ID
        $preview_id = absint( $_GET['jalpi_preview_tpl'] ?? 0 );
        if ( $preview_id ) {
            $nonce = isset( $_GET['jalpi_preview_nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['jalpi_preview_nonce'] ) ) : '';
            if ( ! wp_verify_nonce( $nonce, 'jalpi_preview_tpl_' . $preview_id ) ) {
                $preview_id = 0;
            }
            // Only users with edit_theme_options capability can preview theme templates
            if ( $preview_id && ! current_user_can( 'edit_theme_options' ) ) {
                $preview_id = 0;
            }
        }
        if ( $preview_id ) {
            $post = get_post( $preview_id );
            // Only show published templates to prevent exposing private/unpublished content
            if ( $post && get_post_type( $post ) === self::CPT && 'publish' === $post->post_status ) {
                $this->active_content_tpl = $post;
                $this->using_jalpi_template  = true;
                return JALPI_DIR . 'theme-builder/template.php';
            }
        }

        $location = $this->get_current_location();
        if ( ! $location || in_array( $location, [ 'header', 'footer' ], true ) ) {
            return $template;
        }

        $tpl = $this->find_template( $location );
        if ( ! $tpl ) {
            // No content template, but if a Jalpiheader or footer is active we
            // still take over so the theme's header/footer HTML doesn't render
            // alongside our custom one.
            if ( $this->find_template( 'header' ) || $this->find_template( 'footer' ) ) {
                $this->using_jalpi_template = true;
                return JALPI_DIR . 'theme-builder/template.php';
            }
            return $template;
        }

        $this->active_content_tpl = $tpl;
        $this->using_jalpi_template  = true;
        return JALPI_DIR . 'theme-builder/template.php';
    }

    /**
     * Render the active header/footer templates and enqueue their assets.
     *
     * Both render far too late on their own: wp_body_open and wp_footer fire
     * after wp_head() has already printed every stylesheet, so the per-node CSS
     * that render_to_string() feeds to wp_add_inline_style() is silently
     * discarded — the markup ships with none of its colours, spacing or
     * alignment. The same lateness leaves jalpi-frontend.js unenqueued, since
     * Jalpi_Renderer::enqueue_frontend_assets() only loads it for posts carrying
     * _jalpi_has_canvas_data and a header appears on pages that have none.
     *
     * Rendering here — during wp_enqueue_scripts, before wp_head() — fixes both:
     * the CSS is still collectable, and the resulting HTML is cached in
     * $rendered_html for render_template() to hand back at output time.
     */
    public function enqueue_template_assets(): void {
        if ( is_admin() ) {
            return;
        }

        $templates = array_filter( [
            $this->find_template( 'header' ),
            $this->find_template( 'footer' ),
        ] );

        if ( ! $templates ) {
            return;
        }

        if ( ! wp_style_is( 'jalpi-frontend', 'registered' ) ) {
            wp_register_style(
                'jalpi-frontend',
                JALPI_ASSETS_URL . 'css/frontend.css',
                [],
                Jalpi_Renderer::frontend_css_ver()
            );
        }
        wp_enqueue_style( 'jalpi-frontend' );

        // Registered by Jalpi_Renderer for canvas pages; register it here too so
        // the handle exists on pages the renderer skipped.
        if ( ! wp_script_is( 'jalpi-frontend', 'registered' ) ) {
            wp_register_script(
                'jalpi-frontend',
                JALPI_ASSETS_URL . 'js/jalpi-frontend.js',
                [],
                Jalpi_Renderer::frontend_js_ver(),
                true
            );
        }

        // render_to_string() enqueues each template's own CSS, fonts and icon
        // libraries, and enqueues the script when the template holds anything
        // interactive.
        foreach ( $templates as $tpl ) {
            $this->render_template( $tpl );
        }

        // A header may contain only static elements, in which case
        // render_to_string() leaves the script alone. Enqueue it regardless:
        // the responsive menu toggle is driven by JS at every breakpoint.
        wp_enqueue_script( 'jalpi-frontend' );
    }

    /**
     * Inject header template via wp_body_open action.
     * Skipped when theme-builder/template.php is already handling the page
     * (render_active_templates() renders the header there instead).
     */
    public function render_header_template(): void {
        if ( $this->using_jalpi_template ) return;
        $tpl = $this->find_template( 'header' );
        if ( $tpl ) {
            echo '<a class="jalpi-skip-link screen-reader-text" href="#content">'
                . esc_html__( 'Skip to content', 'jalpi-page-builder' )
                . '</a>';
            echo wp_kses( $this->render_template( $tpl ), Jalpi_Renderer::allowed_html() );
        }
    }

    /**
     * Inject footer template via wp_footer action.
     * Skipped when theme-builder/template.php is already handling the page.
     */
    public function render_footer_template(): void {
        if ( $this->using_jalpi_template ) return;
        $tpl = $this->find_template( 'footer' );
        if ( $tpl ) {
            echo wp_kses( $this->render_template( $tpl ), Jalpi_Renderer::allowed_html() );
        }
    }

    /**
     * Called by theme-builder/template.php.
     * Outputs: Jalpi header → page content → Jalpi footer.
     *
     * When a Jalpicontent template is assigned for the current location it is
     * rendered by render_template(). Otherwise render_wp_content() runs the
     * standard WordPress loop so the theme's own post/page content still shows.
     */
    public function render_active_templates(): string {
        $out = '';

        $header = $this->find_template( 'header' );
        if ( $header ) $out .= $this->render_template( $header );

        if ( $this->active_content_tpl ) {
            $out .= $this->render_template( $this->active_content_tpl );
        } else {
            $out .= $this->render_wp_content();
        }

        $footer = $this->find_template( 'footer' );
        if ( $footer ) $out .= $this->render_template( $footer );

        return $out;
    }

    /**
     * Renders the current WordPress page content when no Jalpi content template
     * is assigned. Used when a Jalpiheader/footer is active and we have taken
     * over the page template, so we must output the body ourselves.
     */
    private function render_wp_content(): string {
        ob_start();

        // ── WooCommerce pages ────────────────────────────────────────────────
        // On WC pages (shop/product archives, single product, cart, checkout,
        // account) defer to WooCommerce's own content renderer so its loop, hooks
        // (woocommerce_before_main_content, add-to-cart, etc.) and templates run.
        // Without this the generic loop below would show plain post excerpts and
        // no products. woocommerce_content() outputs the correct WC markup, which
        // our frontend.css then themes to the LUXE look.
        // A page BUILT IN JALPI is excluded: its canvas is the page (it can embed
        // [woocommerce_cart]/[woocommerce_checkout] itself). Without this guard a
        // Jalpi-built Cart/Checkout page is silently replaced by bare WooCommerce
        // output and everything the user designed disappears.
        $jalpi_built = is_singular()
            && get_post_meta( get_queried_object_id(), '_jalpi_has_canvas_data', true );

        if ( ! $jalpi_built
            && function_exists( 'is_woocommerce' ) && function_exists( 'woocommerce_content' )
            && ( is_woocommerce() || is_cart() || is_checkout() || is_account_page() ) ) {
            echo '<div class="woocommerce jalpi-woo-content">';
            woocommerce_content();
            echo '</div>';
            return ob_get_clean() ?: '';
        }

        if ( is_404() ) {
            echo '<div class="error-404 not-found"><p>' . esc_html__( 'Page not found.', 'jalpi-page-builder' ) . '</p></div>';
        } elseif ( is_singular() && have_posts() ) {
            the_post();
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
            the_content();
        } elseif ( have_posts() ) {
            // Archive, home, search, etc.
            echo '<div class="jalpi-archive-content">';
            while ( have_posts() ) {
                the_post();
                printf(
                    '<article id="post-%1$s" class="jalpi-archive-post"><h2><a href="%2$s">%3$s</a></h2><div class="entry-summary">%4$s</div></article>',
                    esc_attr( (string) get_the_ID() ),
                    esc_url( get_permalink() ),
                    esc_html( get_the_title() ),
                    wp_kses_post( get_the_excerpt() )
                );
            }
            echo '</div>';
        }

        return ob_get_clean() ?: '';
    }

    // ── Condition matching ────────────────────────────────────────────────────

    /**
     * Find the highest-priority active template for a location that matches
     * the current WP request context.
     */
    public function find_template( string $location ): ?WP_Post {
        // phpcs:ignore WordPress.DB.SlowDBQuery
        $templates = get_posts( [
            'post_type'      => self::CPT,
            'post_status'    => 'publish',
            'meta_query'     => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
                'relation' => 'AND',
                [ 'key' => '_jalpi_tpl_location', 'value' => $location ],
                [ 'key' => '_jalpi_tpl_status',   'value' => 'active'  ],
            ],
            'meta_key'       => '_jalpi_tpl_priority', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
            'orderby'        => 'meta_value_num',
            'order'          => 'ASC',
            'posts_per_page' => 20,
            'no_found_rows'  => true,
        ] );

        foreach ( $templates as $tpl ) {
            $raw        = get_post_meta( $tpl->ID, '_jalpi_tpl_conditions', true ) ?: '[]';
            $conditions = json_decode( $raw, true ) ?: [];
            if ( $this->matches_conditions( (array) $conditions ) ) {
                return $tpl;
            }
        }

        return null;
    }

    /**
     * Returns true when the conditions array qualifies the template for the
     * current request. At least one "is" condition must match, and no "is_not"
     * condition may match.
     */
    public function matches_conditions( array $conditions ): bool {
        if ( empty( $conditions ) ) return false;

        $positive = array_filter( $conditions, fn( $c ) => ( $c['operator'] ?? 'is' ) === 'is' );
        $negative = array_filter( $conditions, fn( $c ) => ( $c['operator'] ?? 'is' ) === 'is_not' );

        foreach ( $negative as $c ) {
            if ( $this->check_condition( $c ) ) return false;
        }

        // If no positive conditions exist, treat as "match all" (negative-only templates
        // should match everywhere except the excluded contexts).
        if ( empty( $positive ) ) return true;

        foreach ( $positive as $c ) {
            if ( $this->check_condition( $c ) ) return true;
        }

        return false;
    }

    private function check_condition( array $c ): bool {
        $type  = sanitize_key( $c['type']  ?? '' );
        $value = sanitize_text_field( $c['value'] ?? '' );

        switch ( $type ) {
            case 'entire_site':
                return true;

            case 'singular':
                return is_singular();

            case 'post_type':
                return is_singular( $value ) || is_post_type_archive( $value );

            case 'all_archives':
                return is_archive() || is_home();

            case 'category':
                return is_category( $value ) || ( is_singular() && in_category( $value ) );

            case 'tag':
                return is_tag( $value ) || ( is_singular() && has_tag( $value ) );

            case 'post_id':
                return is_singular() && (int) get_the_ID() === (int) $value;

            case 'author':
                return is_author( $value );

            case 'search':
                return is_search();

            case '404':
                return is_404();
        }

        return false;
    }

    private function get_current_location(): ?string {
        if ( is_404() )                  return '404';
        if ( is_search() )               return 'search';
        if ( is_singular() )             return 'single';
        if ( is_archive() || is_home() ) return 'archive';
        return null;
    }

    // ── Rendering ─────────────────────────────────────────────────────────────

    public function render_template( WP_Post $tpl ): string {
        // Serve the copy rendered during wp_enqueue_scripts. Re-rendering here
        // would repeat the work and, more importantly, throw away its CSS —
        // see enqueue_template_assets() for why the render must happen early.
        if ( isset( $this->rendered_html[ $tpl->ID ] ) ) {
            return $this->rendered_html[ $tpl->ID ];
        }

        if ( $this->rendering ) return '';
        $this->rendering = true;

        try {
            $page_data = Jalpi_API::instance()->load_page_data( $tpl->ID );

            // Cache the empty result too, so an unbuilt template is not
            // re-queried on every call.
            $html = empty( $page_data['sections'] )
                ? ''
                // Resolve dynamic tags against the currently queried post, not the template post.
                : Jalpi_Renderer::instance()->render_to_string(
                    $page_data,
                    is_singular() ? (int) get_the_ID() : 0
                );
        } finally {
            $this->rendering = false;
        }

        $this->rendered_html[ $tpl->ID ] = $html;

        return $html;
    }

    // ── Starter Sites live preview ────────────────────────────────────────────

    /**
     * AJAX handler: renders a kit page to a standalone HTML document for iframe preview.
     * URL: admin-ajax.php?action=jalpi_site_preview&website_id=agency&page_slug=about&_wpnonce=...
     */
    public function handle_site_preview(): void {
        if ( ! check_ajax_referer( 'jalpi_preview_site', '_wpnonce', false ) ) {
            wp_die( esc_html__( 'Invalid nonce.', 'jalpi-page-builder' ), '', [ 'response' => 403 ] );
        }
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_die( esc_html__( 'Unauthorized.', 'jalpi-page-builder' ), '', [ 'response' => 403 ] );
        }

        $website_id = sanitize_key( $_GET['website_id'] ?? '' );
        $page_slug  = sanitize_key( $_GET['page_slug']  ?? '__front__' );

        // Reject path separators to prevent directory traversal.
        if ( ! $website_id || false !== strpos( $website_id, '/' ) || false !== strpos( $website_id, '\\' ) ) {
            wp_die( esc_html__( 'Invalid website ID.', 'jalpi-page-builder' ), '', [ 'response' => 400 ] );
        }

        $kit_file = JALPI_DIR . 'websites/' . $website_id . '.json';
        if ( ! file_exists( $kit_file ) ) {
            wp_die( esc_html__( 'Theme not found.', 'jalpi-page-builder' ), '', [ 'response' => 404 ] );
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading a local plugin JSON file; WP_Filesystem requires credentials and is not suitable here
        $kit   = json_decode( file_get_contents( $kit_file ), true );
        $pages = $kit['pages'] ?? [];

        // Find requested page
        $current_page = null;
        foreach ( $pages as $page ) {
            if ( $page_slug === '__front__' ) {
                if ( ! empty( $page['is_front'] ) ) { $current_page = $page; break; }
            } elseif ( sanitize_key( $page['slug'] ?? '' ) === $page_slug ) {
                $current_page = $page;
                break;
            }
        }
        if ( ! $current_page && ! empty( $pages ) ) {
            $current_page = $pages[0];
        }

        // Kit global templates (header/footer) shipped in the kit `templates`
        // array — rendered around the page so the preview matches what the
        // imported site shows (header/footer replace the theme chrome on import).
        // Preview mode: kit templates show their own demo navigation rather
        // than the current site's assigned menu, so the preview represents the
        // kit's design. Cleared in the finally below.
        Jalpi_Renderer::instance()->set_preview_mode( true );

        $tpl_header = '';
        $tpl_footer = '';
        foreach ( (array) ( $kit['templates'] ?? [] ) as $tpl_def ) {
            if ( ! is_array( $tpl_def ) || empty( $tpl_def['canvas_data'] ) ) {
                continue;
            }
            $loc = sanitize_key( $tpl_def['location'] ?? '' );
            try {
                if ( 'header' === $loc && '' === $tpl_header ) {
                    $tpl_header = Jalpi_Renderer::instance()->render_to_string( $tpl_def['canvas_data'], 0 );
                } elseif ( 'footer' === $loc && '' === $tpl_footer ) {
                    $tpl_footer = Jalpi_Renderer::instance()->render_to_string( $tpl_def['canvas_data'], 0 );
                }
            } catch ( \Throwable $e ) {
                // A bad template must not break the whole preview.
            }
        }

        // Render page canvas to HTML
        $content_html = '';
        if ( $current_page && ! empty( $current_page['canvas_data'] ) ) {
            try {
                $content_html = Jalpi_Renderer::instance()->render_to_string(
                    $current_page['canvas_data'],
                    0
                );
            } catch ( \Throwable $e ) {
                $content_html = '<div style="padding:60px;text-align:center;color:#dc2626;font-family:Inter,sans-serif">'
                    . '<p style="font-size:14px">Preview render error: ' . esc_html( $e->getMessage() ) . '</p></div>';
            }
        }

        // Every kit render for this request is done — clear the flag so the
        // singleton renderer does not carry preview behaviour into later calls.
        Jalpi_Renderer::instance()->set_preview_mode( false );

        // Compose header → page → footer so the preview mirrors the live site.
        $content_html = $tpl_header . $content_html . $tpl_footer;

        // ── Rewrite internal nav links to PREVIEW links ─────────────────────
        // The kit header/footer link to real site paths (/shop, /about, …). In a
        // standalone preview those would leave the sandbox. Map each kit page's
        // path → its preview URL so navigation stays inside the preview. Paths
        // with no matching kit page (e.g. /shop when WooCommerce owns it, external
        // links) are neutralised to '#' so they don't break out.
        $preview_nonce = wp_create_nonce( 'jalpi_preview_site' );
        $ajax          = admin_url( 'admin-ajax.php' );
        $slug_by_path  = [];
        foreach ( $pages as $pg ) {
            $slug = sanitize_key( $pg['slug'] ?? '' );
            if ( '' === $slug ) { continue; }
            $is_front = ! empty( $pg['is_front'] );
            $preview_href = esc_url( add_query_arg( [
                'action'     => 'jalpi_site_preview',
                'website_id' => $website_id,
                'page_slug'  => $is_front ? '__front__' : $slug,
                '_wpnonce'   => $preview_nonce,
            ], $ajax ) );
            // Match both "/slug" and "/slug/" and (for front) "/".
            $slug_by_path[ '/' . $slug ]      = $preview_href;
            $slug_by_path[ '/' . $slug . '/' ] = $preview_href;
            if ( $is_front ) {
                $slug_by_path['/'] = $preview_href;
            }
        }
        $content_html = preg_replace_callback(
            '/href="(\/[a-z0-9\-\/]*)"/i',
            static function ( $m ) use ( $slug_by_path ) {
                $path = rtrim( $m[1], '/' ) === '' ? '/' : $m[1];
                if ( isset( $slug_by_path[ $path ] ) ) {
                    return 'href="' . $slug_by_path[ $path ] . '"';
                }
                if ( isset( $slug_by_path[ rtrim( $path, '/' ) ] ) ) {
                    return 'href="' . $slug_by_path[ rtrim( $path, '/' ) ] . '"';
                }
                // Unknown internal path → keep it inert inside the preview.
                return 'href="#"';
            },
            $content_html
        );

        // ── Build a sticky preview navigation bar ────────────────────────────
        // Kits can ship 19+ pages, which wraps a flat tab row onto several lines
        // and looks cluttered. So: a few primary pages stay as tabs and the rest
        // are grouped into dropdowns. Grouping is by slug keyword so it works for
        // any kit without the kit having to declare a menu structure.
        $tab_style  = 'display:inline-flex;align-items:center;padding:8px 16px;border-radius:100px;font:600 12px/1 Inter,sans-serif;letter-spacing:.04em;text-decoration:none;white-space:nowrap;transition:all .2s ease;';
        $idle_style = 'background:rgba(255,255,255,.04);color:#cbd5e1;border:1px solid #2b3350;';
        $on_style   = 'background:linear-gradient(135deg,#7c5cff,#22d3ee);color:#fff;box-shadow:0 6px 18px rgba(124,92,255,.4);border:1px solid transparent;';

        $mk_href = static function ( $pg ) use ( $website_id, $preview_nonce, $ajax ) {
            return esc_url( add_query_arg( [
                'action'     => 'jalpi_site_preview',
                'website_id' => $website_id,
                'page_slug'  => ! empty( $pg['is_front'] ) ? '__front__' : sanitize_key( $pg['slug'] ?? '' ),
                '_wpnonce'   => $preview_nonce,
            ], $ajax ) );
        };
        $is_active = static function ( $pg ) use ( $page_slug ) {
            $slug = sanitize_key( $pg['slug'] ?? '' );
            return ( ! empty( $pg['is_front'] ) && '__front__' === $page_slug )
                || ( '' !== $slug && $slug === $page_slug );
        };

        // Which group a page belongs to, decided from its slug.
        //
        // Keyword lists are intentionally broad so a page from ANY kit type
        // (agency, medical, travel, commerce…) lands on a visible tab or a
        // meaningfully-named dropdown rather than being buried in "More" — the
        // symptom being that key pages like Doctors / Departments / Tours
        // looked "missing" in the preview.
        $group_of = static function ( $pg ) {
            $slug = sanitize_key( $pg['slug'] ?? '' );
            if ( ! empty( $pg['is_front'] ) ) { return 'primary'; }
            // Top-level pages every visitor expects as a direct tab. EXACT match
            // only, so detail pages (service-details, department-detail) fall to
            // their group dropdown instead of the top bar.
            $primary = [
                'about', 'services', 'portfolio', 'contact', 'work',
                'departments', 'doctors', 'tours', 'gallery', 'pricing', 'blog',
                'menu', 'shop', 'rooms', 'programs', 'team',
            ];
            if ( in_array( $slug, $primary, true ) ) { return 'primary'; }
            // Work / portfolio / case studies / medical departments / tours.
            foreach ( [ 'service', 'portfolio', 'case', 'work', 'project', 'department', 'doctor', 'tour', 'gallery', 'program', 'course', 'room', 'menu' ] as $k ) {
                if ( false !== strpos( $slug, $k ) ) { return 'Work'; }
            }
            // Company / info / people / pricing / patient & booking pages.
            foreach ( [ 'team', 'career', 'pricing', 'testimonial', 'faq', 'about', 'patient', 'info', 'appointment', 'booking', 'book', 'destination', 'coming', 'privacy', 'terms', '404' ] as $k ) {
                if ( false !== strpos( $slug, $k ) ) { return 'Company'; }
            }
            // Editorial.
            foreach ( [ 'blog', 'news', 'article', 'post', 'story', 'journal', 'single' ] as $k ) {
                if ( false !== strpos( $slug, $k ) ) { return 'Blog'; }
            }
            return 'More';
        };

        $primary_tabs = '';
        $groups       = [ 'Work' => [], 'Company' => [], 'Blog' => [], 'More' => [] ];
        foreach ( $pages as $pg ) {
            $g = $group_of( $pg );
            if ( 'primary' === $g ) {
                $primary_tabs .= '<a href="' . $mk_href( $pg ) . '" style="' . $tab_style
                    . ( $is_active( $pg ) ? $on_style : $idle_style ) . '">'
                    . esc_html( $pg['title'] ?? '' ) . '</a>';
            } else {
                $groups[ $g ][] = $pg;
            }
        }

        // Render each non-empty group as a hover dropdown. Pure CSS (:hover) so
        // it needs no JS inside the standalone preview document.
        $dropdowns = '';
        foreach ( $groups as $label => $items ) {
            if ( ! $items ) { continue; }
            $has_active = false;
            $menu       = '';
            foreach ( $items as $pg ) {
                $on = $is_active( $pg );
                if ( $on ) { $has_active = true; }
                $menu .= '<a href="' . $mk_href( $pg ) . '" style="display:block;padding:9px 14px;border-radius:9px;'
                    . 'font:600 12px/1.3 Inter,sans-serif;text-decoration:none;white-space:nowrap;'
                    . ( $on ? 'background:rgba(124,92,255,.22);color:#fff;' : 'color:#cbd5e1;' )
                    . '">' . esc_html( $pg['title'] ?? '' ) . '</a>';
            }
            $dropdowns .= '<span class="jalpi-pvdd" style="position:relative;display:inline-block;">'
                . '<span style="' . $tab_style . ( $has_active ? $on_style : $idle_style ) . 'cursor:default;">'
                . esc_html( $label ) . ' <span style="margin-left:6px;font-size:9px;opacity:.75;">&#9660;</span></span>'
                . '<span class="jalpi-pvdd__menu" style="position:absolute;top:calc(100% + 8px);left:0;min-width:190px;'
                . 'background:#0f1424;border:1px solid #2b3350;border-radius:14px;padding:7px;'
                . 'box-shadow:0 18px 44px rgba(0,0,0,.55);opacity:0;visibility:hidden;transform:translateY(-6px);'
                . 'transition:opacity .18s ease,transform .18s ease,visibility .18s;z-index:100000;">'
                . $menu . '</span></span>';
        }

        $nav_tabs = $primary_tabs . $dropdowns;
        // Premium glass preview bar: gold-dot brand mark + kit name + page tabs.
        $preview_bar = '<div style="position:sticky;top:0;z-index:99999;display:flex;align-items:center;gap:16px;flex-wrap:wrap;'
            . 'background:linear-gradient(180deg,rgba(11,15,28,0.96),rgba(11,15,28,0.88));border-bottom:1px solid #1e293b;'
            . 'box-shadow:0 8px 30px rgba(0,0,0,.4);padding:12px 20px;font-family:Inter,sans-serif;">'
            . '<span style="display:inline-flex;align-items:center;gap:9px;">'
            . '<span style="width:9px;height:9px;border-radius:50%;background:linear-gradient(135deg,#e7cf9a,#a8813d);box-shadow:0 0 10px rgba(201,163,91,.7);"></span>'
            . '<span style="color:#f5f4f2;font:700 12px/1 Inter,sans-serif;letter-spacing:.18em;text-transform:uppercase;">'
            . esc_html( $kit['name'] ?? 'Preview' ) . '</span>'
            . '<span style="color:#64748b;font:500 11px/1 Inter,sans-serif;letter-spacing:.14em;text-transform:uppercase;margin-left:2px;">Live Preview</span>'
            . '</span>'
            . '<div style="flex:1;height:1px;background:linear-gradient(90deg,#2b3350,transparent);min-width:20px;"></div>'
            . '<div style="display:flex;gap:8px;flex-wrap:wrap;">' . $nav_tabs . '</div>'
            . '</div>';

        $kit_name    = esc_html( $kit['name'] ?? '' );
        $page_title  = esc_html( $current_page['title'] ?? __( 'Page', 'jalpi-page-builder' ) );

        // Serve a clean, standalone HTML document (no WP admin chrome)
        header( 'Content-Type: text/html; charset=utf-8' );
        header( 'X-Frame-Options: SAMEORIGIN' );
        header( 'Cache-Control: no-store, no-cache' );

        // Enqueue preview styles using WordPress functions.
        $preview_fonts_url = 'https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;1,300;1,400&display=swap';
        wp_register_style( 'jalpi-preview-fonts', $preview_fonts_url, [], JALPI_VERSION );
        wp_enqueue_style( 'jalpi-preview-fonts' );

        // Load the builder's frontend stylesheet so the preview matches the real
        // site: WooCommerce product-grid theming, animations, and all luxe-*
        // classes live here. Without it, WC shortcodes ([products] etc.) fall
        // back to an unstyled vertical list with default blue links. The <body>
        // also gets the `jalpi-theme-page` class below so the WC theme (scoped to
        // body.jalpi-theme-page) applies exactly as on the imported site.
        if ( ! wp_style_is( 'jalpi-frontend', 'registered' ) ) {
            wp_register_style( 'jalpi-frontend', JALPI_ASSETS_URL . 'css/frontend.css', [], Jalpi_Renderer::frontend_css_ver() );
        }
        wp_enqueue_style( 'jalpi-frontend' );

        $preview_reset_css = '*, *::before, *::after { box-sizing: border-box; }'
            . ' html, body { margin: 0; padding: 0; }'
            // overflow-x:hidden kills the ~8px horizontal scroll the full-width
            // section breakout (.jalpi-section--fw margin-left:calc(50% - 50vw))
            // creates against the scrollbar, so the preview never shows a stray
            // bottom scrollbar or a left-shifted first column.
            . ' html, body { overflow-x: hidden; max-width: 100%; }'
            . ' body { font-family: "Inter", sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; background:#0a0a0c; }'
            . ' img { max-width: 100%; display: block; }'
            . ' a { text-decoration: none; }'
            . ' p, h1, h2, h3, h4, h5, h6 { margin: 0; }'
            . ' button { cursor: pointer; }';
        wp_register_style( 'jalpi-preview-reset', false, [], JALPI_VERSION );
        wp_enqueue_style( 'jalpi-preview-reset' );
        wp_add_inline_style( 'jalpi-preview-reset', $preview_reset_css );
        ?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?php echo esc_html( $kit_name ); ?> — <?php echo esc_html( $page_title ); ?></title>
<?php wp_print_styles(); ?>
<style>
/* Preview-bar dropdowns: CSS-only so the standalone preview needs no JS.
   The padding-top on the menu keeps a hoverable bridge across the 8px gap,
   otherwise the menu closes as the pointer travels down to it. */
.jalpi-pvdd__menu { padding-top: 7px; }
.jalpi-pvdd::after { content: ""; position: absolute; left: 0; right: 0; top: 100%; height: 10px; }
.jalpi-pvdd:hover > .jalpi-pvdd__menu,
.jalpi-pvdd:focus-within > .jalpi-pvdd__menu {
  opacity: 1 !important; visibility: visible !important; transform: translateY(0) !important;
}
.jalpi-pvdd__menu a:hover { background: rgba(255,255,255,.07) !important; color: #fff !important; }
.jalpi-pvdd > span:first-child:hover { border-color: #7c5cff !important; }
</style>
</head>
<body class="jalpi-theme-page woocommerce woocommerce-page jalpi-kit-<?php echo esc_attr( sanitize_html_class( $website_id ) ); ?>">
<?php
        // Preview navigation bar — escaped with wp_kses using the plugin's allowed-HTML whitelist.
        echo wp_kses( $preview_bar, Jalpi_Renderer::allowed_html() );
        echo wp_kses( $content_html, Jalpi_Renderer::allowed_html() );
?>
<div style="background:#1e293b;color:#64748b;text-align:center;padding:12px;font-family:Inter,sans-serif;font-size:11px;letter-spacing:0.05em;border-top:1px solid #0f172a">
  JALPI PAGE BUILDER PREVIEW — <?php echo esc_html( strtoupper( $kit['name'] ?? '' ) ); ?>
</div>
<?php
// The preview is a standalone document that never runs wp_footer(), so the
// builder's frontend script would otherwise never load here. Without it
// NOTHING JS-driven animates in the preview — counters, marquees, split text,
// tilt, carousels and the whole interaction engine sit inert.
//
// Register + enqueue + print, rather than echoing a raw <script> tag, so the
// dependency system and any filters still apply (and it satisfies
// WordPress.WP.EnqueuedResources).
wp_register_script(
	'jalpi-frontend',
	JALPI_ASSETS_URL . 'js/jalpi-frontend.js',
	[],
	Jalpi_Renderer::frontend_js_ver(),
	true
);
wp_enqueue_script( 'jalpi-frontend' );
wp_print_scripts( 'jalpi-frontend' );
?>
</body>
</html>
<?php
        exit;
    }

    // ── Preview URL ───────────────────────────────────────────────────────────

    public static function get_preview_url( int $tpl_id ): string {
        $location = get_post_meta( $tpl_id, '_jalpi_tpl_location', true ) ?: 'single';
        $param    = [
            'jalpi_preview_tpl'   => $tpl_id,
            'jalpi_preview_nonce' => wp_create_nonce( 'jalpi_preview_tpl_' . $tpl_id ),
        ];

        switch ( $location ) {
            case '404':
                return add_query_arg( $param, home_url( '/jalpi-404-preview-' . $tpl_id ) );

            case 'search':
                return add_query_arg( array_merge( $param, [ 's' => 'preview' ] ), home_url( '/' ) );

            case 'archive':
                foreach ( get_post_types( [ 'has_archive' => true ], 'names' ) as $type ) {
                    $url = get_post_type_archive_link( $type );
                    if ( $url ) return add_query_arg( $param, $url );
                }
                return add_query_arg( $param, home_url( '/' ) );

            case 'single':
                $posts = get_posts( [
                    'posts_per_page' => 1,
                    'post_status'    => 'publish',
                    'post_type'      => 'post',
                    'no_found_rows'  => true,
                ] );
                if ( $posts ) return add_query_arg( $param, get_permalink( $posts[0] ) );
                return add_query_arg( $param, home_url( '/' ) );

            default:
                return add_query_arg( $param, home_url( '/' ) );
        }
    }
}
