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

class Falcify_Renderer {
    public static function init() {
        add_filter( 'the_content', array( __CLASS__, 'inject' ), 20 );
        add_action( 'wp_enqueue_scripts', array( __CLASS__, 'assets' ) );
        add_shortcode( 'falcify_content', array( __CLASS__, 'shortcode' ) );
        add_action( 'wp_footer', array( __CLASS__, 'maybe_floating' ) );
    }

    public static function should_render() {
        if ( ! is_singular() ) return false;
        $post = get_queried_object();
        if ( ! $post || empty( $post->ID ) ) return false;
        if ( ! Falcify_Post_Types::is_enabled( $post->post_type ) ) return false;
        $falc   = get_post_meta( $post->ID, '_falcify_content', true );
        $status = get_post_meta( $post->ID, '_falcify_status', true );
        return ! empty( $falc ) && $status === 'published';
    }

    public static function assets() {
        $opts = Falcify_Settings::get();
        if ( $opts['load_assets_only_active'] && ! self::should_render() ) return;
        wp_enqueue_style( 'falcify-public', FALCIFY_URL . 'assets/public.css', array(), FALCIFY_VERSION );
        wp_enqueue_script( 'falcify-public', FALCIFY_URL . 'assets/public.js', array(), FALCIFY_VERSION, true );
        // Personnalisation pictogrammes (taille/couleur/espacement) injectée
        // via variables CSS. Les bornes sûres garantissent l'accessibilité.
        $picto_size    = isset( $opts['picto_size'] ) && is_numeric( $opts['picto_size'] ) ? (float) $opts['picto_size'] : 100.0;
        $picto_spacing = isset( $opts['picto_spacing'] ) && is_numeric( $opts['picto_spacing'] ) ? (float) $opts['picto_spacing'] : 20.0;
        // Migration : valeurs stockées en em (< 10) → conversion px (×50 / ×40).
        if ( $picto_size < 10 ) $picto_size = round( $picto_size * 50 );
        if ( $picto_spacing < 10 ) $picto_spacing = round( $picto_spacing * 40 );
        $picto_size    = (int) max( 40, min( 200, $picto_size ) );
        $picto_spacing = (int) max( 0,  min( 60,  $picto_spacing ) );
        $picto_color   = isset( $opts['picto_color'] ) ? trim( (string) $opts['picto_color'] ) : '';
        if ( $picto_color !== '' && ! sanitize_hex_color( $picto_color ) ) {
            $picto_color = '';
        }
        $color_decl = $picto_color !== '' ? sprintf( '--falc-picto-color:%s;', esc_attr( $picto_color ) ) : '';
        $custom = sprintf(
            ':root{--falcify-color:%s;--falc-picto-size:%dpx;--falc-picto-gap:%dpx;%s}',
            esc_attr( $opts['button_color'] ),
            $picto_size,
            $picto_spacing,
            $color_decl
        );
        wp_add_inline_style( 'falcify-public', $custom );
    }

    protected static function structure_stats( $html ) {
        if ( ! is_string( $html ) || $html === '' ) {
            return array(
                'has_text' => false,
                'blocks'   => 0,
                'headings' => 0,
                'wrappers' => false,
                'images'   => false,
                'flat'     => false,
            );
        }

        return array(
            'has_text' => trim( wp_strip_all_tags( $html ) ) !== '',
            'blocks'   => (int) preg_match_all( '#</(?:p|h2|h3|h4|li)>#i', $html ),
            'headings' => (int) preg_match_all( '#</(?:h2|h3|h4)>#i', $html ),
            'wrappers' => false !== strpos( $html, 'falc-picto' ),
            'images'   => false !== strpos( $html, '<img' ),
            'flat'     => (bool) preg_match( '#<p[^>]*>[\s\S]*<br\s*/?>[\s\S]*</p>#i', $html ),
        );
    }

    /**
     * Récupère le HTML FALC à afficher.
     *
     * @param int    $post_id ID du contenu courant.
     * @param string $context Contexte d'insertion : 'auto' (the_content),
     *                        'shortcode' ([falcify_content]) ou 'floating'
     *                        (bouton flottant). Détermine quel toggle de
     *                        pictogrammes consulter.
     */
    public static function get_render_html( $post_id, $context = 'auto' ) {
        $diag = self::diagnose_pictos( $post_id, $context );
        $raw_pictos = (string) get_post_meta( $post_id, '_falcify_content_pictos', true );
        $raw_base   = (string) get_post_meta( $post_id, '_falcify_content', true );

        // v2.7.22 — on normalise systématiquement les deux metas avant la
        // décision de rendu, afin d'éviter qu'une meta encore « plate » ne soit
        // préférée à une source mieux structurée.
        $base   = self::repair_flattened_blocks( $raw_base );
        $pictos = self::repair_flattened_blocks( $raw_pictos );

        // Les écritures en base (réparation de metas obsolètes) sont réservées
        // au contexte admin pour éviter des DB writes sur chaque pageview front.
        $can_write = is_admin();

        if ( $base !== $raw_base && trim( wp_strip_all_tags( $base ) ) !== '' ) {
            if ( $can_write ) {
                update_post_meta( $post_id, '_falcify_content', self::kses_falc( $base ) );
            }
        }

        $base_stats   = self::structure_stats( $base );
        $pictos_stats = self::structure_stats( $pictos );

        $pictos_is_stale = $diag['shown'] && $pictos_stats['has_text'] && $base_stats['has_text'] && (
            ! $pictos_stats['wrappers']
            || ! $pictos_stats['images']
            || ( $base_stats['blocks'] > 1 && $pictos_stats['blocks'] + 1 < $base_stats['blocks'] )
            || ( $base_stats['headings'] > $pictos_stats['headings'] )
            || ( $pictos_stats['flat'] && ! $base_stats['flat'] )
        );

        if ( $diag['shown'] && $base_stats['has_text'] && ( ! $pictos_stats['has_text'] || $pictos_is_stale ) ) {
            $rebuilt = self::repair_flattened_blocks( self::inject_pictos_fallback( $base ) );
            $clean   = self::kses_falc( $rebuilt );
            if ( $can_write ) {
                update_post_meta( $post_id, '_falcify_content_pictos', $clean );
            }
            return $clean;
        }

        if ( $diag['shown'] && $pictos_stats['has_text'] ) {
            $clean = self::kses_falc( $pictos );
            if ( $can_write && $clean !== $raw_pictos ) {
                update_post_meta( $post_id, '_falcify_content_pictos', $clean );
            }
            return $clean;
        }

        // Fallback : la meta est vide (génération antérieure au SaaS v2.5.7
        // qui ne renvoyait pas `falc_content_pictos`). On injecte les pictos
        // côté PHP à la volée pour éviter une régénération forcée.
        if ( $diag['shown'] && ! $pictos_stats['has_text'] && ! empty( $base ) ) {
            $injected = self::inject_pictos_fallback( $base );
            if ( $injected !== $base ) {
                if ( $can_write ) {
                    update_post_meta( $post_id, '_falcify_content_pictos', self::kses_falc( $injected ) );
                }
                return $injected;
            }
            $diag['shown']  = false;
            $diag['reason'] = 'meta_empty';
            $diag['detail'] = "La meta _falcify_content_pictos est vide et le fallback PHP n'a trouve aucune regle declenchante. Regenerez la version FALC.";
        }

        self::log_picto_diag( $post_id, $context, $diag );
        return self::maybe_prepend_admin_diag( $base, $post_id, $context, $diag );
    }

    /**
     * Décide si l'on affiche la variante pictogrammée selon le contexte
     * d'insertion ET le type de contenu (page / article / autre CPT).
     * Le réglage `show_pictos` (master/legacy) sert de garde global et de
     * fallback pour les types de contenu non couverts par les toggles
     * spécifiques (pages, articles, shortcode).
     */
    public static function should_show_pictos( $post_id, $context = 'auto' ) {
        $diag = self::diagnose_pictos( $post_id, $context );
        return (bool) $diag['shown'];
    }

    /**
     * Diagnostic complet : pour chaque post + contexte, retourne un
     * tableau ['shown' => bool, 'reason' => string, 'detail' => string,
     * 'context' => string, 'post_type' => string]. Permet de tracer
     * précisément pourquoi les pictogrammes s'affichent ou non.
     *
     * Codes `reason` :
     *  - 'ok'                 : pictos affichés.
     *  - 'master_off'         : toggle global `show_pictos` désactivé.
     *  - 'shortcode_off'      : toggle `show_pictos_shortcode` désactivé.
     *  - 'pages_off'          : toggle `show_pictos_pages` désactivé.
     *  - 'posts_off'          : toggle `show_pictos_posts` désactivé.
     *  - 'meta_empty'         : meta `_falcify_content_pictos` absente/vide.
     *  - 'cpt_followed_master': CPT non standard, suit le master (ok).
     */
    public static function diagnose_pictos( $post_id, $context = 'auto' ) {
        // On lit l'option BRUTE pour distinguer "clé absente" (= ON par
        // défaut) de "clé présente à 0" (= choix explicite OFF). Sans cela,
        // wp_parse_args fusionnerait avec les défauts et masquerait toute
        // configuration partielle héritée d'une version antérieure.
        $raw  = get_option( Falcify_Settings::OPTION, array() );
        if ( ! is_array( $raw ) ) $raw = array();
        $opts = wp_parse_args( $raw, Falcify_Settings::defaults() );
        $pt   = get_post_type( $post_id );
        $base = array(
            'shown'     => true,
            'reason'    => 'ok',
            'detail'    => 'Pictogrammes activés pour ce contexte.',
            'context'   => $context,
            'post_type' => (string) $pt,
        );
        // Master : seul un choix EXPLICITE à 0 désactive l'affichage. Une clé
        // absente du tableau stocké est traitée comme ON pour rester
        // permissif lorsque les réglages sont partiels.
        if ( array_key_exists( 'show_pictos', $raw ) && empty( $raw['show_pictos'] ) ) {
            return array_merge( $base, array(
                'shown'  => false,
                'reason' => 'master_off',
                'detail' => 'Le toggle global « Afficher les pictogrammes » est désactivé. Aucun sous-toggle ne peut le contourner.',
            ) );
        }
        if ( $context === 'shortcode' ) {
            $on = ! array_key_exists( 'show_pictos_shortcode', $raw ) || ! empty( $raw['show_pictos_shortcode'] );
            if ( ! $on ) return array_merge( $base, array(
                'shown'  => false,
                'reason' => 'shortcode_off',
                'detail' => 'Le toggle « Afficher les pictogrammes dans les blocs [falcify_content] » est désactivé.',
            ) );
            return $base;
        }
        if ( $pt === 'page' ) {
            $on = ! array_key_exists( 'show_pictos_pages', $raw ) || ! empty( $raw['show_pictos_pages'] );
            if ( ! $on ) return array_merge( $base, array(
                'shown'  => false,
                'reason' => 'pages_off',
                'detail' => 'Le toggle « Afficher les pictogrammes dans les pages » est désactivé.',
            ) );
            return $base;
        }
        if ( $pt === 'post' ) {
            $on = ! array_key_exists( 'show_pictos_posts', $raw ) || ! empty( $raw['show_pictos_posts'] );
            if ( ! $on ) return array_merge( $base, array(
                'shown'  => false,
                'reason' => 'posts_off',
                'detail' => 'Le toggle « Afficher les pictogrammes dans les articles » est désactivé.',
            ) );
            return $base;
        }
        return array_merge( $base, array( 'reason' => 'cpt_followed_master' ) );
    }

    /**
     * Log via error_log() lorsque WP_DEBUG est actif (ou que le filtre
     * `falcify_force_picto_log` retourne true). Préfixe toutes les lignes
     * pour faciliter le grep côté hébergeur.
     */
    protected static function log_picto_diag( $post_id, $context, $diag ) {
        $force = (bool) apply_filters( 'falcify_force_picto_log', false, $post_id, $context, $diag );
        if ( ! $force && ! ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) return;
        // On ne logge pas les cas où tout va bien pour éviter le bruit,
        // sauf si explicitement forcé.
        if ( ! $force && $diag['shown'] ) return;
        // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- gated by WP_DEBUG above
        error_log( sprintf(
            '[falcify][picto] post_id=%d post_type=%s context=%s reason=%s shown=%s detail=%s',
            (int) $post_id,
            $diag['post_type'],
            $diag['context'],
            $diag['reason'],
            $diag['shown'] ? 'yes' : 'no',
            $diag['detail']
        ) );
    }

    /**
     * Insère un commentaire HTML de diagnostic visible dans le source HTML
     * uniquement pour les utilisateurs ayant `manage_options` (admins) OU
     * lorsque l'URL contient `?falcify_debug=1`. Ce commentaire est invisible
     * pour les visiteurs et n'altère pas l'affichage.
     */
    protected static function maybe_prepend_admin_diag( $html, $post_id, $context, $diag ) {
        $can_see = function_exists( 'current_user_can' ) && current_user_can( 'manage_options' );
        if ( ! $can_see ) return $html;
        if ( $diag['shown'] ) return $html;
        $comment = sprintf(
            "\n<!-- Falcify diagnostic pictogrammes :\n  post_id   = %d\n  post_type = %s\n  context   = %s\n  reason    = %s\n  detail    = %s\n  Astuce : visitez la page avec ?falcify_debug=1 pour voir ce diagnostic même si vous n'êtes pas connecté en admin.\n-->\n",
            (int) $post_id,
            esc_html( $diag['post_type'] ),
            esc_html( $diag['context'] ),
            esc_html( $diag['reason'] ),
            esc_html( $diag['detail'] )
        );
        return $comment . (string) $html;
    }

    /**
     * Étend wp_kses_post pour préserver les attributs d'accessibilité
     * (`role`, `aria-label`, `data-picto`, `tabindex`) sur les
     * <span class="falc-picto">. Sans cela, wp_kses_post supprime
     * `data-*` et `role` par défaut.
     */
    public static function kses_falc( $html ) {
        $allowed = wp_kses_allowed_html( 'post' );
        if ( ! isset( $allowed['span'] ) || ! is_array( $allowed['span'] ) ) {
            $allowed['span'] = array();
        }
        if ( ! isset( $allowed['img'] ) || ! is_array( $allowed['img'] ) ) {
            $allowed['img'] = array();
        }
        $allowed['span']['class']       = true;
        $allowed['span']['role']        = true;
        $allowed['span']['tabindex']    = true;
        $allowed['span']['data-picto']  = true;
        $allowed['span']['aria-label']  = true;
        $allowed['span']['aria-hidden'] = true; // rétrocompatibilité ancien contenu
        $allowed['span']['title']       = true; // tooltip accessible des pictos
        $allowed['img']['class']        = true;
        $allowed['img']['src']          = true;
        $allowed['img']['alt']          = true;
        $allowed['img']['width']        = true;
        $allowed['img']['height']       = true;
        $allowed['img']['loading']      = true;
        $allowed['img']['decoding']     = true;
        $allowed['img']['aria-hidden']  = true;
        $allowed['img']['title']        = true;
        return wp_kses( $html, $allowed );
    }

    /**
     * Formate du HTML FALC pour l'affichage public sans casser wpautop :
     * - si la source contient des blocs Gutenberg (`<!-- wp:... -->`), on
     *   exécute `do_blocks()` pour les rendre en HTML, puis on évite wpautop
     *   (le rendu Gutenberg produit déjà des <p>/<h2>/...).
     * - si la source contient déjà des balises de bloc (<p>, <h2>, <h3>,
     *   <ul>, <ol>, <li>, <blockquote>), wpautop est ignoré (sinon il insère
     *   des <br> parasites au milieu des blocs déjà construits, notamment
     *   ceux préfixés par un picto inline).
     * - sinon, on applique wpautop pour les contenus en texte brut.
     * Dans tous les cas, on retire les délimiteurs de blocs résiduels
     * (`<!-- wp:... -->` / `<!-- /wp:... -->`) qui pourraient apparaître si
     * `do_blocks()` n'a pas tout résolu (blocs dynamiques inconnus côté FALC).
     * Dans tous les cas, on passe par kses_falc() pour conserver les
     * <span class="falc-picto" data-picto aria-label aria-hidden> (et donc
     * l'affichage des pictogrammes y compris dans le shortcode).
     */
    public static function format_falc_html( $falc, &$_debug_steps = null ) {
        if ( ! is_string( $falc ) || $falc === '' ) return '';

        // Chemin rapide : contenu déjà structuré en blocs h2/h3/p issus de
        // get_render_html(). On évite de le faire passer par repair_flattened_blocks()
        // (conçu pour le contenu plat) qui peut, via son second fallback, produire
        // un résultat incompatible avec kses et déclencher le fallback wpautop.
        if ( preg_match( '#<(?:h2|h3)\b#i', $falc ) ) {
            $falc = preg_replace( '#<span[^>]*class="[^"]*falc-sr-only[^"]*"[^>]*>.*?</span>#is', '', $falc );
            $html = is_string( $falc ) ? $falc : '';
            if ( false === strpos( (string) $html, 'falc-picto' ) ) {
                $html = self::inject_pictos_fallback( (string) $html );
            }
            $rendered = self::kses_falc( (string) $html );
            return trim( wp_strip_all_tags( $rendered ) ) !== '' ? $rendered : (string) $html;
        }

        $falc = self::strip_block_delimiters( $falc, /*pre_render*/ true );
        $falc = self::repair_flattened_blocks( $falc );
        // v2.7.13 — supprime les <span class="falc-sr-only">label</span>
        // hérités d'anciennes versions : leur texte apparaissait à l'écran
        // lorsque la feuille de style publique n'était pas chargée.
        $falc = preg_replace( '#<span[^>]*class="[^"]*falc-sr-only[^"]*"[^>]*>.*?</span>#is', '', $falc );
        // v2.7.17 — ne jamais supprimer un wrapper `falc-picto` valide sous le
        // seul prétexte qu'il ne contient pas encore d'<img>. Les sorties SaaS
        // plus anciennes utilisaient un span emoji-only, et les supprimer ici
        // efface le seul signal permettant de conserver / réparer les pictos.
        // 1) Rendu Gutenberg si nécessaire (do_blocks gère <!-- wp:... -->).
        $has_gutenberg = ( false !== strpos( $falc, '<!-- wp:' ) );
        if ( $has_gutenberg && function_exists( 'do_blocks' ) ) {
            $falc = do_blocks( $falc );
        }
        // 2) Nettoyage post-render : délimiteurs résiduels (blocs dynamiques
        //    inconnus côté serveur, blocs réservés non enregistrés, etc.).
        $falc = self::strip_block_delimiters( $falc, /*pre_render*/ false );
        // 3) Suppression des paragraphes vides laissés par la disparition
        //    d'un bloc dynamique (`<p></p>`, `<p>&nbsp;</p>`, etc.).
        $falc = preg_replace( '#<p[^>]*>(?:\s|&nbsp;|&#160;|&#xA0;)*</p>#iu', '', (string) $falc );
        // 4) Compactage des lignes vides multiples laissées par do_blocks.
        $falc = preg_replace( "/(\r?\n){3,}/", "\n\n", (string) $falc );
        // 5) wpautop uniquement pour du texte brut (évite les <br> parasites).
        $has_block = (bool) preg_match( '#</(?:p|h[1-6]|ul|ol|li|blockquote|div|figure|table)>#i', $falc );
        $html = $has_block ? $falc : wpautop( $falc );
        // 6) Si aucun pictogramme n'est présent, on tente d'en injecter via le
        //    fallback PHP. Sans cela, un contenu généré côté SaaS sans pictos
        //    resterait définitivement « nu ». L'injection est idempotente : si
        //    un <span class="falc-picto"> existe déjà dans un paragraphe, le
        //    fallback ne l'écrase pas.
        if ( false === strpos( $html, 'falc-picto' ) ) {
            $html = self::inject_pictos_fallback( $html );
        }
        $rendered = self::kses_falc( $html );
        // Filet de sécurité (v2.5.8 + v2.6.1) : si toute la chaîne de rendu finit
        // par produire une chaîne vide alors que la source `$falc` contenait du
        // texte, on retombe successivement sur :
        //   1) le HTML pré-kses (déjà passé par wpautop + injection pictos),
        //      en y appliquant un kses_falc « relâché » qui n'altère pas les
        //      <span class="falc-picto">. Cela évite la perte des pictogrammes
        //      observée quand wp_kses_post strippait l'attribut `data-picto`.
        //   2) le texte brut paragraphé. Mieux vaut afficher du texte non
        //      formaté que rien du tout au clic sur « Lire en FALC ».
        $stripped = trim( wp_strip_all_tags( (string) $rendered ) );
        if ( $stripped === '' ) {
            $html_text = trim( wp_strip_all_tags( (string) $html ) );
            if ( $html_text !== '' ) {
                // Re-kses sans wp_kses_post : on autorise explicitement les
                // tags utiles + les <span class="falc-picto"> avec attributs.
                $minimal = array(
                    'p'      => array( 'class' => true ),
                    'br'     => array(),
                    'strong' => array(), 'em' => array(),
                    'ul' => array(), 'ol' => array(), 'li' => array(),
                    'h2' => array( 'class' => true ), 'h3' => array( 'class' => true ),
                    'a'  => array( 'href' => true, 'title' => true, 'rel' => true, 'target' => true ),
                    'img' => array(
                        'class' => true, 'src' => true, 'alt' => true,
                        'width' => true, 'height' => true, 'loading' => true,
                        'decoding' => true, 'aria-hidden' => true, 'title' => true,
                    ),
                    'span' => array(
                        'class' => true, 'role' => true, 'tabindex' => true,
                        'data-picto' => true, 'aria-label' => true, 'aria-hidden' => true,
                    ),
                );
                $relaxed = wp_kses( (string) $html, $minimal );
                if ( trim( wp_strip_all_tags( $relaxed ) ) !== '' ) {
                    return $relaxed;
                }
            }
            $raw_text = trim( wp_strip_all_tags( (string) $falc ) );
            if ( $raw_text !== '' ) {
                return self::kses_falc( wpautop( esc_html( $raw_text ) ) );
            }
        }
        return $rendered;
    }

    /**
     * Retire les délimiteurs de blocs Gutenberg (`<!-- wp:... -->` et
     * `<!-- /wp:... -->`), y compris les variantes problématiques :
     *  - blocs auto-fermants : `<!-- wp:my-block /-->`
     *  - délimiteurs avec attributs JSON contenant `>` :
     *    `<!-- wp:foo {"key":">"} -->`
     *  - délimiteurs sur plusieurs lignes (rares mais possibles si l'éditeur
     *    formate les attributs JSON sur plusieurs lignes)
     *  - balises avec espaces multiples / casse mixte
     * `$pre_render` = true → on est avant `do_blocks()`, on retire uniquement
     * les délimiteurs de blocs **inconnus** (préfixes non enregistrés) pour
     * laisser do_blocks() rendre les blocs valides. À false, on retire tout
     * ce qui reste (délimiteurs résiduels après rendu).
     */
    public static function strip_block_delimiters( $html, $pre_render = false ) {
        if ( ! is_string( $html ) || $html === '' ) return '';
        // Regex robuste :
        //  - `\s*\/?` : ouverture optionnelle `/` pour les fermetures
        //  - `wp:[\w\-\/]+` : namespace + nom du bloc
        //  - `(?:\s+\{(?:[^{}]|\{[^{}]*\})*\})?` : attributs JSON optionnels
        //    (gère un niveau d'imbrication d'objets — suffit pour Gutenberg)
        //  - `\s*\/?\s*-->` : fermeture éventuellement auto-fermante
        //  - flag `s` (dotall) pour absorber les sauts de ligne dans le JSON
        $pattern = '#<!--\s*\/?wp:[\w\-\/]+(?:\s+\{(?:[^{}]|\{[^{}]*\})*\})?\s*\/?\s*-->#s';
        if ( $pre_render ) {
            // Avant do_blocks(): ne retirer QUE les blocs dont le nom n'est pas
            // enregistré (sinon do_blocks() ne pourra pas les rendre).
            if ( ! function_exists( 'WP_Block_Type_Registry' ) && ! class_exists( 'WP_Block_Type_Registry' ) ) {
                return $html;
            }
            return preg_replace_callback( $pattern, function ( $m ) {
                if ( ! preg_match( '#wp:([\w\-\/]+)#', $m[0], $name ) ) return '';
                $block_name = $name[1];
                // Normalise : si pas de namespace, c'est un bloc core/.
                $full = ( false === strpos( $block_name, '/' ) ) ? 'core/' . $block_name : $block_name;
                $registry = \WP_Block_Type_Registry::get_instance();
                if ( $registry && $registry->is_registered( $full ) ) {
                    return $m[0]; // bloc valide : on laisse do_blocks() faire.
                }
                return ''; // bloc inconnu : on supprime le délimiteur.
            }, $html );
        }
        // Après do_blocks(): on supprime tout ce qui reste.
        $cleaned = preg_replace( $pattern, '', $html );
        // Filet de sécurité : si une regex tordue laisse passer un fragment
        // `<!-- wp:` orphelin (ex. JSON malformé), on l'efface jusqu'au
        // prochain `-->` pour éviter l'affichage en clair.
        $cleaned = preg_replace( '#<!--\s*\/?wp:[^>]*?(?:-->|$)#s', '', (string) $cleaned );
        return (string) $cleaned;
    }

    /**
     * Répare les anciennes versions FALC stockées sous la forme d'un unique
     * <p> contenant une succession de lignes séparées par <br>. Gère aussi les
     * anciennes metas encapsulées dans <body>/<div>/<section>/<article>/<main>,
     * puis recompose des blocs <h2>/<h3>/<p> distincts afin d'éviter le rendu
     * « gros pavé » et de permettre l'injection de pictogrammes par bloc.
     */
    public static function repair_flattened_blocks( $html ) {
        if ( ! is_string( $html ) || $html === '' ) return $html;

        $source = trim( $html );
        while ( preg_match( '#^\s*<(?:body|div|section|article|main)\b[^>]*>([\s\S]*?)</(?:body|div|section|article|main)>\s*$#i', $source, $wrapper ) ) {
            $unwrapped = trim( (string) $wrapper[1] );
            if ( $unwrapped === '' || $unwrapped === $source ) break;
            $source = $unwrapped;
        }

        if ( false === stripos( $source, '<br' ) && false === strpos( $source, "\n" ) ) {
            return $source !== $html ? $source : $html;
        }
        $heading_count = 0;
        $inline_allowed = array(
            'strong' => array(),
            'span'   => array(
                'class'       => true,
                'role'        => true,
                'tabindex'    => true,
                'data-picto'  => true,
                'aria-label'  => true,
                'aria-hidden' => true,
                'title'       => true,
            ),
            'img'    => array(
                'class'       => true,
                'src'         => true,
                'alt'         => true,
                'width'       => true,
                'height'      => true,
                'loading'     => true,
                'decoding'    => true,
                'aria-hidden' => true,
                'title'       => true,
            ),
        );
        $rebuilt_source = preg_replace_callback(
            '#<p([^>]*)>([\s\S]*?)</p>#i',
            function( $match ) use ( &$heading_count, $inline_allowed ) {
                $inner = (string) $match[2];
                if ( false === stripos( $inner, '<br' ) && false === strpos( $inner, "\n" ) ) {
                    return $match[0];
                }
                if ( preg_match( '#</(?:h[1-6]|ul|ol|li|blockquote|div|figure|table)>#i', $inner ) ) {
                    return $match[0];
                }

                $parts = preg_split( '#(?:<br\s*/?>\s*|\n\s*)+#i', $inner );
                if ( ! is_array( $parts ) || count( $parts ) < 2 ) {
                    return $match[0];
                }

                $rebuilt = array();
                foreach ( $parts as $part ) {
                    $segment_html = trim( preg_replace( '~^(?:\s|&nbsp;|&#160;|&#xA0;)+|(?:\s|&nbsp;|&#160;|&#xA0;)+$~u', '', (string) $part ) );
                    $segment_text = trim( wp_strip_all_tags( $segment_html ) );
                    if ( $segment_text === '' ) continue;
                    $words = preg_split( '/\s+/u', $segment_text, -1, PREG_SPLIT_NO_EMPTY );
                    $word_count = is_array( $words ) ? count( $words ) : 0;
                    $looks_like_heading = $word_count > 0 && $word_count <= 12 && (
                        preg_match( '/\?\s*$/u', $segment_text )
                        || ! preg_match( '/[.!:;]\s*$/u', $segment_text )
                    );
                    $safe_inner = wp_kses( $segment_html, $inline_allowed );
                    if ( $looks_like_heading ) {
                        $rebuilt[] = 0 === $heading_count
                            ? '<h2>' . $safe_inner . '</h2>'
                            : '<h3>' . $safe_inner . '</h3>';
                        $heading_count++;
                    } else {
                        $rebuilt[] = '<p>' . $safe_inner . '</p>';
                    }
                }

                return count( $rebuilt ) >= 2 ? implode( "\n", $rebuilt ) : $match[0];
            },
            $source
        );

        $rebuilt_source = is_string( $rebuilt_source ) ? $rebuilt_source : $source;

        if ( $rebuilt_source !== $source ) {
            return $rebuilt_source;
        }

        // Filet de sécurité : certaines anciennes metas ne sont pas stockées
        // dans un unique <p>…</p> mais comme un fragment HTML/texte « brut »
        // séparé par des <br>. On reconstruit alors tout le fragment bloc par
        // bloc, en conservant les pictogrammes déjà présents.
        $parts = preg_split( '#(?:<br\s*/?>\s*|\n\s*)+#i', $source );
        if ( ! is_array( $parts ) || count( $parts ) < 2 ) {
            return $source !== $html ? $source : $html;
        }

        $rebuilt = array();
        foreach ( $parts as $part ) {
            $segment_html = trim( preg_replace( '~^(?:\s|&nbsp;|&#160;|&#xA0;)+|(?:\s|&nbsp;|&#160;|&#xA0;)+$~u', '', (string) $part ) );
            $segment_text = trim( wp_strip_all_tags( $segment_html ) );
            if ( $segment_text === '' ) continue;
            if ( preg_match( '#^</?(?:p|h[1-6]|ul|ol|li|blockquote|div|figure|table)\b#i', $segment_html ) ) {
                $rebuilt[] = $segment_html;
                continue;
            }
            $words = preg_split( '/\s+/u', $segment_text, -1, PREG_SPLIT_NO_EMPTY );
            $word_count = is_array( $words ) ? count( $words ) : 0;
            $looks_like_heading = $word_count > 0 && $word_count <= 12 && (
                preg_match( '/\?\s*$/u', $segment_text )
                || ! preg_match( '/[.!:;]\s*$/u', $segment_text )
            );
            $safe_inner = wp_kses( $segment_html, $inline_allowed );
            if ( $looks_like_heading ) {
                $rebuilt[] = 0 === $heading_count
                    ? '<h2>' . $safe_inner . '</h2>'
                    : '<h3>' . $safe_inner . '</h3>';
                $heading_count++;
            } else {
                $rebuilt[] = '<p>' . $safe_inner . '</p>';
            }
        }

        return count( $rebuilt ) >= 2 ? implode( "\n", $rebuilt ) : ( $source !== $html ? $source : $html );
    }


    public static function inject( $content ) {
        if ( is_admin() || ! in_the_loop() || ! is_main_query() ) return $content;
        if ( ! self::should_render() ) return $content;
        $opts = Falcify_Settings::get();
        if ( ! $opts['auto_inject'] ) return $content;
        if ( $opts['button_position'] === 'floating' ) return $content;
        $post_id = get_the_ID();
        $falc    = self::get_render_html( $post_id, 'auto' );
        // v2.6.5 : ne jamais injecter de wrapper vide si get_render_html ne renvoie rien
        // de visible (sinon on obtient `<div class="falcify-falc-content"></div>` cote front).
        if ( ! is_string( $falc ) || trim( wp_strip_all_tags( (string) $falc ) ) === '' ) {
            return $content;
        }
        return self::wrapper( $content, $falc, $opts['button_position'], $opts );
    }

    public static function shortcode( $atts ) {
        $post_id = get_the_ID();
        $falc = self::get_render_html( $post_id, 'shortcode' );
        if ( ! $falc ) return '';
        $opts = Falcify_Settings::get();
        return self::wrapper( '', $falc, 'before', $opts, true );
    }

    public static function wrapper( $content, $falc, $pos, $opts, $shortcode = false ) {
        $hc = ! empty( $opts['high_contrast'] ) ? ' falcify-hc' : '';
        $btn_falc = esc_html( $opts['button_text_falc'] );
        $btn_normal = esc_html( $opts['button_text_normal'] );
        $id = 'falcify-' . wp_unique_id();
        $btn = sprintf(
            '<button type="button" class="falcify-toggle-button" aria-expanded="false" aria-controls="%1$s-falc" data-text-falc="%2$s" data-text-normal="%3$s"><span class="falcify-icon" aria-hidden="true">✦</span><span class="falcify-label">%2$s</span></button>',
            esc_attr( $id ), $btn_falc, $btn_normal
        );
        $top = ( $pos === 'before' || $pos === 'both' ) ? $btn : '';
        $bottom = ( $pos === 'after' || $pos === 'both' ) ? $btn : '';
        $original   = $shortcode ? '' : sprintf( '<div class="falcify-original-content" id="%s-orig">%s</div>', esc_attr( $id ), $content );
        $show_debug = isset( $_GET['falcify_debug'] ) && current_user_can( 'manage_options' );
        $format_steps = $show_debug ? array() : null;
        $formatted = self::format_falc_html( $falc, $format_steps );
        // v2.6.5 : filet de derniere chance — si tout le pipeline produit du vide
        // alors que `$falc` contient du texte visible, on retombe sur un rendu
        // brut (texte echappe + paragraphes) plutot que sur un div vide.
        if ( trim( wp_strip_all_tags( (string) $formatted ) ) === '' ) {
            $raw = trim( wp_strip_all_tags( (string) $falc ) );
            if ( $raw !== '' ) {
                $formatted = wpautop( esc_html( $raw ) );
            }
        }
        $falc_html = sprintf( '<div class="falcify-falc-content" id="%s-falc" hidden>%s</div>', esc_attr( $id ), $formatted );
        $debug = '';
        if ( $show_debug ) {
            $pid       = get_the_ID();
            $raw_base  = (string) get_post_meta( $pid, '_falcify_content', true );
            $raw_pic   = (string) get_post_meta( $pid, '_falcify_content_pictos', true );
            $steps_dump = '';
            if ( is_array( $format_steps ) ) {
                foreach ( $format_steps as $s ) {
                    $steps_dump .= "\n  " . implode( ' | ', array_map( function( $v ) { return is_bool( $v ) ? ( $v ? 'TRUE' : 'FALSE' ) : esc_html( (string) $v ); }, $s ) );
                }
            }
            $debug     = sprintf(
                "\n<!-- FALCIFY DEBUG (post %d)\n_falcify_content (first 400):\n%s\n\n_falcify_content_pictos (first 400):\n%s\n\nget_render_html output (first 600):\n%s\n\nformat_falc_html steps:%s\n\nformat_falc_html output (first 600):\n%s\n-->",
                (int) $pid,
                esc_html( substr( $raw_base, 0, 400 ) ),
                esc_html( substr( $raw_pic, 0, 400 ) ),
                esc_html( substr( (string) $falc, 0, 600 ) ),
                $steps_dump,
                esc_html( substr( (string) $formatted, 0, 600 ) )
            );
        }
        return sprintf( '<div class="falcify-wrapper%s" data-falcify="1">%s%s%s%s%s</div>', $hc, $top, $original, $falc_html, $bottom, $debug );
    }

    public static function maybe_floating() {
        if ( ! self::should_render() ) return;
        $opts = Falcify_Settings::get();
        if ( ! $opts['auto_inject'] ) return;
        $needs_float = $opts['button_position'] === 'floating' || ! empty( $opts['floating_button'] );
        if ( ! $needs_float ) return;
        $post_id = get_the_ID();
        $falc = self::get_render_html( $post_id, 'floating' );
        if ( ! $falc ) return;
        $selector = $opts['builder_mode'] && $opts['content_selector'] ? (string) $opts['content_selector'] : '';
        printf(
            '<div class="falcify-floating" data-target-selector="%s"><button type="button" class="falcify-toggle-button falcify-floating-btn" aria-expanded="false" data-text-falc="%s" data-text-normal="%s"><span class="falcify-icon" aria-hidden="true">✦</span><span class="falcify-label">%s</span></button><div class="falcify-falc-content falcify-floating-content" hidden>%s</div></div>',
            esc_attr( $selector ),
            esc_attr( $opts['button_text_falc'] ),
            esc_attr( $opts['button_text_normal'] ),
            esc_html( $opts['button_text_falc'] ),
            self::format_falc_html( $falc )
        );
    }
    /**
     * Injecteur PHP de pictogrammes — utilise le manifeste officiel
     * `assets/pictos/manifest.php` (359 pictos FALC).
     *
     * Pour chaque <p>/<h2>/<h3>, cherche dans le texte (sans accents,
     * minuscules) le mot-clé le plus spécifique présent dans le manifeste,
     * puis insère <img class="falc-picto" src="…" …> en début de bloc.
     *
     * Mémoïsé : le manifeste n'est chargé qu'une fois par requête.
     */
    public static function inject_pictos_fallback( $html ) {
        if ( ! is_string( $html ) || $html === '' ) return $html;
        static $rules = null;
        if ( $rules === null ) {
            $manifest = plugin_dir_path( FALCIFY_FILE ) . 'assets/pictos/manifest.php';
            $rules = file_exists( $manifest ) ? (array) require $manifest : array();
        }
        if ( empty( $rules ) ) return $html;
        $base_url = FALCIFY_URL . 'assets/pictos/';

        $result = preg_replace_callback(
            '#<(p|h2|h3)([^>]*)>(.*?)</\1>#is',
            function( $m ) use ( $rules, $base_url ) {
                $tag = $m[1]; $attrs = $m[2]; $inner = $m[3];
                if ( preg_match( '/class\s*=\s*"[^"]*falc-picto/i', $inner ) ) return $m[0];
                $plain = wp_strip_all_tags( $inner );
                if ( $plain === '' ) return $m[0];
                // Normalise : minuscules + suppression des accents.
                $needle = function_exists( 'remove_accents' )
                    ? remove_accents( mb_strtolower( $plain, 'UTF-8' ) )
                    : strtolower( $plain );

                $best = null; $best_score = 0;
                foreach ( $rules as $r ) {
                    if ( empty( $r['kw'] ) ) continue;
                    $score = 0;
                    foreach ( $r['kw'] as $kw ) {
                        if ( $kw === '' ) continue;
                        // Mot-frontière avec tolérance aux pluriels français (+s/+x).
                        // \b en fin de keyword serait trop strict pour "familles",
                        // "établissements", etc. — le ?:s|x)? capture les formes régulières.
                        if ( preg_match( '/\b' . preg_quote( $kw, '/' ) . '(?:s|x)?\b/u', $needle ) ) {
                            $score += strlen( $kw );
                        }
                    }
                    // bonus : on récompense les règles dont *tous* les
                    // mots-clés matchent (= plus spécifique).
                    if ( $score > 0 && $score >= array_sum( array_map( 'strlen', $r['kw'] ) ) ) {
                        $score += 10;
                    }
                    if ( $score > $best_score ) {
                        $best_score = $score;
                        $best = $r;
                    }
                }
                if ( ! $best ) return $m[0];

                $src = esc_url( $base_url . $best['file'] );
                $label = $best['label'];
                // v2.7.13 — on retire le span sr-only (le label texte
                // s'affichait à côté du picto quand la CSS .falc-sr-only
                // n'était pas appliquée). aria-label sur le wrapper suffit
                // aux lecteurs d'écran.
                $picto = sprintf(
                    '<span class="falc-picto" role="img" aria-label="%s" title="%s" data-picto="%s"><img src="%s" alt="%s" width="32" height="32" loading="lazy" /></span> ',
                    esc_attr( $label ),
                    esc_attr( $label ),
                    esc_attr( $best['file'] ),
                    $src,
                    esc_attr( $label )
                );
                return '<' . $tag . $attrs . '>' . $picto . $inner . '</' . $tag . '>';
            },
            $html
        );
        // Si preg_replace_callback échoue (PCRE backtrack limit, etc.), retourner
        // le HTML original plutôt que null pour ne pas casser l'affichage.
        return is_string( $result ) ? $result : $html;
    }

}
