<?php
/**
 * Classe API Avision - Proxy vers FastAPI (engine.avision.org)
 */

namespace Avision;

if (!defined('ABSPATH')) exit;

if (!defined('AVI_API_SPRINT6_VERSION')) {
    define('AVI_API_SPRINT6_VERSION', '6.0');
}
if (!defined('AVI_HELLO_INIT_VERSION')) {
    define('AVI_HELLO_INIT_VERSION', '2.1');
}
if (!defined('AVI_HELLO_INIT_BEHAVIOR')) {
    define('AVI_HELLO_INIT_BEHAVIOR', 'client_native'); // JS gère l'accueil
}
if (!defined('AVI_B2C_HELLO_I18N_VERSION')) {
    define('AVI_B2C_HELLO_I18N_VERSION', '1.0'); // FR+EN+ES
}
// AVI-ANTI-REGRESSION: Accueil B2B garanti (agences)
// Versioning léger pour tracer les overrides côté PHP.
if (!defined('AVI_B2B_HELLO_VERSION')) {
    define('AVI_B2B_HELLO_VERSION', '3.0');
}
if (!defined('AVI_B2B_HELLO_MODEL_DYNAMIC')) {
    define('AVI_B2B_HELLO_MODEL_DYNAMIC', 'avi_b2b_briefing_v1');
}
if (!defined('AVI_B2B_HELLO_MODEL_STATIC')) {
    define('AVI_B2B_HELLO_MODEL_STATIC', 'hello_init_agency');
}
if (!defined('AVI_B2B_HELLO_TESTED')) {
    define('AVI_B2B_HELLO_TESTED', '2026-04-25');
}
/*
 * AVI-ANTI-REGRESSION v3.0 — 2026-04-25
 * =======================================
 * MESSAGE ACCUEIL B2B — COMPORTEMENT GARANTI :
 *
 * SOURCE 1 (prioritaire) : FastAPI génère un briefing dynamique
 *   → model = "avi_b2b_briefing_v1"
 *   → reply = briefing avec fleet_kpis (fiches critiques, score, guardian)
 *   → NE PAS écraser ce reply côté PHP
 *
 * SOURCE 2 (fallback) : FastAPI ne peut pas générer le briefing
 *   → model = "hello_init_client_only"
 *   → PHP écrase reply avec gabarit statique AVI Intelligence Agence
 *   → model devient "hello_init_agency"
 *
 * CE QUI EST INTERDIT :
 *   × "Fleet Assistant" dans le reply B2B
 *   × "flotte de clients, posts en masse, rapports d'automatisation"
 *   × Appeler hello_init_vip_welcome_cockpit_html() pour B2B
 *   × Afficher le score /100 pour B2B (pas de niveau)
 *
 * CE QUI EST OBLIGATOIRE :
 *   ✓ Ton analytique (pas coach Maps)
 *   ✓ Données flotte si fleet_kpis disponibles
 *   ✓ Fiches critiques en rouge si > 0
 *   ✓ Score moyen flotte
 *   ✓ Guardian Mode count
 *
 * POUR MODIFIER : toujours tester sur agency-dashboard avant commit.
 * FICHIERS LIÉS :
 *   - avision-backend/avi/orchestrator.py → _nlg_b2b_fleet_briefing_reply()
 *   - premium-dashboard-v4.js → AVI_DASHBOARD_SPRINT6.guardrails
 *   - wor1515_options → avi_prompt_b2b_welcome (fallback statique)
 */

class Avision_API {

    public function init() {
        // Compatibilité avec les appels existants (ex. class-ajax-router)
    }

    /**
     * En-têtes multi-tenant pour les requêtes vers le backend Python : X-Profile-UUID (pid) et X-Agency-UUID (aid).
     * SÉCURITÉ : n'envoie JAMAIS l'ID numérique au backend, uniquement l'UUID (format 32 ou 36 caractères).
     * Permet au moteur de vérifier que l'agence a le droit d'accéder aux données du client (prévention IDOR).
     *
     * @param \WP_REST_Request|array|null $request Requête REST (get_param) ou tableau de params (profile_uuid, agency_uuid / pid, aid).
     * @param bool $hello_init_fast_path Si true (HELLO_INIT) : ne pas appeler avi_get_active_profile / avi_get_or_create_profile_uuid (évite BDD / latence).
     * @return array Headers à merger avec Content-Type, Host, etc.
     */
    private static function get_backend_uuid_headers($request = null, $hello_init_fast_path = false) {
        $profile_uuid = '';
        $agency_uuid  = '';
        if ($request instanceof \WP_REST_Request) {
            $profile_uuid = $request->get_param('pid') ?: $request->get_param('profile_uuid');
            $agency_uuid  = $request->get_param('aid') ?: $request->get_param('agency_uuid');
            if (is_array($profile_uuid)) $profile_uuid = '';
            if (is_array($agency_uuid)) $agency_uuid = '';
            $profile_uuid = is_string($profile_uuid) ? trim($profile_uuid) : '';
            $agency_uuid  = is_string($agency_uuid) ? trim($agency_uuid) : '';
        } elseif (is_array($request)) {
            $profile_uuid = isset($request['profile_uuid']) ? trim((string) $request['profile_uuid']) : (isset($request['pid']) ? trim((string) $request['pid']) : '');
            $agency_uuid  = isset($request['agency_uuid']) ? trim((string) $request['agency_uuid']) : (isset($request['aid']) ? trim((string) $request['aid']) : '');
        }
        // Ne jamais envoyer d'ID numérique dans les headers : uniquement des UUID (32 hex ou 36 avec tirets).
        if ($profile_uuid !== '' && (ctype_digit((string) $profile_uuid) || ! self::is_valid_uuid($profile_uuid))) {
            $profile_uuid = '';
        }
        if ($agency_uuid !== '' && (ctype_digit((string) $agency_uuid) || ! self::is_valid_uuid($agency_uuid))) {
            $agency_uuid = '';
        }
        if (!$hello_init_fast_path && $profile_uuid === '' && function_exists('avi_get_active_profile') && function_exists('avi_get_or_create_profile_uuid')) {
            $pid = avi_get_active_profile();
            if ($pid) $profile_uuid = avi_get_or_create_profile_uuid($pid);
        }
        if (!$hello_init_fast_path && $agency_uuid === '' && function_exists('avi_get_agency_uuid_for_user')) {
            $agency_uuid = avi_get_agency_uuid_for_user();
        }
        $h = array('Host' => 'engine.avision.org');
        if (self::is_valid_uuid($profile_uuid)) $h['X-Profile-UUID'] = $profile_uuid;
        if (self::is_valid_uuid($agency_uuid)) $h['X-Agency-UUID'] = $agency_uuid;
        if (function_exists('is_user_logged_in') && is_user_logged_in() && function_exists('get_current_user_id')) {
            $h['X-WP-User-ID'] = (string) (int) get_current_user_id();
        }
        $py_key = (defined('AVISION_INTERNAL_API_KEY') && is_string(AVISION_INTERNAL_API_KEY)) ? AVISION_INTERNAL_API_KEY : '';
        if ($py_key === '' && function_exists('getenv')) {
            $e = getenv('AVISION_INTERNAL_API_KEY');
            $py_key = is_string($e) ? $e : '';
        }
        if ($py_key !== '') {
            $h['Authorization'] = 'Bearer ' . $py_key;
        }
        return $h;
    }

    /**
     * Vérifie qu'une chaîne est un UUID valide (36 car. avec tirets ou 32 car. hex).
     * Utilisé pour n'envoyer que des UUID au backend, jamais d'ID numériques.
     *
     * @param string $value Valeur à tester.
     * @return bool
     */
    private static function is_valid_uuid($value) {
        if (!is_string($value) || $value === '') return false;
        $v = trim($value);
        return (bool) (preg_match('/^[a-f0-9\-]{36}$/i', $v) || (strlen($v) === 32 && preg_match('/^[a-f0-9]+$/i', $v)));
    }

    /**
     * Log diagnostic WordPress → FastAPI /chat (mute / 502).
     *
     * @param string $chat_url URL complète (ex. http://127.0.0.1:8001/chat).
     * @param string $json_body Corps JSON déjà encodé.
     */
    private static function log_fastapi_chat_outbound($chat_url, $json_body) {
        if (!function_exists('error_log')) {
            return;
        }
        $raw = is_string($json_body) ? $json_body : '';
        $len = strlen($raw);
        $max = 8000;
        $snippet = ($len > $max) ? substr($raw, 0, $max) . '…[tronqué, total=' . $len . ' octets]' : $raw;
        error_log('[AVI-FASTAPI-CHAT-OUT] url=' . $chat_url . ' body_bytes=' . $len . ' body_json=' . $snippet);
    }

    /**
     * Log réponse FastAPI → WordPress (corps vide, HTTP non-2xx, erreur transport).
     *
     * @param string      $chat_url  URL appelée.
     * @param string      $phase     primary|retry.
     * @param int|string  $http_code Code HTTP ou 0 si inconnu.
     * @param string      $body      Corps brut.
     * @param string      $wp_error  Message WP_Error transport, ou chaîne vide.
     */
    private static function log_fastapi_chat_inbound($chat_url, $phase, $http_code, $body, $wp_error = '') {
        if (!function_exists('error_log')) {
            return;
        }
        if ($wp_error !== '') {
            error_log('[AVI-FASTAPI-CHAT-IN][' . $phase . '] transport_wp_error url=' . $chat_url . ' detail=' . $wp_error . ' — Cause fréquente: backend arrêté, port 8001 fermé, ou hôte PHP incapable de joindre 127.0.0.1 (conteneur / réseau).');
            return;
        }
        $blen = is_string($body) ? strlen($body) : 0;
        $excerpt = is_string($body) ? substr(trim($body), 0, 600) : '';
        error_log('[AVI-FASTAPI-CHAT-IN][' . $phase . '] http=' . (string) $http_code . ' body_bytes=' . $blen . ' excerpt=' . $excerpt);
        if ($blen === 0) {
            if (is_numeric($http_code) && (int) $http_code >= 200 && (int) $http_code < 300) {
                error_log('[AVI-FASTAPI-CHAT-IN][' . $phase . '] Raison (logs WP): HTTP succès mais corps vide — vérifier que FastAPI /chat renvoie bien du JSON (reply) et qu’aucun proxy ne tronque la réponse.');
            } else {
                error_log('[AVI-FASTAPI-CHAT-IN][' . $phase . '] Raison (logs WP): corps vide avec HTTP ' . (string) $http_code . ' — erreur application ou gateway devant le backend.');
            }
        }
        if (!is_numeric($http_code) || (int) $http_code < 200 || (int) $http_code >= 300) {
            error_log('[AVI-FASTAPI-CHAT-IN][' . $phase . '] Réponse HTTP anormale — le dashboard peut afficher AVI muet ou une erreur JSON côté client.');
        }
    }

    /**
     * Bloc trilingue (FR / EN / ES) pour une option KC fallback de base (sans suffixe _en/_es).
     *
     * @param string $base_option ex. avi_kc_fallback_greetings
     * @return array{fr:string,en:string,es:string}
     */
    private static function kc_fallback_locale_triple($base_option) {
        $fr = get_option($base_option, '');
        $en = get_option($base_option . '_en', '');
        $es = get_option($base_option . '_es', '');
        $fr_s = is_string($fr) ? $fr : '';
        $en_s = is_string($en) ? $en : '';
        $es_s = is_string($es) ? $es : '';
        return array(
            'fr' => $fr_s,
            'en' => $en_s,
            'es' => $es_s,
        );
    }

    /**
     * Même logique que {@see kc_fallback_locale_triple()} — nom générique pour tout champ KC base + _en/_es.
     *
     * @param string $base_option ex. avi_kc_gbp_foundations
     * @return array{fr:string,en:string,es:string}
     */
    private static function kc_option_locale_triple($base_option) {
        return self::kc_fallback_locale_triple($base_option);
    }

    /**
     * Réponse LLM / FastAPI : ne pas appliquer wp_kses ni strip_tags (balises &lt;span class="avi-…"&gt;, markdown, etc.).
     * Normalisation UTF-8 uniquement pour éviter une réponse JSON invalide.
     *
     * @param mixed $value
     * @return mixed
     */
    private static function passthrough_llm_string($value) {
        if (! is_string($value)) {
            return $value;
        }
        return wp_check_invalid_utf8($value, true);
    }

    /**
     * @param array $data Charge utile décodée depuis FastAPI (reply, meta_level_title, …).
     * @return array
     */
    private static function passthrough_fastapi_chat_response_array(array $data) {
        foreach (array('reply', 'meta_level_title') as $key) {
            if (isset($data[ $key ]) && is_string($data[ $key ])) {
                $data[ $key ] = self::passthrough_llm_string($data[ $key ]);
            }
        }
        /**
         * Filtre : ne pas y appliquer wp_kses sur reply (préserver le HTML / markdown côté client).
         */
        return apply_filters('avision_fastapi_chat_response_data', $data);
    }

    /**
     * Normalise la réponse FastAPI — garantit que tous les champs attendus existent.
     * Appeler immédiatement après json_decode($body, true).
     *
     * @param array|null $data Tableau décodé depuis FastAPI.
     * @return array Tableau normalisé avec valeurs par défaut safe.
     */
    private static function normalize_fastapi_response( $data ) {
        if ( ! is_array( $data ) ) {
            return array(
                'reply'             => '',
                'model'             => 'unknown',
                'meta_level_title'  => '',
                'rag_used'          => null,
                'orchestrator_path' => null,
                'rag_chunks'        => null,
            );
        }
        return array_merge(
            array(
                'reply'             => '',
                'model'             => 'unknown',
                'meta_level_title'  => '',
                'rag_used'          => null,
                'orchestrator_path' => null,
                'rag_chunks'        => null,
            ),
            $data
        );
    }

    /**
     * HELLO_INIT : si le moteur renvoie un accueil « relique » (texte court sans HTML cockpit), vider reply
     * pour que le client garde l’accueil instantané (stub data-avi-instant-stub).
     *
     * @param array<string, mixed> $data
     * @return array<string, mixed>
     */
    private static function hello_init_strip_legacy_plain_reply(array $data) {
        if (!is_array($data) || empty($data['reply']) || !is_string($data['reply'])) {
            return $data;
        }
        $r = trim($data['reply']);
        if ($r === '') {
            return $data;
        }
        if (preg_match('/class\s*=\s*["\'][^"\']*avi-text-(orange|blue)/i', $r)) {
            return $data;
        }
        if (strlen($r) > 400) {
            return $data;
        }
        if (preg_match('/^Salut\s+.+,?\s+ici ton Coach AVI/i', $r)) {
            $data['reply'] = '';
            return $data;
        }
        if (stripos($r, 'Connecting to AVI') !== false || stripos($r, 'Connexion à AVI') !== false) {
            $data['reply'] = '';
            return $data;
        }
        return $data;
    }

    /**
     * Détecte HELLO_INIT (seul ou après « Question utilisateur : » dans le message chat).
     *
     * @param string $message Message brut (idéalement avec retours ligne conservés).
     * @return bool
     */
    private static function message_is_hello_init($message) {
        if (!is_string($message) || trim($message) === '') {
            return false;
        }
        $t = trim($message);
        if ($t === 'HELLO_INIT') {
            return true;
        }
        if ((bool) preg_match('/Question\s+utilisateur\s*:\s*HELLO_INIT\b/u', $message)) {
            return true;
        }
        // premium-dashboard-v4.js : « …\n\nUser question: HELLO_INIT » (libellé anglais).
        return (bool) preg_match('/User\s+question\s*:\s*HELLO_INIT\b/ui', $message);
    }

    /**
     * AVI-ANTI-REGRESSION v2.1 — Sprint 6
     * COMPORTEMENT CRITIQUE : ne PAS injecter de reply PHP pour hello_init_client_only
     * Le JS natif (premium-dashboard-v4.js → aviApplyHelloInitResponse) gère l'accueil.
     * Si reply PHP écrase le JS → le message d'accueil change pendant les requêtes.
     * RÈGLE : model === 'hello_init_client_only' → retourner $data sans modification.
     * TESTÉ : 2026-04-24 — NE PAS MODIFIER sans test complet dashboard.
     *
     * @param array<string, mixed>      $data
     * @param string                    $incoming_message Message brut (HELLO_INIT ou préfixe Dashboard + User question).
     * @param array<string, mixed>|null $ctx_params       Niveau, langue, technical_context, business_name, score (réservé / cohérence d’appel).
     * @return array<string, mixed>
     */
    private static function hello_init_fill_empty_client_delegate_reply($data, $incoming_message = '', $ctx_params = null) {
        if (!is_array($data)) {
            return $data;
        }
        $incoming = is_string($incoming_message) ? $incoming_message : '';
        if (!self::message_is_hello_init($incoming)) {
            return $data;
        }
        // Si le JS gère déjà l’accueil (hello_init_client_only) → ne pas modifier reply / model.
        if (isset($data['model']) && (string) $data['model'] === 'hello_init_client_only') {
            return $data;
        }
        return $data;
    }

    /**
     * Langue pour le JSON /chat → FastAPI : uniquement les champs explicites `language` / `lang` de la requête.
     * Ne pas utiliser get_locale(), WPLANG ni la langue d’administration WordPress.
     *
     * @param array $params Corps JSON ou tableau AJAX (clés language, lang).
     * @return string fr|en|es
     */
    public static function normalize_chat_language_for_fastapi(array $params) {
        /*
         * AVI-LANG-PRIORITY v1.0 — 2026-04-25
         * Ordre priorité langue :
         * 1. POST['language'] (JS envoie toujours la bonne langue)
         * 2. GET['lang'] / GET['language'] (URL ?lang=fr)
         * 3. Cookie 'avision_lang' / 'avi_lang' (sync localStorage)
         * 4. WordPress locale (fallback)
         * JAMAIS inverser cet ordre.
         */
        $sources_post = array(
            (isset($_POST['language']) && is_scalar($_POST['language'])) ? (string) $_POST['language'] : '',
            // Fallback: certains appels REST internes passent language dans $params, pas dans $_POST.
            (isset($params['language']) && $params['language'] !== null) ? (string) $params['language'] : '',
            (isset($params['lang']) && $params['lang'] !== null) ? (string) $params['lang'] : '',
        );
        $sources_get = array(
            (isset($_GET['lang']) && is_scalar($_GET['lang'])) ? (string) $_GET['lang'] : '',
            (isset($_GET['language']) && is_scalar($_GET['language'])) ? (string) $_GET['language'] : '',
        );
        $sources_cookie = array(
            (isset($_COOKIE['avision_lang']) && is_scalar($_COOKIE['avision_lang'])) ? (string) $_COOKIE['avision_lang'] : '',
            (isset($_COOKIE['avi_lang']) && is_scalar($_COOKIE['avi_lang'])) ? (string) $_COOKIE['avi_lang'] : '',
        );
        $sources_locale = array();
        if (function_exists('get_locale')) {
            $sources_locale[] = (string) get_locale();
        }

        $sources = array_merge($sources_post, $sources_get, $sources_cookie, $sources_locale);

        foreach ($sources as $src) {
            $raw = strtolower(trim((string) $src));
            if ($raw === '') {
                continue;
            }
            $parts = preg_split('/[-_]/', $raw, 2);
            $raw = isset($parts[0]) ? strtolower(trim((string) $parts[0])) : $raw;
            $raw = substr($raw, 0, 2);
            if (in_array($raw, array('fr', 'en', 'es'), true)) {
                return $raw;
            }
        }
        return 'fr';
    }

    /**
     * Accueil chat Fleet (HELLO_INIT) selon la langue : KC FR + variantes EN/ES optionnelles, sinon pont translate_reply_from_french.
     *
     * @param string $language fr|en|es
     * @return string Vide si aucun texte FR configuré dans le KC.
     */
    public static function get_b2b_hello_reply_for_language($language) {
        $language = strtolower((string) $language);
        if (!in_array($language, array('fr', 'en', 'es'), true)) {
            $language = 'en';
        }
        $welcome_b2b = trim((string) get_option('avi_prompt_b2b_welcome', ''));
        if ($welcome_b2b === '') {
            return '';
        }
        if ($language === 'en') {
            $welcome_en = trim((string) get_option('avi_prompt_b2b_welcome_en', ''));
            return $welcome_en !== '' ? $welcome_en : self::translate_reply_from_french($welcome_b2b, 'en');
        }
        if ($language === 'es') {
            $welcome_es = trim((string) get_option('avi_prompt_b2b_welcome_es', ''));
            return $welcome_es !== '' ? $welcome_es : self::translate_reply_from_french($welcome_b2b, 'es');
        }
        return $welcome_b2b;
    }

    /**
     * HELLO_INIT + contexte agency-dashboard : détection via snapshot (dashboard_type) ou KPI flotte.
     *
     * @param array<string, mixed> $ctx_params
     */
    private static function hello_init_is_agency_hello_context(array $ctx_params) {
        if (isset($ctx_params['proxy_user_type']) && (string) $ctx_params['proxy_user_type'] === 'b2b') {
            return true;
        }
        if (isset($ctx_params['dashboard_type']) && is_string($ctx_params['dashboard_type'])) {
            $dt = strtolower(trim($ctx_params['dashboard_type']));
            if (in_array($dt, array('b2b', 'agency', 'fleet', 'b2b_agency'), true)) {
                return true;
            }
        }
        if (!empty($ctx_params['fleet_managed_count']) && (int) $ctx_params['fleet_managed_count'] > 0) {
            return true;
        }
        return false;
    }

    /**
     * Message d’accueil HTML agency-dashboard (HELLO_INIT) — ton analytique, sans niveaux ni score /100.
     *
     * @param string    $lang fr|en|es.
     * @param int|null $fleet_n Nombre de fiches actives (managed_listings_count) ; null → formulation sans chiffre.
     * @return string
     */
    private static function hello_init_agency_welcome_html($lang, $fleet_n) {
        $lang = strtolower((string) $lang);
        if (!in_array($lang, array('fr', 'en', 'es'), true)) {
            $lang = 'en';
        }
        if ($lang === 'fr') {
            $fleet_line = ($fleet_n !== null && (int) $fleet_n > 0)
                ? sprintf('<strong>Votre flotte</strong> : %d fiches actives sous surveillance.', (int) $fleet_n)
                : '<strong>Votre flotte</strong> : vos fiches actives sous surveillance.';
            return '<span class="avi-text-orange"><strong>📊 AVI — Intelligence Agence</strong></span><br><br>'
                . 'Bienvenue dans votre console de pilotage.<br>'
                . $fleet_line . '<br>'
                . '→ Consultez le tableau de bord pour voir les alertes prioritaires.<br>'
                . '→ AVI analyse votre flotte et vous guide sur les actions à fort impact.';
        }
        if ($lang === 'es') {
            $fleet_line = ($fleet_n !== null && (int) $fleet_n > 0)
                ? sprintf('<strong>Su flota</strong>: %d fichas activas bajo supervisión.', (int) $fleet_n)
                : '<strong>Su flota</strong>: sus fichas activas bajo supervisión.';
            return '<span class="avi-text-orange"><strong>📊 AVI — Inteligencia Agencia</strong></span><br><br>'
                . 'Bienvenido a su consola de pilotaje.<br>'
                . $fleet_line . '<br>'
                . '→ Revise el panel para ver las alertas prioritarias.<br>'
                . '→ AVI analiza su flota y le orienta hacia las acciones de mayor impacto.';
        }
        $fleet_line = ($fleet_n !== null && (int) $fleet_n > 0)
            ? sprintf('<strong>Your fleet</strong>: %d active profiles under monitoring.', (int) $fleet_n)
            : '<strong>Your fleet</strong>: your active profiles under monitoring.';
        return '<span class="avi-text-orange"><strong>📊 AVI — Agency Intelligence</strong></span><br><br>'
            . 'Welcome to your management console.<br>'
            . $fleet_line . '<br>'
            . '→ Check the dashboard for priority alerts.<br>'
            . '→ AVI analyzes your fleet and guides you on high-impact actions.';
    }

    /**
     * HELLO_INIT agence : impose l’accueil B2B (KC ou gabarit HTML), en écrasant une réponse FastAPI « coach Maps ».
     *
     * @param array<string, mixed>      $data
     * @param string                    $lang fr|en|es.
     * @param array<string, mixed>|null $hello_ctx
     * @return array<string, mixed>
     */
    private static function hello_init_apply_agency_hello_override($data, $lang, $hello_ctx) {
        if (!is_array($data)) {
            return $data;
        }
        // SOURCE 1 (prioritaire) : briefing dynamique FastAPI → ne jamais écraser.
        if (isset($data['model']) && (string) $data['model'] === AVI_B2B_HELLO_MODEL_DYNAMIC) {
            return $data;
        }
        $n = null;
        if (is_array($hello_ctx) && isset($hello_ctx['fleet_managed_count'])) {
            $v = (int) $hello_ctx['fleet_managed_count'];
            if ($v > 0) {
                $n = $v;
            }
        }
        // ANTI-RÉGRESSION #XXX [27 mai 2026] — Ne pas écraser si FastAPI a fourni une réponse B2B dynamique
        // Préserver reply si non vide et substantiel (FastAPI avi_b2b_briefing_v1 ou orchestrateur)
        $existing_reply = isset($data['reply']) ? trim((string) $data['reply']) : '';
        $has_real_reply = $existing_reply !== '' && strlen($existing_reply) > 50;
        if (!$has_real_reply) {
            $data['reply'] = self::hello_init_agency_welcome_html($lang, $n);
            // Si un accueil B2B est configuré dans le KC, il a priorité sur le gabarit.
            $kc = trim(self::get_b2b_hello_reply_for_language($lang));
            if ($kc !== '') {
                $data['reply'] = $kc;
            }
        }
        return $data;
    }

    /**
     * Niveau 1–21 pour le moteur ; B2B sans niveau explicite → null (pas de logique L1–L21).
     *
     * @param array  $params
     * @param string $user_type b2c|b2b
     * @return int|null
     */
    private static function resolve_chat_level_from_params(array $params, $user_type) {
        $user_type = ($user_type === 'b2b') ? 'b2b' : 'b2c';
        $raw = null;
        foreach (array('currentLevel', 'level') as $key) {
            if (!isset($params[$key]) || $params[$key] === '' || $params[$key] === null) {
                continue;
            }
            $v = (int) $params[$key];
            if ($v >= 1 && $v <= 21) {
                $raw = $v;
                break;
            }
        }
        if ($user_type === 'b2b') {
            return $raw;
        }
        return ($raw !== null) ? $raw : 1;
    }

    /**
     * Sprint cartographié (1–3) pour filtrage RAG côté FastAPI / Qdrant.
     *
     * @param int $level Niveau 1–21 (hors plage → 0).
     * @return int 1, 2 ou 3 ; 0 si inconnu.
     */
    private static function resolve_chat_sprint_from_level($level) {
        $level = (int) $level;
        if ($level < 1 || $level > 21) {
            return 0;
        }
        if ($level <= 8) {
            return 1;
        }
        if ($level <= 17) {
            return 2;
        }
        return 3;
    }

    /**
     * Dernier kilomètre : mode RAG — pas d’inline Knowledge Center (gros blocs PHP) dans le JSON.
     * Envoie le contexte session JS seul en system prompt + métadonnées de filtrage (user_type déjà sur le payload, current_level, sprint).
     *
     * @param array       $payload        Réf. payload JSON vers /chat.
     * @param array       $params         Corps JSON d’origine (systemMessage = texte JS session).
     * @param string      $user_type      b2c|b2b.
     * @param int|null    $level_resolved Niveau dashboard 1–21 ou null (ex. B2B sans niveau).
     */
    private static function apply_absolute_final_chat_system_prompt(array &$payload, array $params, $user_type, $level_resolved) {
        // Savoir = Qdrant (FastAPI) : ne jamais coller le prompt session / KC / lexiques sur le JSON envoyé au moteur.
        unset(
            $payload['systemMessage'],
            $payload['system_message'],
            $payload['avi_system_prompt_wordpress_final'],
            $payload['kc_trilingual'],
            $payload['gbp_master_knowledge'],
            $payload['b2b_master_knowledge'],
            $payload['avi_kc_restricted_words'],
            $payload['avi_skip_static_encyclopedia'],
            $payload['avi_academy_from_wordpress'],
            $payload['lexicon'],
            $payload['avi_lexicon'],
            $payload['kc_lexicon'],
            $payload['encyclopedia_text'],
            $payload['avi_encyclopedia'],
            $payload['kc_fallbacks']
        );
        $current_level = ($level_resolved !== null && $level_resolved >= 1 && $level_resolved <= 21) ? (int) $level_resolved : 0;
        // B2B fleet: force current_level=null to prevent B2C Level 1 routing in FastAPI
        $payload['current_level'] = (isset($payload['user_type']) && $payload['user_type'] === 'b2b') ? null : $current_level;
        $payload['sprint']        = $current_level > 0 ? self::resolve_chat_sprint_from_level($current_level) : 0;
        if (isset($payload['user_type'])) {
            $payload['user_type'] = ($payload['user_type'] === 'b2b') ? 'b2b' : 'b2c';
        }
        if (function_exists('error_log')) {
            error_log('[AVI PROMPT SESSION] (wire=allégé) user_type=' . (isset($payload['user_type']) ? (string) $payload['user_type'] : '') . ' current_level=' . (string) $current_level . ' sprint=' . (string) $payload['sprint'] . ' — pas de systemMessage/kc_trilingual/lexicon vers FastAPI');
        }
    }

    /**
     * Marque le canal B2B (flotte) vs B2C (premium) pour le moteur / RAG : tag dans le message, en-tête du system prompt, et contexte JSON.
     *
     * @param array  $payload   Réf. payload /chat (déjà passé par apply_absolute_final_chat_system_prompt).
     * @param string $user_type b2b|b2c (résolu par resolve_proxy_chat_user_type).
     */
    private static function apply_avision_chat_dashboard_context_tag(array &$payload, $user_type) {
        $user_type = ($user_type === 'b2b') ? 'b2b' : 'b2c';
        // Ne plus préfixer le message ni les champs system : le JSON vers FastAPI ne contient que message + user_type + fleet_kpis (build_fastapi_chat_payload_slim).
        if (!isset($payload['context']) || !is_array($payload['context'])) {
            $payload['context'] = array();
        }
        // Score B2B à 0 : note interne contexte (non envoyée si absent du slim).
        if ($user_type === 'b2b' && isset($payload['score']) && (int) $payload['score'] === 0) {
            $lang_ctx = isset($payload['language']) && is_string($payload['language']) ? strtolower(substr(trim($payload['language']), 0, 2)) : 'en';
            if ($lang_ctx === 'en') {
                $payload['context']['avi_fleet_score_policy'] = 'The aggregated dashboard score is 0 or not yet available. Never say "your score is 0" or "your current score is 0". Explain that fleet-wide metrics are still being analyzed or synchronized, and guide the user toward fleet KPIs or the next data refresh.';
            } else {
                $payload['context']['avi_fleet_score_policy'] = 'Le score agrégé affiché est 0 ou pas encore disponible. Ne dis jamais « votre score est de 0 » ni « votre score actuel est de 0 ». Explique que les indicateurs de la flotte sont encore en cours d\'analyse ou de synchronisation, et oriente vers les KPIs flotte ou le prochain rafraîchissement des données.';
            }
        }
    }

    /**
     * Payload minimal pour POST /chat : uniquement message, user_type, fleet_kpis (savoir = Qdrant côté FastAPI).
     * Aucun kc_trilingual, lexicon, system prompt, contexte technique étendu.
     *
     * @param array  $payload  Après apply_absolute_final_chat_system_prompt + apply_avision (+ préfixe [SYSTEM:] sur message).
     * @param string $language Non envoyé dans le JSON wire (conservé pour compat signature / logs PHP).
     * @return array<string, mixed>
     */
    private static function build_fastapi_chat_payload_slim(array $payload, $language) {
        $ctx_src = (isset($payload['context']) && is_array($payload['context'])) ? $payload['context'] : array();
        $fk = (isset($ctx_src['fleet_kpis']) && is_array($ctx_src['fleet_kpis']) && $ctx_src['fleet_kpis'] !== array())
            ? $ctx_src['fleet_kpis']
            : null;
        if ($fk === null && isset($payload['fleet_kpis']) && is_array($payload['fleet_kpis']) && $payload['fleet_kpis'] !== array()) {
            $fk = $payload['fleet_kpis'];
        }
        $lang_slim = is_string($language) ? strtolower(substr(trim($language), 0, 2)) : 'en';
        if (!in_array($lang_slim, array('fr', 'en', 'es'), true)) {
            $lang_slim = 'en';
        }
        $out = array(
            'message'         => isset($payload['message']) && is_string($payload['message']) ? $payload['message'] : '',
            'user_type'       => (isset($payload['user_type']) && $payload['user_type'] === 'b2b') ? 'b2b' : 'b2c',
            'language'        => $lang_slim,
            /** Autorité langue côté FastAPI (main._tenant_context_supreme_language) — indépendant du préfixe [SYSTEM:…] dans message. */
            'tenant_context'  => array('language' => $lang_slim),
        );
        if ($fk !== null) {
            $out['fleet_kpis'] = $fk;
        }
        return $out;
    }

    /**
     * Profil SaaS (ligne BDD) pour enrichissement payload chat — utilisé par build_fastapi_chat_payload_v2.
     *
     * @param int $profile_id ID numérique avi_saas_profiles.id
     * @return array<string, mixed>
     */
    private static function get_saas_profile_by_id($profile_id) {
        $pid = (int) $profile_id;
        if ($pid <= 0 || ! class_exists(__NAMESPACE__ . '\\Avision_Database')) {
            return array();
        }
        try {
            $db = new Avision_Database();
            $row = $db->get_saas_profile_by_id($pid);
            return is_array($row) ? $row : array();
        } catch ( \Throwable $e ) {
            return array();
        }
    }

    /**
     * Payload enrichi POST /chat (v2) : base slim + current_level, contexte métier, premium (BDD si besoin), historique (max 6).
     * Rétrocompatible : les champs absents restent optionnels ; le message conserve le préfixe [SYSTEM:…] comme le slim.
     *
     * @param array  $payload  Après apply_absolute_final_chat_system_prompt + apply_avision_chat_dashboard_context_tag.
     * @param string $language Langue normalisée (même usage que le slim).
     * @return array<string, mixed>
     */
    private static function build_fastapi_chat_payload_v2(array $payload, $language) {
        static $profile_cache = array();
        $out = self::build_fastapi_chat_payload_slim($payload, $language);

        $current_level = null;
        if (isset($payload['current_level']) && $payload['current_level'] !== null && $payload['current_level'] !== '') {
            $current_level = max(1, min(21, (int) $payload['current_level']));
        } elseif (isset($payload['level']) && $payload['level'] !== null && $payload['level'] !== '') {
            $current_level = max(1, min(21, (int) $payload['level']));
        }

        $user_status = null;
        if (isset($payload['user_status']) && is_string($payload['user_status']) && trim($payload['user_status']) !== '') {
            $user_status = sanitize_text_field($payload['user_status']);
        }

        $dashboard_type = null;
        if (isset($payload['dashboard_type']) && is_string($payload['dashboard_type']) && trim($payload['dashboard_type']) !== '') {
            $dashboard_type = sanitize_text_field($payload['dashboard_type']);
        }

        $score = null;
        if (isset($payload['score'])) {
            $score = max(0, min(100, (int) $payload['score']));
        }

        $ctx_src = (isset($payload['context']) && is_array($payload['context'])) ? $payload['context'] : array();
        $primary_cat = null;
        if (isset($ctx_src['google_primary_category']) && is_string($ctx_src['google_primary_category']) && trim($ctx_src['google_primary_category']) !== '') {
            $primary_cat = sanitize_text_field($ctx_src['google_primary_category']);
        } elseif (isset($ctx_src['primary_category']) && is_string($ctx_src['primary_category']) && trim($ctx_src['primary_category']) !== '') {
            $primary_cat = sanitize_text_field($ctx_src['primary_category']);
        }

        $out['context'] = array(
            'business_name'    => (isset($ctx_src['business_name']) && is_string($ctx_src['business_name']) && $ctx_src['business_name'] !== '') ? sanitize_text_field($ctx_src['business_name']) : null,
            'city'             => (isset($ctx_src['city']) && is_string($ctx_src['city']) && $ctx_src['city'] !== '') ? sanitize_text_field($ctx_src['city']) : null,
            'sector'           => (isset($ctx_src['sector']) && is_string($ctx_src['sector']) && $ctx_src['sector'] !== '') ? sanitize_text_field($ctx_src['sector']) : null,
            'primary_category' => $primary_cat,
            'email'            => (isset($ctx_src['email']) && is_string($ctx_src['email']) && $ctx_src['email'] !== '') ? sanitize_email($ctx_src['email']) : null,
        );

        $guardian_from_payload = null;
        $radar_from_payload = null;
        $pilot_rev_from_payload = null;
        $pilot_posts_from_payload = null;
        if (isset($payload['premium']) && is_array($payload['premium'])) {
            $p = $payload['premium'];
            if (array_key_exists('guardian_active', $p)) {
                $guardian_from_payload = ($p['guardian_active'] === true || $p['guardian_active'] === 1 || $p['guardian_active'] === '1' || $p['guardian_active'] === 'true' || $p['guardian_active'] === 'yes');
            }
            if (isset($p['radar_plan']) && is_string($p['radar_plan']) && trim($p['radar_plan']) !== '') {
                $radar_from_payload = sanitize_text_field($p['radar_plan']);
            }
            if (isset($p['pilot_reviews_mode']) && is_string($p['pilot_reviews_mode']) && trim($p['pilot_reviews_mode']) !== '') {
                $pilot_rev_from_payload = sanitize_text_field($p['pilot_reviews_mode']);
            }
            if (isset($p['pilot_posts_mode']) && is_string($p['pilot_posts_mode']) && trim($p['pilot_posts_mode']) !== '') {
                $pilot_posts_from_payload = sanitize_text_field($p['pilot_posts_mode']);
            }
        }

        $profile_id = isset($payload['profile_id']) ? trim((string) $payload['profile_id']) : '';
        $profile = array();
        if ($profile_id !== '' && ctype_digit($profile_id)) {
            if (! isset($profile_cache[$profile_id])) {
                $profile_cache[$profile_id] = self::get_saas_profile_by_id((int) $profile_id);
            }
            $profile = is_array($profile_cache[$profile_id]) ? $profile_cache[$profile_id] : array();
        }

        $guardian_active = false;
        if ($guardian_from_payload !== null) {
            $guardian_active = (bool) $guardian_from_payload;
        } elseif (isset($profile['guardian_active'])) {
            $ga = $profile['guardian_active'];
            $guardian_active = ($ga === true || $ga === 1 || $ga === '1' || $ga === 'true' || $ga === 'yes');
        }

        $radar_plan = 'offert';
        if ($radar_from_payload !== null) {
            $radar_plan = strtolower(trim($radar_from_payload));
        } elseif (isset($profile['radar_plan']) && is_string($profile['radar_plan']) && trim($profile['radar_plan']) !== '') {
            $radar_plan = strtolower(trim(sanitize_text_field($profile['radar_plan'])));
        }
        if ($radar_plan === '') {
            $radar_plan = 'offert';
        }

        $pilot_reviews_mode = 'manual';
        if ($pilot_rev_from_payload !== null) {
            $pilot_reviews_mode = strtolower(trim($pilot_rev_from_payload));
        } elseif (isset($profile['pilot_reviews_mode']) && is_string($profile['pilot_reviews_mode']) && trim($profile['pilot_reviews_mode']) !== '') {
            $pilot_reviews_mode = strtolower(trim(sanitize_text_field($profile['pilot_reviews_mode'])));
        }

        $pilot_posts_mode = 'manual';
        if ($pilot_posts_from_payload !== null) {
            $pilot_posts_mode = strtolower(trim($pilot_posts_from_payload));
        } elseif (isset($profile['pilot_posts_mode']) && is_string($profile['pilot_posts_mode']) && trim($profile['pilot_posts_mode']) !== '') {
            $pilot_posts_mode = strtolower(trim(sanitize_text_field($profile['pilot_posts_mode'])));
        }

        $has_premium_options = (
            $guardian_active
            || ($radar_plan !== '' && $radar_plan !== 'offert')
            || $pilot_reviews_mode === 'auto'
            || $pilot_posts_mode === 'auto'
        );
        // B2B agency always has premium options — suppresses footer upsell
        if (isset($dashboard_type) && in_array(strtolower((string)$dashboard_type), array('b2b','b2b_fleet','agency','fleet','b2b_agency'), true)) {
            $has_premium_options = true;
        }

        $out['premium'] = array(
            'guardian_active'     => $guardian_active,
            'radar_plan'          => $radar_plan,
            'pilot_reviews_mode'  => $pilot_reviews_mode,
            'pilot_posts_mode'    => $pilot_posts_mode,
            'has_premium_options' => $has_premium_options,
        );

        $out['current_level'] = $current_level;
        $out['user_status'] = $user_status;
        $out['dashboard_type'] = $dashboard_type;
        $out['score'] = $score;

        $hist = isset($payload['history']) ? $payload['history'] : array();
        if (! is_array($hist)) {
            $hist = array();
        }
        if (count($hist) > 6) {
            $hist = array_slice($hist, -6);
        }
        $out['history'] = $hist;

        if (isset($payload['review_rating']) && (int) $payload['review_rating'] >= 1 && (int) $payload['review_rating'] <= 5) {
            $out['review_rating'] = (int) $payload['review_rating'];
        }
        if (isset($payload['review_context']) && is_string($payload['review_context']) && $payload['review_context'] !== '') {
            $out['review_context'] = sanitize_text_field($payload['review_context']);
        }

        return $out;
    }

    /**
     * Nettoie l’objet KPI flotte envoyé au moteur (JSON shallow, pas de dump client complet).
     *
     * @param array $fk
     * @return array
     */
    /**
     * Accepte camelCase accidentel depuis le client et le mappe en snake_case attendu par le moteur.
     *
     * @param array $fk
     * @return array
     */
    private static function normalize_fleet_kpis_incoming(array $fk) {
        $aliases = array(
            'managedListingsCount'   => 'managed_listings_count',
            'slotsUsed'              => 'slots_used',
            'slotsQuota'             => 'slots_quota',
            'agencyTier'             => 'agency_tier',
            'visibilityTier'         => 'visibility_tier',
            'criticalCount'          => 'critical_count',
            'opportunitiesCount'     => 'opportunities_count',
            'underProtectionCount'   => 'under_protection_count',
            'unmonitoredCount'       => 'unmonitored_count',
            'googleConnected'        => 'google_connected',
            'snapshotAt'             => 'snapshot_at',
            'critical_listings'      => 'critical_count',
            'warning_listings'       => 'opportunities_count',
            'healthy_listings'       => 'under_protection_count',
            'avg_score'              => 'avg_score',
            'fragile_listings'       => 'fragile_count',
            'improving_listings'     => 'improving_count',
            'solid_listings'         => 'solid_count',
            'dominant_listings'      => 'dominant_count',
        );
        foreach ($aliases as $from => $to) {
            if (!array_key_exists($to, $fk) && array_key_exists($from, $fk)) {
                $fk[$to] = $fk[$from];
            }
        }
        return $fk;
    }

    /**
     * Rehausse les compteurs flotte depuis la BDD si le payload JS est vide ou sous-estimé.
     *
     * @param int   $user_id ID WP agence.
     * @param array $kpis    KPIs déjà sanitizés.
     * @return array
     */
    private static function merge_agency_fleet_kpis_with_db($user_id, array $kpis) {
        $user_id = (int) $user_id;
        $ds = isset($kpis['fleet_kpis_dom_sourced']) ? $kpis['fleet_kpis_dom_sourced'] : null;
        if ($ds === true || $ds === 1 || $ds === '1' || $ds === 'true' || $ds === 'yes') {
            return $kpis;
        }
        if ($user_id <= 0 || !function_exists('avi_chat_merge_agency_fleet_kpis_from_db')) {
            return $kpis;
        }
        return avi_chat_merge_agency_fleet_kpis_from_db($user_id, $kpis);
    }

    private static function sanitize_fleet_kpis_for_chat(array $fk) {
        $fk = self::normalize_fleet_kpis_incoming($fk);
        $out = array();
        $int_keys = array(
            'managed_listings_count',
            'slots_used',
            'slots_quota',
            'critical_count',
            'fragile_count',
            'improving_count',
            'solid_count',
            'dominant_count',
            'opportunities_count',
            'under_protection_count',
            'unmonitored_count',
            'agency_tier',
            'avg_score',
            'avg_visibility_score',
            'fleet_avg_visibility',
            'listings_in_danger',
            'listings_critical',
        );
        foreach ($int_keys as $k) {
            if (!array_key_exists($k, $fk)) {
                continue;
            }
            $out[$k] = max(0, (int) $fk[$k]);
        }
        if (array_key_exists('google_connected', $fk)) {
            $gc = $fk['google_connected'];
            $out['google_connected'] = ($gc === true || $gc === 1 || $gc === '1' || $gc === 'true' || $gc === 'yes');
        }
        if (array_key_exists('fleet_kpis_dom_sourced', $fk)) {
            $ds = $fk['fleet_kpis_dom_sourced'];
            $out['fleet_kpis_dom_sourced'] = ($ds === true || $ds === 1 || $ds === '1' || $ds === 'true' || $ds === 'yes');
        }
        foreach (array('visibility_tier', 'agency_radar_plan', 'snapshot_at', 'fleet_summary_plain') as $sk) {
            if (!empty($fk[$sk]) && is_string($fk[$sk])) {
                $out[$sk] = sanitize_text_field($sk === 'fleet_summary_plain' ? substr($fk[$sk], 0, 600) : $fk[$sk]);
            }
        }
        // Préserver top_priority_profiles (array de profils DB enrichis)
        if (!empty($fk['top_priority_profiles']) && is_array($fk['top_priority_profiles'])) {
            $out['top_priority_profiles'] = $fk['top_priority_profiles'];
        }
        return $out;
    }

    /**
     * AVI-ANTI-REGRESSION : fleet_kpis DOIT être envoyé dans
     * le payload HELLO_INIT B2B pour déclencher le briefing dynamique.
     * Sans fleet_kpis → FastAPI retourne hello_init_client_only
     * → PHP affiche le fallback statique.
     * Sources : $_POST / params context > wp_option cache > calcul DB.
     *
     * @param array<string, mixed> $params Contexte entrant (REST JSON ou AJAX).
     * @param int                 $user_id WP user id (si disponible).
     * @return array<string, mixed>
     */
    private static function get_fleet_kpis_for_briefing(array $params, $user_id = 0) {
        $user_id = (int) $user_id;

        // 1) Depuis le contexte envoyé par le JS (REST/AJAX): context.fleet_kpis
        $ctx = isset($params['context']) ? $params['context'] : array();
        if (is_string($ctx) && $ctx !== '') {
            $decoded = json_decode($ctx, true);
            $ctx = is_array($decoded) ? $decoded : array();
        }
        if (is_array($ctx) && isset($ctx['fleet_kpis']) && is_array($ctx['fleet_kpis']) && $ctx['fleet_kpis'] !== array()) {
            return self::enrich_fleet_kpis_with_profiles(self::sanitize_fleet_kpis_for_chat($ctx['fleet_kpis']), $user_id);
        }

        // Compat: payload direct fleet_kpis
        if (isset($params['fleet_kpis']) && is_array($params['fleet_kpis']) && $params['fleet_kpis'] !== array()) {
            return self::enrich_fleet_kpis_with_profiles(self::sanitize_fleet_kpis_for_chat($params['fleet_kpis']), $user_id);
        }
        if (isset($params['fleet_kpis']) && is_string($params['fleet_kpis']) && $params['fleet_kpis'] !== '') {
            $decoded = json_decode($params['fleet_kpis'], true);
            if (is_array($decoded) && $decoded !== array()) {
                return self::enrich_fleet_kpis_with_profiles(self::sanitize_fleet_kpis_for_chat($decoded), $user_id);
            }
        }

        // 2) Depuis wp_option cache (mis à jour par CRON / futur Sprint 9)
        if ($user_id > 0) {
            $cached = get_option('avi_fleet_kpis_cache_' . $user_id, array());
            if (is_array($cached) && $cached !== array()) {
                return self::enrich_fleet_kpis_with_profiles(self::sanitize_fleet_kpis_for_chat($cached), $user_id);
            }
        }

        // 3) Calcul basique depuis la DB (si agency_id résoluble)
        $agency_id = 0;
        if (function_exists('avi_get_agency_id_for_user')) {
            $agency_id = (int) avi_get_agency_id_for_user($user_id);
        }
        if ($agency_id <= 0 && $user_id > 0 && function_exists('get_user_meta')) {
            $m = get_user_meta($user_id, 'avi_saas_agency_id', true);
            if (is_numeric($m)) {
                $agency_id = (int) $m;
            }
        }
        if ($agency_id <= 0) {
            if (defined('WP_DEBUG') && WP_DEBUG && function_exists('error_log')) {
                error_log('[AVI-DEBUG-FLEET] agency_id: ' . (string) $agency_id);
            }
            // ANTI-RÉGRESSION #209d [28 mai 2026] — fallback agency_id depuis avi_saas_agencies
            // Raison : avi_get_agency_id_for_user() manquante + meta avi_saas_agency_id vide
            // pour certains comptes agence → agency_id=0 → DB non interrogée
            global $wpdb;
            if ( $agency_id <= 0 && $user_id > 0 && isset( $wpdb ) && is_object( $wpdb ) ) {
                $agency_id = (int) $wpdb->get_var(
                    $wpdb->prepare(
                        "SELECT id FROM {$wpdb->prefix}avi_saas_agencies WHERE admin_user_id = %d LIMIT 1",
                        $user_id
                    )
                );
            }
            if ( $agency_id <= 0 ) {
                return array();
            }
        }
        global $wpdb;
        if (!isset($wpdb) || !is_object($wpdb)) {
            return array();
        }
        $table = $wpdb->prefix . 'avi_saas_profiles';
        // ANTI-RÉGRESSION #209h [28 mai 2026] — exclure AGENCY_MASTER des counts
        // Raison : profil pivot id=43 fausse les comptages fleet
        $total = (int) $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*) FROM {$table} WHERE agency_id = %d AND category NOT IN ('AGENCY_MASTER', 'agency')",
                $agency_id
            )
        );
        // ANTI-RÉGRESSION #209 [28 mai 2026] — retourner structure minimale si total=0
        // Raison : fleet_kpis={} → FastAPI return None → KC statique. Structure minimale → fallback dynamique.
        if ( (int) $total <= 0 ) {
            return array(
                'managed_listings_count' => 0,
                'critical_count'         => 0,
                'opportunities_count'    => 0,
                'under_protection_count' => 0,
                'avg_score'              => 0,
                'top_profiles'           => array(),
                'agency_id'              => $agency_id,
            );
        }
        // ANTI-RÉGRESSION #209g [28 mai 2026] — seuils 5 tiers alignés sur JS getScoreTier()
        $critical = (int) $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*) FROM {$table} WHERE agency_id = %d AND score <= 45 AND category NOT IN ('AGENCY_MASTER', 'agency')",
                $agency_id
            )
        );
        $fragile = (int) $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*) FROM {$table} WHERE agency_id = %d AND score >= 46 AND score <= 60 AND category NOT IN ('AGENCY_MASTER', 'agency')",
                $agency_id
            )
        );
        $improving = (int) $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*) FROM {$table} WHERE agency_id = %d AND score >= 61 AND score <= 75 AND category NOT IN ('AGENCY_MASTER', 'agency')",
                $agency_id
            )
        );
        $solid = (int) $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*) FROM {$table} WHERE agency_id = %d AND score >= 76 AND score <= 90 AND category NOT IN ('AGENCY_MASTER', 'agency')",
                $agency_id
            )
        );
        $dominant = (int) $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*) FROM {$table} WHERE agency_id = %d AND score >= 91 AND category NOT IN ('AGENCY_MASTER', 'agency')",
                $agency_id
            )
        );
        $avg = (int) $wpdb->get_var(
            $wpdb->prepare(
                "SELECT AVG(score) FROM {$table} WHERE agency_id = %d AND category NOT IN ('AGENCY_MASTER', 'agency')",
                $agency_id
            )
        );

        // Top 5 fiches prioritaires (TIER 1 en premier) pour contexte AVI
        $top_profiles = array();
        $rows = $wpdb->get_results($wpdb->prepare(
            "SELECT id, business_name, COALESCE(score, 0) as score, guardian_active, radar_plan "
            . "FROM {$table} WHERE agency_id = %d AND category NOT IN ('AGENCY_MASTER', 'agency') ORDER BY COALESCE(score, 0) ASC LIMIT 5",
            $agency_id
        ), ARRAY_A);
        if (is_array($rows)) {
            foreach ($rows as $row) {
                $top_profiles[] = array(
                    'id'             => (int) $row['id'],
                    'business_name'  => sanitize_text_field((string) $row['business_name']),
                    'score'          => (int) $row['score'],
                    'guardian_active'=> (int) $row['guardian_active'],
                    'radar_plan'     => sanitize_text_field((string) $row['radar_plan']),
                );
            }
        }
        $kpis = array(
            'managed_listings_count' => (int) $total,
            'critical_listings'      => (int) $critical,
            'fragile_listings'       => (int) $fragile,
            'improving_listings'     => (int) $improving,
            'solid_listings'         => (int) $solid,
            'dominant_listings'      => (int) $dominant,
            'avg_score'              => (int) $avg,
            'guardian_active_count'  => 0,
        );
        $sanitized = self::sanitize_fleet_kpis_for_chat($kpis);
        if (!empty($top_profiles)) {
            $sanitized['top_priority_profiles'] = $top_profiles;
        }
        return $sanitized;
    }

    /**
     * Enrichit les fleet_kpis sanitisés avec les top_priority_profiles depuis la DB.
     */
    public static function enrich_fleet_kpis_with_profiles(array $sanitized, $user_id = 0) {
        $user_id = (int) $user_id;
        $agency_id = 0;
        if (function_exists('avi_get_agency_id_for_user')) {
            $agency_id = (int) avi_get_agency_id_for_user($user_id);
        }
        if ($agency_id <= 0 && $user_id > 0) {
            $m = get_user_meta($user_id, 'avi_saas_agency_id', true);
            if (is_numeric($m) && (int) $m > 0) $agency_id = (int) $m;
        }
        // ANTI-RÉGRESSION [05 juin 2026] — fallback meta avi_agency_id
        // avi_saas_agency_id absent → lire avi_agency_id (meta réelle en production)
        if ($agency_id <= 0 && $user_id > 0) {
            $m2 = get_user_meta($user_id, 'avi_agency_id', true);
            if (is_numeric($m2) && (int) $m2 > 0) $agency_id = (int) $m2;
        }
        if ($agency_id <= 0) return $sanitized;
        global $wpdb;
        if (!isset($wpdb) || !is_object($wpdb)) return $sanitized;
        $table = $wpdb->prefix . 'avi_saas_profiles';
        $rows = $wpdb->get_results($wpdb->prepare(
            "SELECT id, business_name, COALESCE(score, 0) as score, guardian_active, radar_plan " .
            "FROM {$table} WHERE agency_id = %d AND category NOT IN ('AGENCY_MASTER', 'agency') ORDER BY COALESCE(score, 0) ASC LIMIT 20",
            $agency_id
        ), ARRAY_A);
        if (!is_array($rows) || empty($rows)) return $sanitized;
        $top_profiles = array();
        foreach ($rows as $row) {
            $top_profiles[] = array(
                'id'             => (int) $row['id'],
                'business_name'  => sanitize_text_field((string) $row['business_name']),
                'score'          => (int) $row['score'],
                'guardian_active'=> (int) $row['guardian_active'],
                'radar_plan'     => sanitize_text_field((string) $row['radar_plan']),
            );
        }
        $sanitized['top_priority_profiles'] = $top_profiles;
        return $sanitized;
    }
    public static function enrich_fleet_kpis_with_profiles_public(array $sanitized, $user_id = 0) {
        return self::enrich_fleet_kpis_with_profiles($sanitized, $user_id);
    }


    /**
     * Détermine le type d'utilisateur pour le payload FastAPI : b2b (agence) ou b2c.
     */
    private static function get_user_type_for_payload() {
        $user_id = get_current_user_id();
        if (!$user_id) return 'b2c';
        // GARDE ABSOLUE : avi_user_type=b2c écrase tout
        $explicit_type = get_user_meta($user_id, 'avi_user_type', true);
        if ($explicit_type === 'b2c') return 'b2c';
        // « administrator » est un rôle, pas une capability : user_can(…, 'administrator') est faux pour les vrais admins.
        if (user_can($user_id, 'manage_options')) return 'b2b';
        $ud = get_userdata($user_id);
        if ($ud && !empty($ud->roles) && is_array($ud->roles) && in_array('administrator', $ud->roles, true)) return 'b2b';
        $is_agency = get_user_meta($user_id, 'is_agency', true);
        if ($is_agency === true || $is_agency === '1' || $is_agency === 'true' || $is_agency === 'yes') return 'b2b';
        $is_agency_master = get_user_meta($user_id, 'is_agency_master', true);
        if ($is_agency_master === true || $is_agency_master === '1' || $is_agency_master === 'true' || $is_agency_master === 'yes') return 'b2b';
        $u = get_userdata($user_id);
        if ($u && !empty($u->roles) && is_array($u->roles) && in_array('avi_agency', $u->roles, true)) return 'b2b';
        return 'b2c';
    }

    /**
     * Si le front n’envoie pas profile_id (ex. cache HTML, changement de rôle), repli session / thème.
     *
     * @param array $body Corps déjà assemblé (lecture seule pour profile_id).
     * @return string|null ID numérique du profil ou null.
     */
    private static function resolve_chat_profile_id_from_server(array $body) {
        $existing = isset($body['profile_id']) ? $body['profile_id'] : null;
        if ($existing !== null && $existing !== '' && (string) $existing !== '0') {
            return sanitize_text_field((string) $existing);
        }
        if (!is_user_logged_in()) {
            return null;
        }
        if (function_exists('avi_get_active_profile')) {
            $apid = avi_get_active_profile();
            if ($apid !== null && (int) $apid > 0) {
                return (string) (int) $apid;
            }
        }
        return null;
    }

    /**
     * URLs HTTP utiles pour le contexte chat (Referer / Origin / en-têtes REST).
     *
     * @param \WP_REST_Request|null $request
     * @return array<int, string>
     */
    private static function collect_chat_context_source_urls($request) {
        $out = array();
        if ($request instanceof \WP_REST_Request) {
            $ref = $request->get_header('referer');
            if (is_string($ref) && $ref !== '') {
                $out[] = $ref;
            }
            $org = $request->get_header('origin');
            if (is_string($org) && $org !== '') {
                $out[] = $org;
            }
        }
        if (!empty($_SERVER['HTTP_REFERER']) && is_string($_SERVER['HTTP_REFERER'])) {
            $out[] = (string) $_SERVER['HTTP_REFERER'];
        }
        if (!empty($_SERVER['HTTP_ORIGIN']) && is_string($_SERVER['HTTP_ORIGIN'])) {
            $out[] = (string) $_SERVER['HTTP_ORIGIN'];
        }
        return array_values(array_unique(array_filter($out)));
    }

    /**
     * @param array<int, string> $urls
     */
    private static function urls_indicate_agency_dashboard(array $urls) {
        foreach ($urls as $u) {
            if (!is_string($u) || $u === '') {
                continue;
            }
            if (stripos($u, 'agency-dashboard') !== false) {
                return true;
            }
        }
        return false;
    }

    /**
     * Page Premium (parcours client) : verrou B2C pour le moteur (évite « Fleet » / admin agence).
     *
     * @param array<int, string> $urls
     */
    private static function urls_indicate_premium_dashboard(array $urls) {
        foreach ($urls as $u) {
            if (!is_string($u) || $u === '') {
                continue;
            }
            if (stripos($u, 'premium-dashboard') !== false) {
                return true;
            }
        }
        return false;
    }

    /**
     * Nom de table métier profils SaaS (préfixe WP ou constante complète).
     */
    private static function get_saas_profiles_table_name_for_sql() {
        global $wpdb;
        $table = (defined('AVISION_SAAS_PROFILES_TABLE') && is_string(AVISION_SAAS_PROFILES_TABLE) && AVISION_SAAS_PROFILES_TABLE !== '')
            ? AVISION_SAAS_PROFILES_TABLE
            : ($wpdb->prefix . 'avi_saas_profiles');
        return preg_match('/^[a-zA-Z0-9_]+$/', (string) $table) ? (string) $table : '';
    }

    /**
     * Profil SaaS « client simple » (B2C) : lecture colonne user_type si présente.
     *
     * @param int $profile_id ID avi_saas_profiles.id
     * @return bool|null true = forcer b2c, false = profil typé agence/B2B, null = inconnu
     */
    private static function saas_profile_indicates_b2c_client($profile_id) {
        global $wpdb;
        $pid = (int) $profile_id;
        if ($pid <= 0) {
            return null;
        }
        $table = self::get_saas_profiles_table_name_for_sql();
        if ($table === '') {
            return null;
        }
        $ut = $wpdb->get_var($wpdb->prepare("SELECT user_type FROM `{$table}` WHERE id = %d LIMIT 1", $pid));
        if ($wpdb->last_error !== '') {
            return null;
        }
        if ($ut === null) {
            return null;
        }
        $ut = strtolower(trim((string) $ut));
        if ($ut === '' || $ut === 'b2c' || $ut === 'client' || $ut === 'premium') {
            return true;
        }
        if ($ut === 'b2b' || $ut === 'agency' || $ut === 'fleet') {
            return false;
        }
        return null;
    }

    /**
     * ID profil numérique pour décision B2C/B2B (body JSON puis ?pid= sur Referer).
     *
     * @param array<string, mixed> $params
     * @param \WP_REST_Request|null $request
     * @return int|null
     */
    private static function resolve_numeric_profile_id_for_chat_context(array $params, $request = null) {
        if (!empty($params['profile_id']) && is_scalar($params['profile_id'])) {
            $p = trim((string) $params['profile_id']);
            if ($p !== '' && ctype_digit($p)) {
                $n = (int) $p;
                return $n > 0 ? $n : null;
            }
        }
        $raw = self::extract_pid_query_from_context_urls($request);
        if ($raw === '') {
            return null;
        }
        return self::resolve_numeric_profile_id_from_pid_param($raw);
    }

    /**
     * Type utilisateur pour /chat : priorité au JSON dashboard_type=b2b (Fleet Assistant) ;
     * sinon B2B si URL agency-dashboard ; B2C si premium-dashboard, profil SaaS client, ou par défaut.
     *
     * @param array<string, mixed> $params
     * @param \WP_REST_Request|null $request
     * @return string 'b2c'|'b2b'
     */
    private static function resolve_proxy_chat_user_type(array $params, $request = null) {
        // Priorité absolue : signal JSON du dashboard — b2b seul, sans lecture URL / Referer.
        if (isset($params['dashboard_type']) && is_scalar($params['dashboard_type']) && trim((string) $params['dashboard_type']) === 'b2b') {
            return 'b2b';
        }
        if (isset($params['dashboard_type']) && is_string($params['dashboard_type'])) {
            $dt_json = strtolower(trim($params['dashboard_type']));
            if ($dt_json === 'agency' || $dt_json === 'fleet' || $dt_json === 'b2b_agency') {
                return 'b2b';
            }
        }
        $agency_first = self::collect_chat_context_source_urls($request);
        if (!empty($_SERVER['HTTP_REFERER']) && is_string($_SERVER['HTTP_REFERER'])) {
            $agency_first[] = (string) $_SERVER['HTTP_REFERER'];
        }
        if (!empty($_SERVER['REQUEST_URI']) && is_string($_SERVER['REQUEST_URI'])) {
            $agency_first[] = (string) $_SERVER['REQUEST_URI'];
        }
        $agency_first = array_values(array_unique(array_filter($agency_first)));
        if (self::urls_indicate_agency_dashboard($agency_first)) {
            return 'b2b';
        }

        $urls = self::collect_chat_context_source_urls($request);
        if (self::urls_indicate_premium_dashboard($urls)) {
            return 'b2c';
        }
        // Corps JSON (admin-ajax) : si le front annonce explicitement le Premium, forcer b2c avant l’URL agence (Referer parfois absent / LiteSpeed).
        if (isset($params['dashboard_type']) && is_string($params['dashboard_type'])) {
            $dt_early = strtolower(trim($params['dashboard_type']));
            if (in_array($dt_early, array('b2c', 'client', 'premium', 'demo'), true)) {
                return 'b2c';
            }
        }
        $pid_check = self::resolve_numeric_profile_id_for_chat_context($params, $request);
        if ($pid_check !== null && $pid_check > 0) {
            $sig = self::saas_profile_indicates_b2c_client($pid_check);
            if ($sig === true) {
                return 'b2c';
            }
        }
        if (self::urls_indicate_agency_dashboard($urls)) {
            return 'b2b';
        }
        return 'b2c';
    }

    /**
     * Extrait le paramètre de requête pid (UUID ou ID) depuis le Referer / Origin.
     *
     * @param \WP_REST_Request|null $request
     */
    private static function extract_pid_query_from_context_urls($request) {
        foreach (self::collect_chat_context_source_urls($request) as $url) {
            $q = wp_parse_url($url, PHP_URL_QUERY);
            if (!$q || !is_string($q)) {
                continue;
            }
            $parsed = array();
            parse_str($q, $parsed);
            if (!empty($parsed['pid']) && is_scalar($parsed['pid'])) {
                $p = trim((string) $parsed['pid']);
                if ($p !== '') {
                    return $p;
                }
            }
        }
        return '';
    }

    /**
     * @param string $pid_raw
     * @return int|null ID ligne profil SaaS
     */
    private static function resolve_numeric_profile_id_from_pid_param($pid_raw) {
        $pid_raw = trim((string) $pid_raw);
        if ($pid_raw === '') {
            return null;
        }
        if (ctype_digit($pid_raw)) {
            $n = (int) $pid_raw;
            return $n > 0 ? $n : null;
        }
        if (function_exists('avision_get_profile_id_by_uuid') && preg_match('/^[a-f0-9\-]{32,36}$/i', $pid_raw)) {
            $n = (int) avision_get_profile_id_by_uuid(sanitize_text_field($pid_raw));
            return $n > 0 ? $n : null;
        }
        return null;
    }

    /**
     * Priorité au pid présent dans l’URL de la page (changement de fiche) pour éviter un profile_id obsolète ou le repli admin « 43 ».
     *
     * @param array<string, mixed> $params
     * @param \WP_REST_Request|null $request
     * @return array<string, mixed>
     */
    private static function merge_chat_profile_id_from_request_context(array $params, $request = null) {
        $raw = self::extract_pid_query_from_context_urls($request);
        if ($raw === '') {
            return $params;
        }
        $nid = self::resolve_numeric_profile_id_from_pid_param($raw);
        if ($nid !== null && $nid > 0) {
            $params['profile_id'] = (string) $nid;
        }
        return $params;
    }

    /**
     * Proxy chat vers FastAPI (appel REST).
     * Pour l'AJAX (tableau $params), utiliser proxy_chat_to_fastapi_params().
     *
     * Pas de sleep() ni de boucle d’attente bloquante ici : un seul wp_remote_post (éventuellement un second
     * pour le filet L15). Le timeout HTTP est un plafond (secondes max) — la réponse est traitée dès qu’elle
     * arrive ; WordPress n’impose pas d’attendre jusqu’au timeout si le backend répond plus tôt.
     * Filtre WordPress : avision_fastapi_chat_http_timeout (défaut 120, min 20, max 300) — laisse le temps au double-call LLM / OpenRouter.
     */
    public static function proxy_chat_to_fastapi($request) {
        if (function_exists('error_log')) {
            error_log('[AVI-DEBUG-STEP-1] Entrée dans proxy_chat_to_fastapi');
        }
        $params = $request->get_json_params();
        if (empty($params) && $request->get_body()) {
            $params = json_decode($request->get_body(), true) ?: array();
        }
        $params = is_array($params) ? $params : array();
        $params = self::merge_chat_profile_id_from_request_context($params, $request);

        $raw_message_full = isset($params['message']) && is_string($params['message']) ? wp_unslash($params['message']) : '';
        if ($raw_message_full === '' && isset($params['query']) && is_string($params['query'])) {
            $raw_message_full = wp_unslash($params['query']);
        }
        $is_hello_init_proxy = self::message_is_hello_init($raw_message_full);

        $user_id = get_current_user_id();
        if ($user_id <= 0 && !empty($_SERVER['HTTP_X_WP_USER_ID'])) {
            $user_id = (int) $_SERVER['HTTP_X_WP_USER_ID'];
        }
        if ($user_id <= 0 && isset($params['user_id']) && is_numeric($params['user_id'])) {
            $user_id = (int) $params['user_id'];
        }
        $business_name = (isset($params['business_name']) && $params['business_name'] !== '') ? sanitize_text_field($params['business_name']) : (($user_id && get_user_meta($user_id, 'avision_business_name', true)) ? get_user_meta($user_id, 'avision_business_name', true) : get_option('avision_business_name', get_option('avision_company_name', '')));
        $city = ($user_id && get_user_meta($user_id, 'avision_business_city', true)) ? get_user_meta($user_id, 'avision_business_city', true) : get_option('avision_business_city', get_option('avision_city', ''));
        $sector = isset($params['sector']) ? sanitize_text_field($params['sector']) : (($user_id && get_user_meta($user_id, 'avision_sector', true)) ? get_user_meta($user_id, 'avision_sector', true) : get_option('avision_sector', ''));
        $user_email = ($user_id && get_userdata($user_id)) ? get_userdata($user_id)->user_email : '';
        // Personnalité moteur : B2C par défaut ; B2B uniquement si l’URL (Referer/Origin) contient agency-dashboard.
        // premium-dashboard ou profil SaaS user_type client → B2C (ignore dashboard_type JSON « b2b » seul).
        // HELLO_INIT : pas de resolve_proxy_chat_user_type (évite SaaS DB / Referer) — redirection immédiate vers FastAPI.
        if ($is_hello_init_proxy) {
            $user_type = 'b2c';
            if (isset($params['dashboard_type']) && is_scalar($params['dashboard_type'])) {
                $dt_hello = strtolower(trim((string) $params['dashboard_type']));
                if ($dt_hello === 'b2b' || $dt_hello === 'agency' || $dt_hello === 'fleet' || $dt_hello === 'b2b_agency') {
                    $user_type = 'b2b';
                }
            }
        } else {
            $user_type = self::resolve_proxy_chat_user_type($params, $request);
        }

        $language = self::normalize_chat_language_for_fastapi(is_array($params) ? $params : array());

        // HELLO_INIT : toujours proxifier vers FastAPI (réponse HTML instantanée, sans court-circuit KC B2B ici).

        $level = self::resolve_chat_level_from_params($params, $user_type);
        $score = isset($params['score']) ? (int) $params['score'] : 0;
        // Agence : le score B2C du formulaire / localStorage est hors-sujet — évite score chirurgical erroné côté Python.
        if ($user_type === 'b2b') {
            $score = 0;
        }

        $user_status = isset($params['user_status']) && $params['user_status'] !== '' ? sanitize_text_field($params['user_status']) : null;
        $chat_history = isset($params['chatHistory']) ? $params['chatHistory'] : array();
        if (is_string($chat_history)) {
            $chat_history = json_decode($chat_history, true);
            if (!is_array($chat_history)) {
                $chat_history = array();
            }
        }
        $context_extra = array(
            'business_name' => $business_name !== '' ? $business_name : ($language === 'fr' ? 'votre établissement' : ($language === 'es' ? 'su establecimiento' : 'your business')),
            'city'          => $city !== '' ? $city : ($language === 'fr' ? 'ta ville' : ($language === 'es' ? 'tu ciudad' : 'your city')),
            'sector'        => $sector !== '' ? $sector : 'SERVICE',
            'email'         => $user_email !== '' ? $user_email : '',
        );
        // ANTI-RÉGRESSION #209i [28 mai 2026] — agency_name dans payload B2B
        // Raison : orchestrator.py a besoin du nom agence (pas business_name fiche)
        if ( $user_type === 'b2b' ) {
            global $wpdb;
            $current_user_id = (int) $user_id;
            if ( isset( $params['user_id'] ) && is_scalar( $params['user_id'] ) && (int) $params['user_id'] > 0 ) {
                $current_user_id = (int) $params['user_id'];
            }
            // ANTI-RÉGRESSION #209i-fix [28 mai 2026] — avi_get_agency_name() = source officielle
            // Raison : avi_saas_agencies.name != nom affiché dashboard (avi_get_agency_name retourne display_name/login)
            $agency_name_row = function_exists( 'avi_get_agency_name' )
                ? avi_get_agency_name( $current_user_id )
                : '';
            if ( $agency_name_row ) {
                $context_extra['agency_name'] = sanitize_text_field( $agency_name_row );
            }
        }
        $fleet_kpis_raw = isset($params['fleet_kpis']) ? $params['fleet_kpis'] : null;
        if (is_string($fleet_kpis_raw) && $fleet_kpis_raw !== '') {
            $decoded_fk = json_decode($fleet_kpis_raw, true);
            $fleet_kpis_raw = is_array($decoded_fk) ? $decoded_fk : null;
        }
        if (is_array($fleet_kpis_raw) && $fleet_kpis_raw !== array()) {
            $context_extra['fleet_kpis'] = self::sanitize_fleet_kpis_for_chat($fleet_kpis_raw);
        }
        // HELLO_INIT B2B : enrichir fleet_kpis pour déclencher le briefing dynamique côté FastAPI.
        if ( $is_hello_init_proxy && $user_type === 'b2b' ) {
            // ANTI-RÉGRESSION #209c [28 mai 2026] — HELLO_INIT B2B : DB prioritaire si plus complète
            // Raison : profils démo JS (199-203) n'existent pas en DB → prendre le plus grand total
            // get_fleet_kpis_for_briefing(array(), $user_id) → skip JS/cache → force DB (source 3)
            // NE JAMAIS passer un int comme 1er arg — signature : (array $params, $user_id)
            $current_user_id = (int) $user_id;
            // Utiliser user_id du payload si disponible (plus fiable que get_current_user_id en contexte REST)
            $briefing_user_id = isset( $params['user_id'] ) && is_scalar( $params['user_id'] ) && (int) $params['user_id'] > 0 ? (int) $params['user_id'] : $current_user_id;
            $db_fleet         = self::get_fleet_kpis_for_briefing( array(), $briefing_user_id );
            $js_total        = ( is_array( $fleet_kpis_raw ) && isset( $fleet_kpis_raw['managed_listings_count'] ) ) ? (int) $fleet_kpis_raw['managed_listings_count'] : 0;
            $db_total        = ( isset( $db_fleet['managed_listings_count'] ) ) ? (int) $db_fleet['managed_listings_count'] : 0;
            if ( ! empty( $db_fleet ) && $db_total >= $js_total ) {
                $context_extra['fleet_kpis'] = $db_fleet;
                $fleet_kpis_raw             = $db_fleet;
                $fleet_kpis_override         = $db_fleet;
            } elseif ( ! empty( $fleet_kpis_raw ) ) {
                $context_extra['fleet_kpis'] = $fleet_kpis_raw;
                $fleet_kpis_override         = $fleet_kpis_raw;
            }
        }
        if ($user_type === 'b2b' && $user_id > 0 && !self::message_is_hello_init($raw_message_full)) {
            $fk_base = (isset($context_extra['fleet_kpis']) && is_array($context_extra['fleet_kpis'])) ? $context_extra['fleet_kpis'] : array();
            $fk_merged = self::sanitize_fleet_kpis_for_chat(
                self::merge_agency_fleet_kpis_with_db($user_id, $fk_base)
            );
            // Enrichir avec top_priority_profiles depuis DB
            $context_extra['fleet_kpis'] = self::enrich_fleet_kpis_with_profiles($fk_merged, $user_id);
        }
        if (isset($params['profile_id']) && $params['profile_id'] !== '' && $params['profile_id'] !== null) {
            $context_extra['profile_id'] = sanitize_text_field($params['profile_id']);
        }
        if (isset($params['user_id']) && $params['user_id'] !== '' && $params['user_id'] !== null) {
            $context_extra['user_id'] = sanitize_text_field($params['user_id']);
        }
        $msg_sanitized = isset($params['message']) ? sanitize_textarea_field(wp_unslash($params['message'])) : (isset($params['query']) ? sanitize_textarea_field(wp_unslash($params['query'])) : '');
        $payload = array(
            'message'     => $msg_sanitized,
            'score'       => $score,
            'user_type'   => $user_type,
            'user_status' => $user_status,
            'profile_id'  => isset($params['profile_id']) && $params['profile_id'] !== '' && $params['profile_id'] !== null ? sanitize_text_field($params['profile_id']) : null,
            'user_id'     => isset($params['user_id']) && $params['user_id'] !== '' && $params['user_id'] !== null ? sanitize_text_field($params['user_id']) : null,
            'language'    => $language,
            'context'     => array_merge(
                $context_extra,
                array_filter(array(
                    'lang'                  => $language,
                    'l21_validation_count'  => isset($params['l21_validation_count']) && $params['l21_validation_count'] !== null ? max(0, (int) $params['l21_validation_count']) : null,
                    'l21_validated_names'  => isset($params['l21_validated_names']) && is_array($params['l21_validated_names']) ? array_map('sanitize_text_field', $params['l21_validated_names']) : null,
                ), function ($v) { return $v !== null; })
            ),
            'history'     => $chat_history,
        );
        if ( isset( $context_extra['agency_name'] ) && is_string( $context_extra['agency_name'] ) && $context_extra['agency_name'] !== '' ) {
            $payload['agency_name'] = $context_extra['agency_name'];
        }
        if ( isset( $fleet_kpis_override ) && is_array( $fleet_kpis_override ) && ! empty( $fleet_kpis_override ) ) {
            $payload['fleet_kpis'] = $fleet_kpis_override;
        }
        if ($level !== null) {
            $payload['level'] = $level;
        }
        if (isset($params['dashboard_type']) && is_string($params['dashboard_type']) && $params['dashboard_type'] !== '') {
            $payload['dashboard_type'] = sanitize_text_field($params['dashboard_type']);
        }
        if (isset($params['review_rating']) && $params['review_rating'] >= 1 && $params['review_rating'] <= 5) {
            $payload['review_rating'] = (int) $params['review_rating'];
        }
        if (isset($params['review_context']) && is_string($params['review_context']) && $params['review_context'] !== '') {
            $payload['review_context'] = sanitize_text_field($params['review_context']);
        }

        // Dernier kilomètre : contexte session JS + métadonnées RAG (current_level, sprint) — plus de KC inline dans le JSON.
        self::apply_absolute_final_chat_system_prompt($payload, $params, $user_type, $level);
        self::apply_avision_chat_dashboard_context_tag($payload, $user_type);

        // Administrateur WP : admin_force_ai_chat côté moteur ; bypass PHP sur HELLO_INIT (maybe_admin_override_sync_welcome_reply).
        if (is_user_logged_in() && current_user_can('manage_options')) {
            if (!isset($payload['context']) || !is_array($payload['context'])) {
                $payload['context'] = array();
            }
            $payload['context']['admin_force_ai_chat'] = true;
            $payload['admin_force_ai_chat'] = true;
            $pid_cur = isset($payload['profile_id']) ? trim((string) $payload['profile_id']) : '';
            if ($pid_cur === '' || $pid_cur === '0') {
                $payload['profile_id'] = '43';
                $payload['context']['profile_id'] = '43';
            }
            if (function_exists('error_log')) {
                $hello_tag = self::message_is_hello_init($raw_message_full) ? ' HELLO_INIT' : '';
                error_log('[AVI-PHP] admin_force_ai_chat → profile_id=' . (isset($payload['profile_id']) ? (string) $payload['profile_id'] : '') . $hello_tag);
            }
        }

        // URL canonique locale : http://127.0.0.1:8001/chat — surcharger via wp-config : define( 'AVISION_BACKEND_INTERNAL_URL', 'http://127.0.0.1:8001' );
        // Route Guard : class-avision-dashboard-route-guard retourne immédiatement si REST_REQUEST — /wp-json/avision/v1/* n’est pas redirigé vers /login.
        $base_internal = defined('AVISION_BACKEND_INTERNAL_URL') ? rtrim((string) AVISION_BACKEND_INTERNAL_URL, '/') : 'http://127.0.0.1:8001';
        $chat_url = $base_internal . '/chat';
        $chat_url = apply_filters('avision_fastapi_chat_url', $chat_url);

        error_log('[AVI-LANG-DEBUG] Payload → engine: language=' . ($payload['language'] ?? '(absent)') . ', context.lang=' . ($payload['context']['lang'] ?? '(absent)') . ', url=' . $chat_url);
        if (isset($payload['review_rating'])) {
            error_log('[AVI-L15-DEBUG] review_rating=' . $payload['review_rating'] . ', review_context=' . ($payload['review_context'] ?? '(absent)') . ' → envoi à ' . $chat_url);
        }

        // Contrainte langue : préfixe en tête du message (savoir = Qdrant — corps JSON allégé ci-dessous).
        $language_instruction = ($language === 'en')
            ? '[SYSTEM: ANSWER ONLY IN ENGLISH. MANDATORY.]'
            : '[SYSTEM: RÉPONDS UNIQUEMENT EN FRANÇAIS. OBLIGATOIRE.]';
        $msg_core = isset($payload['message']) && is_string($payload['message']) ? $payload['message'] : '';
        $payload['message'] = $language_instruction . ($msg_core !== '' ? "\n\n" . $msg_core : '');

        // Garantir que $body est disponible pour l'enrichissement top_priority_profiles
        if (!isset($body) || !is_array($body)) {
            $body = isset($params) && is_array($params) ? $params : array();
        }
        if (empty($body) && isset($payload) && is_array($payload)) {
            $body = $payload;
        }

        // Enrichir fleet_kpis avec top_priority_profiles depuis DB — toujours pour B2B
        $wp_uid_enrich = (int) get_current_user_id();
        if ($wp_uid_enrich <= 0 && isset($body['wp_user_id'])) {
            $wp_uid_enrich = (int) $body['wp_user_id'];
        }
        if ($wp_uid_enrich <= 0 && isset($_SERVER['HTTP_X_WP_USER_ID'])) {
            $wp_uid_enrich = (int) $_SERVER['HTTP_X_WP_USER_ID'];
        }
        if ($wp_uid_enrich > 0 && $user_type === 'b2b') {
            $fk_to_enrich = isset($context_extra['fleet_kpis']) && is_array($context_extra['fleet_kpis'])
                ? $context_extra['fleet_kpis']
                : (isset($payload['fleet_kpis']) && is_array($payload['fleet_kpis']) ? $payload['fleet_kpis'] : array());
            $enriched = self::enrich_fleet_kpis_with_profiles($fk_to_enrich, $wp_uid_enrich);
            $context_extra['fleet_kpis'] = $enriched;
            $payload['fleet_kpis'] = $enriched;
        }
        // $payload_slim = self::build_fastapi_chat_payload_slim($payload, $language); // legacy
        $payload_slim = self::build_fastapi_chat_payload_v2($payload, $language);
        $json_body = wp_json_encode($payload_slim);
        if ($json_body === false) {
            $json_body = '{}';
            error_log('[AVI-FASTAPI-CHAT-OUT] wp_json_encode(payload) a échoué — corps vide {} envoyé.');
        }
        self::log_fastapi_chat_outbound($chat_url, $json_body);

        $backend_headers = array_merge(
            array('Content-Type' => 'application/json'),
            self::get_backend_uuid_headers($request, $is_hello_init_proxy),
            array(
                'X-WP-User-ID'  => (string) get_current_user_id(),
                'X-Business-ID' => isset($payload['profile_id']) ? (string) $payload['profile_id'] : '',
            )
        );
        if (function_exists('error_log')) {
            error_log('[AVI-DEBUG-STEP-2] Avant appel FastAPI');
        }
        $chat_http_timeout = (int) apply_filters('avision_fastapi_chat_http_timeout', 120);
        $chat_http_timeout = max(20, min(300, $chat_http_timeout));
        $response = wp_remote_post($chat_url, array(
            // Plafond secondes pour toute la requête ; fin dès réponse serveur (pas d’attente forcée jusqu’au max).
            'timeout'            => $chat_http_timeout,
            'reject_unsafe_urls'  => false,
            'headers'            => $backend_headers,
            'body'               => $json_body,
            'sslverify'          => false,
        ));

        if (is_wp_error($response)) {
            self::log_fastapi_chat_inbound($chat_url, 'primary', 0, '', $response->get_error_message());
            return new \WP_Error('fastapi_error', $response->get_error_message(), array('status' => 500));
        }

        if (!is_array($response)) {
            if (function_exists('error_log')) {
                error_log('[AVI-DEBUG-ERROR] wp_remote_post: valeur inattendue (ni WP_Error ni tableau headers/body)');
            }
            return new \WP_Error('fastapi_error', 'Réponse HTTP invalide', array('status' => 500));
        }

        $code = wp_remote_retrieve_response_code($response);
        $body = wp_remote_retrieve_body($response);
        self::log_fastapi_chat_inbound($chat_url, 'primary', $code, is_string($body) ? $body : '');

        if ($code !== 200) {
            $excerpt = is_string($body) ? substr(trim($body), 0, 300) : '';
            error_log('Avision API: chat HTTP ' . $code . '. Body (300 chars max): ' . $excerpt);
        }

        $data = self::normalize_fastapi_response( json_decode( $body, true ) );

        // Debug temporaire : vérifier le lane HELLO_INIT / B2B.
        if ($is_hello_init_proxy && function_exists('error_log') && defined('WP_DEBUG') && WP_DEBUG) {
            error_log('[AVI-DEBUG] hello_init user_type=' . (isset($user_type) ? (string) $user_type : ''));
        }

        if (json_last_error() !== JSON_ERROR_NONE) {
            error_log('[AVI-FASTAPI-CHAT-IN][primary] json_decode_error=' . json_last_error_msg() . ' http=' . (string) $code . ' — Le backend n’a probablement pas renvoyé du JSON valide (HTML d’erreur, timeout partiel, etc.).');
            if ($code === 200) {
                $excerpt = is_string($body) ? substr(trim($body), 0, 300) : '';
                error_log('Avision API: chat réponse non-JSON (HTTP ' . $code . '). Body: ' . $excerpt);
            }
            return new \WP_Error('json_error', 'Erreur serveur (HTTP ' . $code . ').', array('status' => 500));
        }

        if (is_array($data) && empty($data['reply']) && $code >= 200 && $code < 300) {
            error_log('[AVI-FASTAPI-CHAT-IN][primary] JSON valide mais reply vide — AVI muet côté UI : vérifier la logique main.py /chat (clé reply manquante ou chaîne vide).');
        }

        // AVI-ANTI-REGRESSION: Message accueil B2B garanti
        // Ne JAMAIS afficher "Fleet Assistant" (hello_init_client_only) pour les agences.
        if (
            $is_hello_init_proxy
            && is_array($data)
            && isset($data['model'])
            && (string) $data['model'] === 'hello_init_client_only'
            && (
                (isset($user_type) && (string) $user_type === 'b2b')
                || (isset($payload['dashboard_type']) && (string) $payload['dashboard_type'] === 'b2b')
                || (isset($params['dashboard_type']) && (string) $params['dashboard_type'] === 'b2b')
            )
        ) {
            $fleet_count = 0;
            if (isset($payload['context']) && is_array($payload['context']) && isset($payload['context']['fleet_kpis']) && is_array($payload['context']['fleet_kpis'])) {
                if (isset($payload['context']['fleet_kpis']['managed_listings_count'])) {
                    $fleet_count = max(0, (int) $payload['context']['fleet_kpis']['managed_listings_count']);
                }
            }
            if ($fleet_count === 0 && isset($payload['fleet_kpis']) && is_array($payload['fleet_kpis']) && isset($payload['fleet_kpis']['managed_listings_count'])) {
                $fleet_count = max(0, (int) $payload['fleet_kpis']['managed_listings_count']);
            }
            $hello_ctx_guard = array(
                'dashboard_type'      => 'b2b',
                'fleet_managed_count' => $fleet_count,
                'proxy_user_type'     => 'b2b',
            );
            // Réutilise le KC B2B si présent, sinon gabarit analytique.
            $data = self::hello_init_apply_agency_hello_override($data, $language, $hello_ctx_guard);
            $data['model'] = AVI_B2B_HELLO_MODEL_STATIC;
        }

        // FILET L15 : si review_rating envoyé mais backend renvoie le message générique → retry avec message simplifié (une seule fois ; pas pour admin pour éviter double appel / latence).
        if (
            !(is_user_logged_in() && current_user_can('manage_options'))
            && isset($payload['review_rating']) && $payload['review_rating'] >= 1 && $payload['review_rating'] <= 5
            && is_array($data) && !empty($data['reply'])
            && (
                stripos($data['reply'], 'validación técnica') !== false
                || stripos($data['reply'], 'validation technique') !== false
                || stripos($data['reply'], 'priorité immédiate') !== false
                || stripos($data['reply'], 'prioridad inmediata') !== false
            )
        ) {
            error_log('[AVI-L15-FILET] Backend a renvoyé message générique pour review_rating=' . $payload['review_rating'] . '. Retry avec message simplifié.');
            $retry_payload = $payload;
            $retry_payload['message'] = 'HELP_REQUEST | LevelID: level15 | RATING: ' . (int) $payload['review_rating']
                . ' | CONTEXT: ' . (isset($payload['review_context']) && $payload['review_context'] !== '' ? $payload['review_context'] : 'Réponse standard');
            $retry_payload['level'] = 15;
            self::apply_absolute_final_chat_system_prompt($retry_payload, $params, $user_type, 15);
            self::apply_avision_chat_dashboard_context_tag($retry_payload, $user_type);
            $retry_msg_core = isset($retry_payload['message']) && is_string($retry_payload['message']) ? $retry_payload['message'] : '';
            $retry_payload['message'] = $language_instruction . ($retry_msg_core !== '' ? "\n\n" . $retry_msg_core : '');
            // $retry_slim = self::build_fastapi_chat_payload_slim($retry_payload, $language); // legacy
            $retry_slim = self::build_fastapi_chat_payload_v2($retry_payload, $language);
            if (isset($payload['review_rating'])) {
                $retry_slim['review_rating'] = (int) $payload['review_rating'];
            }
            if (isset($payload['review_context']) && is_string($payload['review_context']) && $payload['review_context'] !== '') {
                $retry_slim['review_context'] = sanitize_text_field($payload['review_context']);
            }
            $retry_slim['level'] = 15;
            $retry_json = wp_json_encode($retry_slim);
            if ($retry_json === false) {
                $retry_json = '{}';
            }
            self::log_fastapi_chat_outbound($chat_url, $retry_json);
            $retry_response = wp_remote_post($chat_url, array(
                'timeout'            => $chat_http_timeout,
                'reject_unsafe_urls'  => false,
                'headers'            => array_merge(array('Content-Type' => 'application/json'), self::get_backend_uuid_headers($request, $is_hello_init_proxy)),
                'body'               => $retry_json,
                'sslverify'          => false,
            ));
            if (is_wp_error($retry_response)) {
                self::log_fastapi_chat_inbound($chat_url, 'retry', 0, '', $retry_response->get_error_message());
            } elseif (!is_array($retry_response)) {
                if (function_exists('error_log')) {
                    error_log('[AVI-DEBUG-ERROR] wp_remote_post (retry L15): réponse inattendue (non-tableau)');
                }
            } else {
                $retry_code = wp_remote_retrieve_response_code($retry_response);
                $retry_body = wp_remote_retrieve_body($retry_response);
                self::log_fastapi_chat_inbound($chat_url, 'retry', $retry_code, is_string($retry_body) ? $retry_body : '');
                $retry_data = json_decode(is_string($retry_body) ? $retry_body : '', true);
                if (json_last_error() === JSON_ERROR_NONE && is_array($retry_data) && !empty($retry_data['reply'])) {
                    $is_still_generic = (
                        stripos($retry_data['reply'], 'validación técnica') !== false
                        || stripos($retry_data['reply'], 'validation technique') !== false
                        || stripos($retry_data['reply'], 'prioridad inmediata') !== false
                    );
                    if (!$is_still_generic) {
                        error_log('[AVI-L15-FILET] Retry réussi → réponse L15 obtenue.');
                        $data = $retry_data;
                    } else {
                        error_log('[AVI-L15-FILET] Retry ÉCHOUÉ → backend ne supporte pas L15. Déployer main.py avec coupe-file L15.');
                    }
                }
            }
        }

        if ($language !== 'fr' && is_array($data) && !empty($data['reply']) && !self::message_is_hello_init($raw_message_full)) {
            $data['reply'] = self::translate_reply_from_french($data['reply'], $language);
        }

        if ($user_type === 'b2b' && self::message_is_hello_init($raw_message_full) && is_array($data)) {
            unset($data['meta_level_title']);
        }

        if (
            $code >= 200 && $code < 300
            && is_array($data) && !empty($data['reply'])
            && class_exists('\Avision\Avision_AI_Interactions')
        ) {
            $log_msg = isset($payload['message']) ? (string) $payload['message'] : '';
            $log_pid = isset($payload['profile_id']) && $payload['profile_id'] !== null && $payload['profile_id'] !== ''
                ? (string) $payload['profile_id'] : null;
            $log_lvl = array_key_exists('level', $payload) && $payload['level'] !== null ? (string) $payload['level'] : null;
            \Avision\Avision_AI_Interactions::log_exchange(
                get_current_user_id(),
                $log_pid,
                $log_msg,
                (string) $data['reply'],
                $log_lvl
            );

            // --- Log avi_chat_logs V2 (Chat Monitor) ---
            global $wpdb;
            $cl_rag_used = isset( $data['rag_used'] ) ? (int) $data['rag_used'] : null;
            $cl_rag_src  = null;
            if ( isset( $data['rag_chunks'] ) ) {
                $je_chunks = wp_json_encode( $data['rag_chunks'], JSON_UNESCAPED_UNICODE );
                $cl_rag_src = ( false !== $je_chunks ) ? substr( $je_chunks, 0, 10000 ) : null;
            } elseif ( isset( $data['rag_sources'] ) ) {
                $je_src = wp_json_encode( $data['rag_sources'], JSON_UNESCAPED_UNICODE );
                $cl_rag_src = ( false !== $je_src ) ? substr( $je_src, 0, 10000 ) : null;
            }
            $cl_orch_path = isset( $data['orchestrator_path'] )
                ? substr( (string) $data['orchestrator_path'], 0, 100 )
                : null;

            $cl_raw_meta = $data;
            unset( $cl_raw_meta['reply'] );
            $je_meta = wp_json_encode( $cl_raw_meta, JSON_UNESCAPED_UNICODE );
            $cl_raw_meta_str = ( false !== $je_meta ) ? substr( $je_meta, 0, 20000 ) : null;

            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
            $wpdb->insert(
                $wpdb->prefix . 'avi_chat_logs',
                array(
                    'user_id'                  => get_current_user_id(),
                    'query'                    => $log_msg,
                    'response'                 => (string) $data['reply'],
                    'level_context'            => $log_lvl,
                    'user_type'                => isset( $payload['user_type'] )
                        ? substr( (string) $payload['user_type'], 0, 10 )
                        : null,
                    'language'                 => isset( $payload['language'] )
                        ? substr( (string) $payload['language'], 0, 5 )
                        : null,
                    'model_used'               => isset( $data['model'] )
                        ? substr( (string) $data['model'], 0, 100 )
                        : null,
                    'profile_id'               => isset( $payload['profile_id'] ) && $payload['profile_id'] !== null && $payload['profile_id'] !== ''
                        ? (int) $payload['profile_id']
                        : null,
                    'rag_used'                 => $cl_rag_used,
                    'rag_sources'              => $cl_rag_src,
                    'orchestrator_path'        => $cl_orch_path,
                    'raw_fastapi_meta'         => $cl_raw_meta_str,
                    'system_prompt_snapshot'   => null,
                ),
                array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s' )
            );
            // --- Fin Log avi_chat_logs V2 ---
        }

        if (is_array($data)) {
            $data_pre_passthrough = $data;
            $data = self::passthrough_fastapi_chat_response_array($data);
            if (!is_array($data)) {
                if (function_exists('error_log')) {
                    error_log('[AVI-DEBUG-ERROR] passthrough_fastapi_chat_response_array / filtre avision_fastapi_chat_response_data a renvoyé un non-tableau — repli sur la charge avant filtre');
                }
                $data = is_array($data_pre_passthrough) ? $data_pre_passthrough : array();
            }
            if (is_array($data) && $is_hello_init_proxy) {
                $data = self::hello_init_strip_legacy_plain_reply($data);
            }
            if (is_array($data)) {
                $ctx0 = isset($payload['context']) && is_array($payload['context']) ? $payload['context'] : array();
                $fleet_managed = 0;
                if (isset($ctx0['fleet_kpis']) && is_array($ctx0['fleet_kpis']) && isset($ctx0['fleet_kpis']['managed_listings_count'])) {
                    $fleet_managed = max(0, (int) $ctx0['fleet_kpis']['managed_listings_count']);
                }
                if ($fleet_managed === 0 && isset($payload['fleet_kpis']) && is_array($payload['fleet_kpis']) && isset($payload['fleet_kpis']['managed_listings_count'])) {
                    $fleet_managed = max(0, (int) $payload['fleet_kpis']['managed_listings_count']);
                }
                $hello_ctx = array(
                    'business_name'        => isset($ctx0['business_name']) ? (string) $ctx0['business_name'] : '',
                    'score'                => isset($payload['score']) ? (int) $payload['score'] : null,
                    'level'                => isset($payload['level']) && $payload['level'] !== null ? (int) $payload['level'] : null,
                    'language'             => $language,
                    'dashboard_type'       => isset($payload['dashboard_type']) ? (string) $payload['dashboard_type'] : '',
                    'technical_context'    => null,
                    'fleet_managed_count'  => $fleet_managed,
                    'proxy_user_type'      => $user_type,
                );
                if ($is_hello_init_proxy) {
                    $data = self::hello_init_fill_empty_client_delegate_reply($data, $raw_message_full, $hello_ctx);
                    /*
                     * AVI-ANTI-REGRESSION: Accueil B2B ≠ accueil B2C
                     * B2B : ton analytique, pas de niveaux, pas de score /100
                     * B2C : ton coach, niveaux 1-21, score Maps
                     * NE PAS appeler hello_init_vip_welcome_cockpit_html() pour B2B
                     */
                    if ($user_type === 'b2b') {
                        $needs_override = empty($data['reply']) || (isset($data['model']) && (string) $data['model'] === 'hello_init_client_only');
                        if ($needs_override) {
                            $data = self::hello_init_apply_agency_hello_override($data, $language, $hello_ctx);
                        }
                    }
                }
                $data = self::maybe_admin_override_sync_welcome_reply($data, $raw_message_full, $hello_ctx);
            }
        }

        if (function_exists('error_log')) {
            $reply_ok = is_array($data) && isset($data['reply']) && is_string($data['reply']) && $data['reply'] !== '';
            if (!is_array($data) || $data === array() || (!$reply_ok && $code >= 200 && $code < 300)) {
                error_log('[AVI-DEBUG-ERROR] FastAPI returned an empty response or error');
            }
            error_log('[AVI-DEBUG-FINAL] Response being sent to JS: ' . print_r(is_array($data) ? $data : array('_non_array' => $data), true));
        }

        return new \WP_REST_Response($data, $code >= 200 && $code < 300 ? $code : 502);
    }

    /**
     * Nom affiché pour accueil HELLO_INIT (fallback admin) : business_name requête / méta / option — sinon « Coach ».
     *
     * @return string
     */
    private static function hello_init_admin_display_name() {
        if (!empty($_REQUEST['business_name']) && is_string($_REQUEST['business_name'])) {
            $n = sanitize_text_field(function_exists('wp_unslash') ? (string) wp_unslash($_REQUEST['business_name']) : (string) $_REQUEST['business_name']);
            if ($n !== '') {
                return $n;
            }
        }
        if (function_exists('is_user_logged_in') && is_user_logged_in()) {
            $uid = function_exists('get_current_user_id') ? (int) get_current_user_id() : 0;
            if ($uid > 0 && function_exists('get_user_meta')) {
                $m = get_user_meta($uid, 'avision_business_name', true);
                if (is_string($m) && trim($m) !== '') {
                    return trim($m);
                }
            }
        }
        if (function_exists('get_option')) {
            $g = get_option('avision_business_name', get_option('avision_company_name', ''));
            if (is_string($g) && trim($g) !== '') {
                return trim($g);
            }
        }
        return 'Coach';
    }

    /**
     * Admin WP : si HELLO_INIT et réponse FastAPI déjà présente (HTML, etc.), ne rien écraser.
     * Sinon seulement en secours si reply vide : message HTML avec business_name (jamais de prénom codé en dur).
     *
     * @param array<string, mixed>      $data
     * @param string                    $incoming_message Message brut client (HELLO_INIT / préfixe Question utilisateur).
     * @param array<string, mixed>|null $ctx_params       Contexte AJAX / REST (business_name, score, level, language, technical_context).
     * @return array<string, mixed>
     */
    private static function maybe_admin_override_sync_welcome_reply($data, $incoming_message = '', $ctx_params = null) {
        if (!is_array($data)) {
            if (function_exists('error_log')) {
                error_log('[AVI-DEBUG-ERROR] maybe_admin_override_sync_welcome_reply: $data n’est pas un tableau, abandon du bypass admin');
            }
            return array();
        }
        if (!is_user_logged_in() || !current_user_can('manage_options')) {
            return $data;
        }
        $incoming = is_string($incoming_message) ? $incoming_message : '';
        if ($incoming === '' && !empty($_REQUEST['message']) && is_string($_REQUEST['message'])) {
            $incoming = function_exists('wp_unslash') ? (string) wp_unslash($_REQUEST['message']) : (string) $_REQUEST['message'];
        }
        if (!self::message_is_hello_init($incoming)) {
            return $data;
        }
        /*
         * AVI-ANTI-REGRESSION: Accueil B2B ≠ accueil B2C
         * B2B : ton analytique, pas de niveaux, pas de score /100
         * B2C : ton coach, niveaux 1-21, score Maps
         * NE PAS appeler hello_init_vip_welcome_cockpit_html() pour B2B
         */
        if (self::hello_init_is_agency_hello_context(is_array($ctx_params) ? $ctx_params : array())) {
            if (empty($data['reply']) || !is_string($data['reply']) || trim($data['reply']) === '') {
                $snap_ag = self::hello_init_resolve_welcome_snapshot($incoming, is_array($ctx_params) ? $ctx_params : array());
                $lang_ag = isset($snap_ag['language']) && is_string($snap_ag['language']) ? strtolower(substr($snap_ag['language'], 0, 2)) : 'fr';
                if (!in_array($lang_ag, array('fr', 'en', 'es'), true)) {
                    $lang_ag = 'fr';
                }
                $n_ag = null;
                if (is_array($ctx_params) && isset($ctx_params['fleet_managed_count']) && (int) $ctx_params['fleet_managed_count'] > 0) {
                    $n_ag = (int) $ctx_params['fleet_managed_count'];
                }
                $kc_ag = trim(self::get_b2b_hello_reply_for_language($lang_ag));
                if ($kc_ag !== '') {
                    $data['reply'] = $kc_ag;
                } else {
                    $data['reply'] = self::hello_init_agency_welcome_html($lang_ag, $n_ag);
                }
                if (function_exists('error_log')) {
                    error_log('[AVI-PHP] Admin HELLO_INIT agency B2B fallback (reply vide, pas de cockpit VIP)');
                }
            }
            return $data;
        }
        // Préserver la réponse moteur (HTML &lt;span class="avi-text-orange"&gt;, etc.) — l’ancien override texte brut cassait le formatage.
        if (!empty($data['reply']) && is_string($data['reply']) && trim($data['reply']) !== '') {
            return $data;
        }
        $ref = (!empty($_SERVER['HTTP_REFERER']) && is_string($_SERVER['HTTP_REFERER']))
            ? (string) $_SERVER['HTTP_REFERER']
            : '';
        $uri = (!empty($_SERVER['REQUEST_URI']) && is_string($_SERVER['REQUEST_URI']))
            ? (string) $_SERVER['REQUEST_URI']
            : '';
        if (function_exists('error_log')) {
            error_log('[AVI-IDENTITY] Referer: ' . $ref . ' | URI: ' . $uri . ' | dashboard_type: ' . (isset($_REQUEST['dashboard_type']) ? (string) $_REQUEST['dashboard_type'] : ''));
        }
        $snap = self::hello_init_resolve_welcome_snapshot($incoming, is_array($ctx_params) ? $ctx_params : array());
        $current_level = 1;
        if (is_array($ctx_params)) {
            if (isset($ctx_params['level']) && is_numeric($ctx_params['level'])) {
                $current_level = max(1, min(21, (int) $ctx_params['level']));
            } elseif (isset($ctx_params['currentLevel']) && is_numeric($ctx_params['currentLevel'])) {
                $current_level = max(1, min(21, (int) $ctx_params['currentLevel']));
            }
        }
        $empty_all = ($snap['business_name'] === '' && $snap['score_current'] === null && $snap['active_level_title'] === '');
        if ($empty_all) {
            $lang = isset($snap['language']) && is_string($snap['language']) ? $snap['language'] : 'en';
            $data['reply'] = self::hello_init_accueil_strings_fallback_html($lang);
        } else {
            $business_name = ($snap['business_name'] !== '') ? $snap['business_name'] : self::hello_init_admin_display_name();
            // Langue : priorité au contexte AJAX/REST (params['language']) puis snapshot.
            $lang_adm = (is_array($ctx_params) && isset($ctx_params['language']) && is_string($ctx_params['language']) && trim($ctx_params['language']) !== '')
                ? trim((string) $ctx_params['language'])
                : (isset($snap['language']) && is_string($snap['language']) ? $snap['language'] : 'fr');
            if (!in_array($lang_adm, array('fr', 'en', 'es'), true)) {
                $lang_adm = 'fr';
            }
            if (defined('WP_DEBUG') && WP_DEBUG && function_exists('error_log')) {
                $post_lang = (isset($_POST['language']) && is_scalar($_POST['language'])) ? (string) $_POST['language'] : 'absent';
                error_log('[AVI-I18N-DEBUG] admin_welcome lang_adm=' . $lang_adm . ' snap=' . (isset($snap['language']) ? (string) $snap['language'] : 'absent') . ' ctx=' . (is_array($ctx_params) && isset($ctx_params['language']) ? (string) $ctx_params['language'] : 'absent') . ' post=' . $post_lang);
            }
            $lt_adm = ($snap['active_level_title'] !== '')
                ? $snap['active_level_title']
                : self::hello_init_level_title_i18n($current_level, $lang_adm);
            $data['reply'] = self::hello_init_vip_welcome_cockpit_html(
                $lang_adm,
                $business_name,
                $current_level,
                $lt_adm,
                $snap['score_current']
            );
        }
        if (function_exists('error_log')) {
            error_log('[AVI-PHP] Admin HELLO_INIT welcome fallback (reply vide) → message cockpit court (level=' . (int) $current_level . ')');
        }
        return $data;
    }

    private static $level_titles_fr = array(
        1 => 'Catégorie Principale',
        2 => 'Description & Mots-clés',
        3 => 'Photos (Pack 1)',
        4 => 'Mode Gardien',
        5 => 'Intégration Web',
        6 => 'Catalogue Produits / Menu',
        7 => 'Contact & Réseaux sociaux',
        8 => 'Horaires',
        9 => 'Attributs & Options',
        10 => 'Couverture GPS',
        11 => 'Besoins & Expansion Sémantique',
        12 => 'Nettoyage Concurrence',
        13 => 'Fiches de Services',
        14 => 'Bouton Réservation',
        15 => 'Gestion des Avis',
        16 => 'Exactitude Données Web',
        17 => 'Story & Storytelling',
        18 => 'Publications Hebdomadaires',
        19 => 'Statistiques & Rapports',
        20 => 'Photos (Pack 2)',
        21 => 'Annuaires Élites',
    );

    private static $level_titles_en = array(
        1 => 'Primary Category',
        2 => 'Description & Keywords',
        3 => 'Photos (Pack 1)',
        4 => 'Guardian Mode',
        5 => 'Website Integration',
        6 => 'Products & Menu Catalog',
        7 => 'Contact & Social Links',
        8 => 'Business Hours',
        9 => 'Attributes & Options',
        10 => 'GPS Coverage',
        11 => 'Needs & Semantic Expansion',
        12 => 'Competitor Cleanup',
        13 => 'Service Listings',
        14 => 'Booking Button',
        15 => 'Review Management',
        16 => 'Web Data Accuracy',
        17 => 'Story & Storytelling',
        18 => 'Weekly Posts',
        19 => 'Stats & Reports',
        20 => 'Photos (Pack 2)',
        21 => 'Elite Directories',
    );

    private static $level_titles_es = array(
        1 => 'Categoría principal',
        2 => 'Descripción y palabras clave',
        3 => 'Fotos (Pack 1)',
        4 => 'Modo Guardián',
        5 => 'Integración web',
        6 => 'Catálogo y menú',
        7 => 'Contacto y redes sociales',
        8 => 'Horarios',
        9 => 'Atributos y opciones',
        10 => 'Cobertura GPS',
        11 => 'Necesidades y expansión semántica',
        12 => 'Limpieza competencia',
        13 => 'Fichas de servicios',
        14 => 'Botón reservas',
        15 => 'Gestión de reseñas',
        16 => 'Exactitud datos web',
        17 => 'Historia y storytelling',
        18 => 'Publicaciones semanales',
        19 => 'Estadísticas e informes',
        20 => 'Fotos (Pack 2)',
        21 => 'Directorios élite',
    );

    /**
     * Titre de niveau 1–21 selon la langue (aligné premium-dashboard / FastAPI).
     *
     * @param int    $level 1–21.
     * @param string $lang  fr|en|es.
     * @return string
     */
    private static function hello_init_level_title_i18n($level, $lang) {
        $level = (int) $level;
        if ($level < 1 || $level > 21) {
            return '';
        }
        if ($lang === 'fr' && isset(self::$level_titles_fr[ $level ])) {
            return self::$level_titles_fr[ $level ];
        }
        if ($lang === 'es' && isset(self::$level_titles_es[ $level ])) {
            return self::$level_titles_es[ $level ];
        }
        return isset(self::$level_titles_en[ $level ]) ? self::$level_titles_en[ $level ] : '';
    }

    /**
     * Extrait business / score / mission depuis le préfixe « Dashboard technical context: … User question: ».
     *
     * @return array{business_name:string,score_current:?int,active_level_title:string}
     */
    private static function hello_init_parse_dashboard_context_from_message($message) {
        $out = array(
            'business_name'      => '',
            'score_current'      => null,
            'active_level_title' => '',
        );
        if (!is_string($message) || $message === '') {
            return $out;
        }
        $needle = 'Dashboard technical context:';
        $end = "\n\nUser question:";
        $p = strpos($message, $needle);
        if ($p === false) {
            return $out;
        }
        $start = $p + strlen($needle);
        $q = strpos($message, $end, $start);
        if ($q === false) {
            return $out;
        }
        $json_str = trim(substr($message, $start, $q - $start));
        if ($json_str === '') {
            return $out;
        }
        $decoded = json_decode($json_str, true);
        if (!is_array($decoded)) {
            return $out;
        }
        if (!empty($decoded['business']['business_name']) && is_string($decoded['business']['business_name'])) {
            $out['business_name'] = trim($decoded['business']['business_name']);
        }
        if (isset($decoded['score']['current']) && is_numeric($decoded['score']['current'])) {
            $out['score_current'] = max(0, min(100, (int) $decoded['score']['current']));
        }
        if (!empty($decoded['activeLevel']['title']) && is_string($decoded['activeLevel']['title'])) {
            $out['active_level_title'] = trim($decoded['activeLevel']['title']);
        }
        return $out;
    }

    /**
     * Fusionne message + POST technical_context + champs plats (business_name, score, level).
     *
     * @param array<string, mixed>|null $ctx_params Paramètres proxy / AJAX.
     * @return array{business_name:string,score_current:?int,active_level_title:string,language:string,complete:bool}
     */
    private static function hello_init_resolve_welcome_snapshot($incoming_message, $ctx_params) {
        $parsed = self::hello_init_parse_dashboard_context_from_message(is_string($incoming_message) ? $incoming_message : '');
        $ctx_params = is_array($ctx_params) ? $ctx_params : array();
        $tc_raw = '';
        if (!empty($ctx_params['technical_context']) && is_string($ctx_params['technical_context'])) {
            $tc_raw = $ctx_params['technical_context'];
        } elseif (!empty($ctx_params['technicalContext']) && is_string($ctx_params['technicalContext'])) {
            $tc_raw = $ctx_params['technicalContext'];
        }
        if ($tc_raw !== '') {
            $dec = json_decode($tc_raw, true);
            if (is_array($dec)) {
                if ($parsed['business_name'] === '' && !empty($dec['business']['business_name']) && is_string($dec['business']['business_name'])) {
                    $parsed['business_name'] = trim($dec['business']['business_name']);
                }
                if ($parsed['score_current'] === null && isset($dec['score']['current']) && is_numeric($dec['score']['current'])) {
                    $parsed['score_current'] = max(0, min(100, (int) $dec['score']['current']));
                }
                if ($parsed['active_level_title'] === '' && !empty($dec['activeLevel']['title']) && is_string($dec['activeLevel']['title'])) {
                    $parsed['active_level_title'] = trim($dec['activeLevel']['title']);
                }
            }
        }
        $lang = 'en';
        if (!empty($ctx_params['language']) && is_string($ctx_params['language'])) {
            $l = strtolower(substr(sanitize_text_field($ctx_params['language']), 0, 2));
            if (in_array($l, array('fr', 'en', 'es'), true)) {
                $lang = $l;
            }
        }
        if ($parsed['business_name'] === '' && !empty($ctx_params['business_name'])) {
            $parsed['business_name'] = trim((string) $ctx_params['business_name']);
        }
        if ($parsed['score_current'] === null && isset($ctx_params['score']) && is_numeric($ctx_params['score'])) {
            $parsed['score_current'] = max(0, min(100, (int) $ctx_params['score']));
        }
        if ($parsed['active_level_title'] === '') {
            $lvl = null;
            if (isset($ctx_params['level']) && is_numeric($ctx_params['level'])) {
                $lvl = (int) $ctx_params['level'];
            } elseif (isset($ctx_params['currentLevel']) && is_numeric($ctx_params['currentLevel'])) {
                $lvl = (int) $ctx_params['currentLevel'];
            }
            if ($lvl !== null && $lvl >= 1 && $lvl <= 21) {
                $parsed['active_level_title'] = self::hello_init_level_title_i18n($lvl, $lang);
            }
        }
        $parsed['language'] = $lang;
        return $parsed;
    }

    /**
     * Repli type « accueil_strings » / init_welcome (données cockpit pas encore prêtes).
     *
     * @param string $lang fr|en|es.
     * @return string HTML (spans cockpit).
     */
    private static function hello_init_accueil_strings_fallback_html($lang) {
        $rows = array(
            'fr' => array(
                'h' => 'COACH AVI',
                'p' => '<span class="avi-text-blue"><strong>Connexion à AVI…</strong></span><br><br>Les données du tableau de bord se chargent encore. Réessaie dans un instant ou rafraîchis la page (F5).',
            ),
            'es' => array(
                'h' => 'COACH AVI',
                'p' => '<span class="avi-text-blue"><strong>Conectando a AVI…</strong></span><br><br>Los datos del panel aún se están cargando. Inténtalo de nuevo en un momento o actualiza la página.',
            ),
            'en' => array(
                'h' => 'COACH AVI',
                'p' => '<span class="avi-text-blue"><strong>Connecting to AVI…</strong></span><br><br>Your dashboard data is still loading. Try again in a moment or refresh the page.',
            ),
        );
        $row = isset($rows[ $lang ]) ? $rows[ $lang ] : $rows['en'];
        return '<span class="avi-text-orange"><strong>' . esc_html($row['h']) . '</strong></span><br><br>' . $row['p'];
    }

    /*
     * AVI-ANTI-REGRESSION: Ce bloc (hello_init_vip_welcome_cockpit_html + hello_init_fill_empty_client_delegate_reply)
     * génère le message d’accueil VIP. NE PAS SUPPRIMER ni modifier sans test complet du dashboard.
     * Contient : salutation business_name + niveau + instructions Comment faire + Vérifier l’action.
     * Testé le : 2026-04-24 — version AVI_HELLO_INIT_VERSION.
     */

    /**
     * Message d’accueil cockpit VIP (HELLO_INIT) : prénom/nom d’établissement, niveau + titre, deux gestes clairs (Comment faire / Vérifier l’action).
     * Aligné sur l’esprit flow_templates.ACCUEIL (avi_master_brain.json), sans jargon technique.
     *
     * @param string   $lang          fr|en|es.
     * @param string   $business_name Non échappé.
     * @param int      $level         1–21.
     * @param string   $level_title   Non échappé.
     * @param int|null $score         Score 0–100 ou null.
     * @return string HTML complet (en-tête orange + corps).
     */
    private static function hello_init_vip_welcome_cockpit_html($lang, $business_name, $level, $level_title, $score) {
        $lang_raw_in = is_string($lang) ? $lang : (is_scalar($lang) ? (string) $lang : '');
        $lang = strtolower(substr(trim((string) $lang), 0, 2));
        if (!in_array($lang, array('fr', 'en', 'es'), true)) {
            $lang = 'fr';
        }
        if (defined('WP_DEBUG') && WP_DEBUG && function_exists('error_log')) {
            $post_lang = (isset($_POST['language']) && is_scalar($_POST['language'])) ? (string) $_POST['language'] : 'absent';
            $req_lang  = (isset($_REQUEST['language']) && is_scalar($_REQUEST['language'])) ? (string) $_REQUEST['language'] : 'absent';
            error_log('[AVI-I18N-DEBUG] hello_cockpit lang=' . $lang . ' raw=' . $lang_raw_in . ' post=' . $post_lang . ' req=' . $req_lang);
        }
        $level = max(1, min(21, (int) $level));
        $score_i = ($score !== null) ? max(0, min(100, (int) $score)) : 0;
        $bn_esc = function_exists('esc_html')
            ? esc_html((string) $business_name)
            : htmlspecialchars((string) $business_name, ENT_QUOTES, 'UTF-8');
        $lt_esc = function_exists('esc_html')
            ? esc_html((string) $level_title)
            : htmlspecialchars((string) $level_title, ENT_QUOTES, 'UTF-8');
        $level_link = '<a href="#level' . (int) $level . '"><strong>Niveau ' . (int) $level . '</strong></a>';
        if ($lang === 'es') {
            $body = '<strong>¡Hola ' . $bn_esc . '!</strong><br>'
                . 'Soy AVI, tu coach local SEO.<br>'
                . 'Estás en el <strong>Nivel ' . $level . ' — ' . $lt_esc . '</strong>.<br>'
                . sprintf('Indicador de visibilidad: <strong>%d/100</strong>.<br><br>', (int) $score_i)
                . '→ Empieza haciendo clic en el <strong>Nivel ' . (int) $level . '</strong> y se abrirá un panel.<br>'
                . '→ Luego haz clic en "💡 Cómo hacerlo" para ver las instrucciones del nivel.<br>'
                . '→ Una vez hecha la acción en tu ficha, haz clic en "⚡ Verificar la acción" — validaré tu acción antes de pasar al siguiente nivel.';
        } elseif ($lang === 'en') {
            $body = '<strong>Hello ' . $bn_esc . '!</strong><br>'
                . "I'm AVI, your local SEO coach.<br>"
                . "You're at <strong>Level " . $level . ' — ' . $lt_esc . '</strong>.<br>'
                . sprintf('Visibility score: <strong>%d/100</strong>.<br><br>', (int) $score_i)
                . '→ Start by clicking on <strong>Level ' . (int) $level . '</strong> and a panel will open.<br>'
                . '→ Then click "💡 How to do it" to see the level instructions.<br>'
                . '→ Once done on your listing, click "⚡ Verify action" — I\'ll validate your action before unlocking the next level.';
        } else {
            // AVI-ANTI-REGRESSION B2C HELLO v2.2 — 2026-04-25
            // UN SEUL message d'accueil B2C — vient du SERVEUR PHP uniquement.
            // Template : business_name + niveau + titre + score + instructions ⚡💡
            // Ton : tutoiement coach VIP (tu/ton/ta), pas vouvoiement (vous/votre)
            $body = '<strong>Bonjour ' . $bn_esc . ' !</strong><br>'
                . 'Je suis AVI, ton coach local SEO.<br>'
                . 'Tu es au <strong>Niveau ' . $level . ' — ' . $lt_esc . '</strong>.<br>'
                . sprintf('Indicateur de visibilité : <strong>%d/100</strong>.<br><br>', (int) $score_i)
                . '→ Commence par cliquer sur le ' . $level_link . ' et un volet va s\'ouvrir.<br>'
                . '→ Puis clique sur "💡 Comment faire" pour voir les consignes du niveau.<br>'
                . '→ Une fois l\'action faite sur ta fiche, clique sur "⚡ Vérifier l\'action" — je validerai ton action avant de passer au niveau suivant.';
        }
        return $body;
    }

    /**
     * Templates chips (Top 3 et Idée de Post) en EN/ES pour remplacement côté proxy si le backend renvoie encore le FR.
     */
    private static function get_chip_top3_template($language) {
        $templates = array(
            'en' => "1. **Neighborhood Circle** — Be the go-to within 500 m: precise category, description with local words, photos of your premises.\n\n"
                . "2. **City Circle** — Strengthen your presence in your city + trade: consistent keywords, recent reviews, regular posts.\n\n"
                . "3. **Trade Circle** — Become the « trade + city » reference: complete your info, hours, services.\n\n"
                . "Foundations to focus on first: **primary category** (Level 1), **description and keywords** (Level 2), **photos** (Level 3). "
                . "Check the active level side panel for concrete actions.",
            'es' => "1. **Círculo Barrio** — Sé la referencia a 500 m: categoría precisa, descripción con palabras del barrio, fotos de tu local.\n\n"
                . "2. **Círculo Ciudad** — Refuerza tu presencia en tu ciudad + sector: palabras clave coherentes, reseñas recientes, publicaciones regulares.\n\n"
                . "3. **Círculo Sector** — Conviértete en la referencia « sector + ciudad »: completa tu información, horarios, servicios.\n\n"
                . "Fundamentos a cuidar primero: **categoría principal** (Level 1), **descripción y palabras clave** (Level 2), **fotos** (Level 3). "
                . "Consulta el panel lateral del nivel activo para las acciones concretas.",
        );
        return isset($templates[$language]) ? $templates[$language] : $templates['en'];
    }

    private static function get_chip_post_template($language) {
        $templates = array(
            'en' => "**Hook** — A line that catches attention (offer, news, useful tip).\n\n"
                . "**Proof / detail** — One concrete fact: today's special, customer testimonial, trade tip in 2 lines.\n\n"
                . "**Call to action** — Invite to visit, book or leave a review.\n\n"
                . "Use your Radar keywords and your sector tone. Keep emojis subtle (1 or 2 max). "
                . "You can adapt this template in the active level panel.",
            'es' => "**Gancho** — Una frase que llame la atención (oferta, noticia, consejo útil).\n\n"
                . "**Prueba / detalle** — Un hecho concreto: especialidad del día, testimonio de cliente, truco del sector en 2 líneas.\n\n"
                . "**Llamada a la acción** — Invita a visitar, reservar o dejar una reseña.\n\n"
                . "Usa las palabras clave de tu Radar y el tono de tu sector. Emojis sobrios (1 o 2 máx). "
                . "Puedes adaptar este esquema en el panel del nivel activo.",
        );
        return isset($templates[$language]) ? $templates[$language] : $templates['en'];
    }

    /**
     * Translate known French patterns in a reply to the target language.
     * Inclut le remplacement des réponses chips (Top 3, Idée de Post) si le backend renvoie encore le FR.
     */
    private static function translate_reply_from_french($reply, $language) {
        if ($language === 'fr' || !$reply || !is_string($reply)) return $reply;

        // Remplacement complet des réponses chips FR si le backend a renvoyé les templates français
        if (strpos($reply, 'Cercle Quartier') !== false && strpos($reply, 'Sois incontournable') !== false && strpos($reply, 'fondations à soigner') !== false) {
            return self::get_chip_top3_template($language);
        }
        if (strpos($reply, 'Accroche') !== false && strpos($reply, 'Preuve / détail') !== false && strpos($reply, 'Utilise les mots-clés de ton Radar') !== false) {
            return self::get_chip_post_template($language);
        }

        $titles_target = ($language === 'es') ? self::$level_titles_es : self::$level_titles_en;
        $titles_fr_to_target = array();
        foreach (self::$level_titles_fr as $num => $fr) {
            $titles_fr_to_target[$fr] = isset($titles_target[$num]) ? $titles_target[$num] : $fr;
        }

        $phrases = ($language === 'es') ? array(
            'Tu es au niveau' => 'Estás en el nivel',
            'et ton score de visibilité est de' => 'y tu puntuación de visibilidad es de',
            'La validation technique du niveau' => 'La validación técnica del nivel',
            'est la priorité immédiate pour ta visibilité' => 'es la prioridad inmediata para tu visibilidad',
            'Clique sur le bloc' => 'Haz clic en el bloque',
            'et suis les instructions' => 'y sigue las instrucciones',
            'Une fois modifié, clique sur le bouton noir' => 'Una vez modificado, haz clic en el botón negro',
            'Vérifier l\'action' => 'Verificar la acción',
            "Vérifier l\xe2\x80\x99action" => 'Verificar la acción',
            'pour synchroniser les données' => 'para sincronizar los datos',
            'pour synchroniser les éléments' => 'para sincronizar',
            'Je vérifierai ton travail' => 'Verificaré tu trabajo',
            'J\'ai analysé ta fiche' => 'He analizado tu ficha',
            "J\xe2\x80\x99ai analysé ta fiche" => 'He analizado tu ficha',
            'On commence fort :' => 'Empecemos fuerte:',
            'On commence fort:' => 'Empecemos fuerte:',
            'Corrigeons ça ensemble dans le' => 'Corrijámoslo juntos en el',
            'Une fois la catégorie corrigée sur ta fiche Google, clique sur le bouton noir' => 'Una vez corregida la categoría en tu ficha de Google, haz clic en el botón negro',
            '**Ton travail préparatoire (Mission de l\'Ombre) :**' => '**Tu trabajo preparatorio (Misión en la Sombra):**',
            "**Ton travail préparatoire (Mission de l\xe2\x80\x99Ombre) :**" => '**Tu trabajo preparatorio (Misión en la Sombra):**',
            'Ton action prioritaire maintenant est :' => 'Tu acción prioritaria ahora es:',
            'Ton action prioritaire maintenant est:' => 'Tu acción prioritaria ahora es:',
            'Ton empire local se construit' => 'Tu imperio local se construye',
            'domination du Top 3 local' => 'dominación del Top 3 local',
            'Prochaine étape' => 'Próximo paso',
            'Niveau Verrouillé' => 'Nivel bloqueado',
            'Utilise le bouton noir pour valider ton action' => 'Usa el botón negro para validar tu acción',
            'On ne construit pas le toit avant les murs' => 'No se construye el techo antes que las paredes',
            'Termine d\'abord ta Phase actuelle pour débloquer cette étape' => 'Termina primero tu Fase actual para desbloquear este paso',
            "Termine d\xe2\x80\x99abord ta Phase actuelle pour débloquer cette étape" => 'Termina primero tu Fase actual para desbloquear este paso',
            'pour synchroniser les éléments. Je vérifierai ton travail' => 'para sincronizar. Verificaré tu trabajo',
            'pour synchroniser les données. Je vérifierai ton travail' => 'para sincronizar los datos. Verificaré tu trabajo',
            'Une fois corrigée sur ta fiche Google' => 'Una vez corregida en tu ficha de Google',
        ) : array(
            'Tu es au niveau' => 'You are at level',
            'et ton score de visibilité est de' => 'and your visibility score is',
            'La validation technique du niveau' => 'The technical validation of',
            'est la priorité immédiate pour ta visibilité' => 'is the immediate priority for your visibility',
            'Clique sur le bloc' => 'Click on the',
            'et suis les instructions' => 'and follow the instructions',
            'Une fois modifié, clique sur le bouton noir' => 'Once modified, click the black button',
            'Vérifier l\'action' => 'Verify action',
            "Vérifier l\xe2\x80\x99action" => 'Verify action',
            'pour synchroniser les données' => 'to sync the data',
            'pour synchroniser les éléments' => 'to sync',
            'Je vérifierai ton travail' => 'I\'ll check your work',
            'J\'ai analysé ta fiche' => 'I\'ve analyzed your listing',
            "J\xe2\x80\x99ai analysé ta fiche" => 'I\'ve analyzed your listing',
            'On commence fort :' => 'Let\'s start strong:',
            'On commence fort:' => 'Let\'s start strong:',
            'Corrigeons ça ensemble dans le' => 'Let\'s fix that together in',
            'Une fois la catégorie corrigée sur ta fiche Google, clique sur le bouton noir' => 'Once the category is corrected on your Google listing, click the black',
            '**Ton travail préparatoire (Mission de l\'Ombre) :**' => '**Your preparatory work (Shadow Mission):**',
            "**Ton travail préparatoire (Mission de l\xe2\x80\x99Ombre) :**" => '**Your preparatory work (Shadow Mission):**',
            'Ton action prioritaire maintenant est :' => 'Your immediate priority is:',
            'Ton action prioritaire maintenant est:' => 'Your immediate priority is:',
            'Ton empire local se construit' => 'Your local empire is being built',
            'domination du Top 3 local' => 'Top 3 local domination',
            'Prochaine étape' => 'Next step',
            'Niveau Verrouillé' => 'Locked Level',
            'Utilise le bouton noir pour valider ton action' => 'Use the black button to validate your action',
            'On ne construit pas le toit avant les murs' => 'You can\'t build the roof before the walls',
            'Termine d\'abord ta Phase actuelle pour débloquer cette étape' => 'Finish your current Phase first to unlock this step',
            "Termine d\xe2\x80\x99abord ta Phase actuelle pour débloquer cette étape" => 'Finish your current Phase first to unlock this step',
            'pour synchroniser les éléments. Je vérifierai ton travail' => 'to sync. I\'ll check your work',
            'pour synchroniser les données. Je vérifierai ton travail' => 'to sync the data. I\'ll check your work',
            'Une fois corrigée sur ta fiche Google' => 'Once corrected on your Google listing',
        );

        $out = $reply;

        // 1. Replace French level titles with translated versions (longest first to avoid partial matches)
        $fr_titles_sorted = array_keys($titles_fr_to_target);
        usort($fr_titles_sorted, function($a, $b) { return mb_strlen($b) - mb_strlen($a); });
        foreach ($fr_titles_sorted as $fr_title) {
            $target_title = $titles_fr_to_target[$fr_title];
            if ($fr_title !== $target_title) {
                $out = str_replace($fr_title, $target_title, $out);
            }
        }

        // 2. Replace French phrase patterns (longest first)
        $fr_phrases_sorted = array_keys($phrases);
        usort($fr_phrases_sorted, function($a, $b) { return mb_strlen($b) - mb_strlen($a); });
        foreach ($fr_phrases_sorted as $fr_phrase) {
            $out = str_replace($fr_phrase, $phrases[$fr_phrase], $out);
        }

        return $out;
    }

    /**
     * Catégorie principale Google (table avi_saas_profiles) pour ancrer le chat FastAPI (ex. hôtellerie vs BTP).
     *
     * @param array $params Paramètres AJAX (profile_id, user_id).
     * @return string Libellé sanitizé ou chaîne vide.
     */
    private static function resolve_google_primary_category_for_chat_proxy( $params ) {
        $params = is_array( $params ) ? $params : array();
        $pid = isset( $params['profile_id'] ) ? (int) $params['profile_id'] : 0;
        $uid = isset( $params['user_id'] ) ? (int) $params['user_id'] : 0;
        if ( $pid <= 0 && $uid <= 0 ) {
            return '';
        }
        if ( ! class_exists( __NAMESPACE__ . '\\Avision_Database' ) ) {
            return '';
        }
        try {
            $db = new Avision_Database();
        } catch ( \Throwable $e ) {
            return '';
        }
        $row = null;
        if ( $pid > 0 ) {
            $row = $db->get_saas_profile_by_id( $pid );
        } elseif ( $uid > 0 ) {
            $row = $db->get_first_saas_profile_by_user_id( $uid );
        }
        if ( ! is_array( $row ) ) {
            return '';
        }
        $gp = isset( $row['google_primary_category'] ) ? trim( (string) $row['google_primary_category'] ) : '';
        if ( $gp !== '' ) {
            return sanitize_text_field( $gp );
        }
        $cat = isset( $row['category'] ) ? trim( (string) $row['category'] ) : '';
        return $cat !== '' ? sanitize_text_field( $cat ) : '';
    }

    /**
     * Proxy chat appelé par l'AJAX avec un tableau (message, chatHistory, currentLevel, score, sector).
     */
    public function proxy_chat_to_fastapi_params($params) {
        $params = is_array($params) ? $params : array();
        $params = self::merge_chat_profile_id_from_request_context($params, null);
        $message = isset($params['message']) ? $params['message'] : (isset($params['query']) ? $params['query'] : '');
        $message = is_string($message) ? sanitize_textarea_field(wp_unslash($message)) : '';

        if ($message === '') {
            return array('success' => false, 'error' => 'Message required');
        }
        if (defined('WP_DEBUG') && WP_DEBUG && function_exists('error_log')) {
            error_log(
                '[AVI-LANG-NAV] POST language=' . ((isset($_POST['language']) && is_scalar($_POST['language'])) ? (string) $_POST['language'] : 'absent')
                . ' GET lang=' . ((isset($_GET['lang']) && is_scalar($_GET['lang'])) ? (string) $_GET['lang'] : 'absent')
                . ' referer=' . ((isset($_SERVER['HTTP_REFERER']) && is_string($_SERVER['HTTP_REFERER'])) ? (string) $_SERVER['HTTP_REFERER'] : 'absent')
            );
        }

        $request = new \WP_REST_Request('POST', '/avision/v1/chat');
        $language = self::normalize_chat_language_for_fastapi($params);
        $user_type_resolve = self::resolve_proxy_chat_user_type($params, null);
        $level_resolved = self::resolve_chat_level_from_params($params, $user_type_resolve);
        $score_body = isset($params['score']) ? (int) $params['score'] : 0;
        if ($user_type_resolve === 'b2b') {
            $score_body = 0;
        }
        $message_is_hello_init = self::message_is_hello_init($message);

        // Court-circuit HELLO_INIT B2C : message instantané sans FastAPI
        if ($message_is_hello_init && $user_type_resolve !== 'b2b') {
            /*
             * AVI-ANTI-REGRESSION HELLO B2C INSTANT v1.0 — 2026-04-25
             * HELLO_INIT B2C → message généré EN PHP, PAS via FastAPI
             * → Délai : < 200ms (pas 10s)
             * → Langue : POST > GET(?lang=) > WordPress locale
             * HELLO_INIT B2B → toujours via FastAPI (briefing dynamique fleet_kpis)
             */
            $biz = isset($params['business_name']) && is_string($params['business_name']) && trim($params['business_name']) !== ''
                ? sanitize_text_field($params['business_name'])
                : '';
            if ($biz === '') {
                $wp_uid = (int) get_current_user_id();
                $biz = ($wp_uid && get_user_meta($wp_uid, 'avision_business_name', true))
                    ? (string) get_user_meta($wp_uid, 'avision_business_name', true)
                    : (string) get_option('avision_business_name', get_option('avision_company_name', ''));
                $biz = trim((string) $biz);
            }
            $lvl = ($level_resolved !== null) ? (int) $level_resolved : 1;
            $lvl = max(1, min(21, $lvl));
            $lt = '';
            if (isset($params['level_title']) && is_string($params['level_title']) && trim($params['level_title']) !== '') {
                $lt = trim((string) $params['level_title']);
            } else {
                $lt = self::hello_init_level_title_i18n($lvl, $language);
            }
            $reply_html = self::hello_init_vip_welcome_cockpit_html(
                $language,
                $biz,
                $lvl,
                $lt,
                $score_body
            );
            wp_send_json_success(array(
                'reply'            => $reply_html,
                'model'            => 'hello_init_instant_b2c',
                'meta_level_title' => $lt,
            ));
            return; // Ne pas appeler FastAPI
        }

        $body = array(
            'message'       => $message,
            'query'         => $message,
            'score'         => $score_body,
            'chatHistory'   => isset($params['chatHistory']) ? $params['chatHistory'] : array(),
            'sector'        => isset($params['sector']) ? sanitize_text_field($params['sector']) : '',
            'business_name' => isset($params['business_name']) && $params['business_name'] !== '' ? sanitize_text_field($params['business_name']) : '',
            'profile_id'    => isset($params['profile_id']) && $params['profile_id'] !== '' ? sanitize_text_field($params['profile_id']) : null,
            'user_id'       => isset($params['user_id']) && $params['user_id'] !== '' ? sanitize_text_field($params['user_id']) : null,
            'language'      => $language,
            'context'       => array('lang' => $language),
        );
        // ANTI-RÉGRESSION #209i [28 mai 2026] — agency_name dans payload B2B (AJAX path)
        // Raison : orchestrator.py a besoin du nom agence (pas business_name fiche)
        if ( $user_type_resolve === 'b2b' ) {
            $uid_for_agency = ( isset( $params['user_id'] ) && is_scalar( $params['user_id'] ) && (int) $params['user_id'] > 0 )
                ? (int) $params['user_id']
                : ( function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0 );
            $agency_name_row = function_exists( 'avi_get_agency_name' ) ? avi_get_agency_name( $uid_for_agency ) : '';
            if ( is_string( $agency_name_row ) && trim( $agency_name_row ) !== '' ) {
                $body['agency_name'] = sanitize_text_field( $agency_name_row );
            }
        }
        if ($level_resolved !== null) {
            $body['level'] = $level_resolved;
        }
        if (isset($params['user_status']) && $params['user_status'] !== '' && $params['user_status'] !== null) {
            $body['user_status'] = sanitize_text_field($params['user_status']);
        }
        if (isset($params['l21_validation_count']) && $params['l21_validation_count'] !== null && $params['l21_validation_count'] !== '') {
            $body['l21_validation_count'] = max(0, (int) $params['l21_validation_count']);
        }
        if (isset($params['l21_validated_names']) && is_array($params['l21_validated_names'])) {
            $body['l21_validated_names'] = array_map('sanitize_text_field', $params['l21_validated_names']);
        }
        if (isset($params['review_rating']) && $params['review_rating'] >= 1 && $params['review_rating'] <= 5) {
            $body['review_rating'] = (int) $params['review_rating'];
        }
        if (isset($params['review_context']) && $params['review_context'] !== '') {
            $body['review_context'] = sanitize_text_field($params['review_context']);
        }
        if (isset($params['systemMessage']) && is_string($params['systemMessage']) && $params['systemMessage'] !== '') {
            $body['systemMessage'] = $params['systemMessage'];
        }
        if (isset($params['pid']) && is_string($params['pid']) && preg_match('/^[a-f0-9\-]{36}$/i', trim($params['pid']))) {
            $body['pid'] = trim($params['pid']);
        }
        if (isset($params['aid']) && is_string($params['aid']) && preg_match('/^[a-f0-9\-]{36}$/i', trim($params['aid']))) {
            $body['aid'] = trim($params['aid']);
        }
        if (isset($params['dashboard_type']) && is_string($params['dashboard_type']) && $params['dashboard_type'] !== '') {
            $body['dashboard_type'] = sanitize_text_field($params['dashboard_type']);
        }
        $fk_in_params = isset($params['fleet_kpis']) ? $params['fleet_kpis'] : null;
        if (is_string($fk_in_params) && $fk_in_params !== '') {
            $fk_in_params = json_decode($fk_in_params, true);
        }
        if (is_array($fk_in_params) && $fk_in_params !== array()) {
            $body['fleet_kpis'] = self::sanitize_fleet_kpis_for_chat($fk_in_params);
        }
        // HELLO_INIT B2B : fleet_kpis obligatoire pour déclencher le briefing dynamique.
        if (self::message_is_hello_init($message) && $user_type_resolve === 'b2b') {
            if (!isset($body['fleet_kpis']) || !is_array($body['fleet_kpis']) || $body['fleet_kpis'] === array()) {
                $body['fleet_kpis'] = self::get_fleet_kpis_for_briefing($params, (int) get_current_user_id());
            }
        }
        if (defined('WP_DEBUG') && WP_DEBUG && function_exists('error_log')) {
            error_log('[AVI-DEBUG-FLEET] fleet_kpis dans payload: ' . json_encode(isset($body['fleet_kpis']) ? $body['fleet_kpis'] : 'ABSENT'));
            error_log('[AVI-DEBUG-FLEET] user_type: ' . (isset($user_type_resolve) ? (string) $user_type_resolve : 'ABSENT'));
            error_log('[AVI-DEBUG-FLEET] is_hello_init: ' . ($message_is_hello_init ? 'TRUE' : 'FALSE'));
        }
        $gp_post = '';
        if (isset($params['google_primary_category']) && is_string($params['google_primary_category'])) {
            $gp_post = trim($params['google_primary_category']);
        }
        if ($gp_post !== '') {
            $body['google_primary_category'] = sanitize_text_field($gp_post);
        } elseif (!self::message_is_hello_init($message)) {
            $gp_chat = self::resolve_google_primary_category_for_chat_proxy($params);
            if ($gp_chat !== '') {
                $body['google_primary_category'] = $gp_chat;
            }
        }
        $wp_uid = (int) get_current_user_id();
        if ($wp_uid > 0) {
            if (!self::message_is_hello_init($message)) {
                $resolved_pid = self::resolve_chat_profile_id_from_server($body);
                if ($resolved_pid !== null && $resolved_pid !== '') {
                    $body['profile_id'] = $resolved_pid;
                }
            }
            if (empty($body['user_id']) || $body['user_id'] === null || $body['user_id'] === '') {
                $body['user_id'] = (string) $wp_uid;
            }
        }
        // Administrateur WP : même signal que proxy_chat_to_fastapi (AJAX admin-ajax).
        if (is_user_logged_in() && current_user_can('manage_options')) {
            if (!isset($body['context']) || !is_array($body['context'])) {
                $body['context'] = array();
            }
            $body['context']['admin_force_ai_chat'] = true;
            $body['admin_force_ai_chat'] = true;
            $pid_b = isset($body['profile_id']) ? trim((string) $body['profile_id']) : '';
            if ($pid_b === '' || $pid_b === '0') {
                $body['profile_id'] = '43';
            }
            $body['context']['profile_id'] = isset($body['profile_id']) ? (string) $body['profile_id'] : '43';
            if (function_exists('error_log')) {
                error_log('[AVI-PHP] proxy_chat_to_fastapi_params admin_force_ai_chat profile_id=' . (string) ($body['profile_id'] ?? ''));
            }
        }
        $request->set_body(json_encode($body));
        $request->set_header('Content-Type', 'application/json');

        $response = self::proxy_chat_to_fastapi($request);

        if (is_wp_error($response)) {
            return array('success' => false, 'error' => $response->get_error_message());
        }

        $http_status = $response->get_status();
        $data = is_array($response->get_data()) ? $response->get_data() : array();
        // Ne jamais renvoyer success: true si le backend a répondu 4xx/5xx (évite bulle vide avec reply: "")
        if ($http_status < 200 || $http_status >= 300) {
            $err = isset($data['detail']) ? $data['detail'] : ('Erreur serveur (HTTP ' . $http_status . ')');
            return array('success' => false, 'error' => is_string($err) ? $err : 'Erreur serveur', 'reply' => isset($data['reply']) ? $data['reply'] : '');
        }
        // Alignement admin-ajax / premium-dashboard.js : res.data.reply — remonter reply si seulement data.reply (évite null.data côté parseurs stricts).
        if (is_array($data) && (!isset($data['reply']) || !is_string($data['reply']) || trim($data['reply']) === '') && isset($data['data']) && is_array($data['data']) && isset($data['data']['reply']) && is_string($data['data']['reply']) && trim($data['data']['reply']) !== '') {
            $data['reply'] = $data['data']['reply'];
        }
        $raw_ajax_hello = isset($params['message']) && is_string($params['message']) ? wp_unslash($params['message']) : '';
        if ($raw_ajax_hello === '' && isset($params['query']) && is_string($params['query'])) {
            $raw_ajax_hello = wp_unslash($params['query']);
        }
        if (is_array($data)) {
            $data = self::hello_init_fill_empty_client_delegate_reply($data, $raw_ajax_hello, $params);
        }
        // AVI-ANTI-REGRESSION: Message accueil B2B garanti (AJAX)
        // Si FastAPI renvoie hello_init_client_only, on force l'accueil analytique B2B.
        if (
            self::message_is_hello_init($raw_ajax_hello)
            && is_array($data)
            && isset($data['model'])
            && (string) $data['model'] === 'hello_init_client_only'
            && (
                (isset($params['dashboard_type']) && is_string($params['dashboard_type']) && strtolower(trim($params['dashboard_type'])) === 'b2b')
                || (isset($user_type_resolve) && (string) $user_type_resolve === 'b2b')
            )
        ) {
            $fleet_count_ajax = 0;
            if (isset($params['fleet_kpis'])) {
                $fk = $params['fleet_kpis'];
                if (is_string($fk) && $fk !== '') {
                    $fk = json_decode($fk, true);
                }
                if (is_array($fk) && isset($fk['managed_listings_count'])) {
                    $fleet_count_ajax = max(0, (int) $fk['managed_listings_count']);
                }
            }
            $user_id = (isset($params['user_id']) && (int) $params['user_id'] > 0) ? (int) $params['user_id'] : (int) get_current_user_id();
            // Si managed_listings_count === 0, tenter un fallback DB
            if ($fleet_count_ajax === 0 && $user_id > 0) {
                $db_kpis = self::get_fleet_kpis_for_briefing($params, $user_id);
                if (is_array($db_kpis) && !empty($db_kpis['managed_listings_count'])) {
                    $fleet_count_ajax = (int) $db_kpis['managed_listings_count'];
                }
            }
            $hello_ctx_guard_ajax = array(
                'dashboard_type'      => 'b2b',
                'fleet_managed_count' => $fleet_count_ajax,
                'proxy_user_type'     => 'b2b',
            );
            $data = self::hello_init_apply_agency_hello_override($data, $language, $hello_ctx_guard_ajax);
            $data['model'] = AVI_B2B_HELLO_MODEL_STATIC;
        }
        if (is_array($data)) {
            $data = self::maybe_admin_override_sync_welcome_reply($data, $raw_ajax_hello, $params);
        }
        if (is_array($data) && isset($data['reply'])) {
            return array_merge(array('success' => true), $data);
        }
        if (is_array($data)) {
            return array_merge(array('success' => true), $data);
        }
        $err = is_array($data) && isset($data['detail']) ? $data['detail'] : 'Réponse FastAPI invalide';
        return array('success' => false, 'error' => is_string($err) ? $err : 'Réponse FastAPI invalide');
    }

    /**
     * Proxy Level 21 Panel vers FastAPI (contenu dynamique Side Panel Annuaires Élites).
     *
     * @param array $body { city, lang, sector, l21_validation_count, l21_validated_names }
     * @return array|WP_Error
     */
    public static function proxy_level21_panel_to_fastapi($body) {
        $body = is_array($body) ? $body : array();
        // Loopback direct : éviter 502 (pas d'HTTPS via internet)
        $base_internal = defined('AVISION_BACKEND_INTERNAL_URL') ? rtrim(AVISION_BACKEND_INTERNAL_URL, '/') : 'http://127.0.0.1:8001';
        $panel_url = $base_internal . '/level-21-panel';
        $panel_url = apply_filters('avision_fastapi_level21_panel_url', $panel_url);

        $response = wp_remote_post($panel_url, array(
            'headers'            => array_merge(array('Content-Type' => 'application/json'), self::get_backend_uuid_headers(null)),
            'body'               => json_encode($body),
            'timeout'            => 30,
            'reject_unsafe_urls' => false,
            'sslverify'          => false,
        ));

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

        $code = wp_remote_retrieve_response_code($response);
        $res_body = wp_remote_retrieve_body($response);
        $data = json_decode($res_body, true);

        if ($code !== 200 || json_last_error() !== JSON_ERROR_NONE) {
            return new \WP_Error('level21_panel_error', 'Erreur lors du chargement du panneau L21.', array('status' => $code >= 400 ? $code : 502));
        }

        return $data;
    }

    /**
     * Proxy catégories Google vers FastAPI (pour Guardian REST API).
     */
    public static function proxy_categories_to_fastapi($request) {
        $search = $request instanceof \WP_REST_Request ? $request->get_param('search') : (isset($_GET['search']) ? sanitize_text_field($_GET['search']) : '');
        $regionCode = $request instanceof \WP_REST_Request ? $request->get_param('regionCode') : (isset($_GET['regionCode']) ? sanitize_text_field($_GET['regionCode']) : 'US');
        $languageCode = $request instanceof \WP_REST_Request ? $request->get_param('languageCode') : (isset($_GET['languageCode']) ? sanitize_text_field($_GET['languageCode']) : 'en');
        // Loopback direct + Host pour éviter 502 (CyberPanel)
        $base_internal = defined('AVISION_BACKEND_INTERNAL_URL') ? rtrim(AVISION_BACKEND_INTERNAL_URL, '/') : 'http://127.0.0.1:8001';
        $url = add_query_arg(array(
            'search'       => $search,
            'regionCode'   => $regionCode,
            'languageCode' => $languageCode
        ), $base_internal . '/google-categories');
        $response = wp_remote_get($url, array(
            'timeout'            => 30,
            'reject_unsafe_urls' => false,
            'headers'            => self::get_backend_uuid_headers($request),
            'sslverify'          => false,
        ));
        if (is_wp_error($response)) {
            return new \WP_REST_Response(array('categories' => array()), 200);
        }
        $body = wp_remote_retrieve_body($response);
        $data = json_decode($body, true);
        $code = wp_remote_retrieve_response_code($response);
        if (json_last_error() !== JSON_ERROR_NONE || $code < 200 || $code >= 300) {
            return new \WP_REST_Response(array('categories' => array()), 200);
        }
        if (!is_array($data)) {
            $data = array('categories' => array());
        }
        if (!isset($data['categories'])) {
            $data['categories'] = array();
        }
        return new \WP_REST_Response($data, 200);
    }

    /**
     * Après un HTTP 401 sur une API Google OAuth : force le rafraîchissement via google_refresh_token,
     * puis l’appelant doit relancer la requête avec le nouvel access_token (voir fetch_google_business_metrics, scan engine).
     *
     * @param int $profile_id ID avi_saas_profiles (ex. 43).
     * @return bool true si refresh_google_access_token_if_needed a réussi (nouveau jeton en base).
     */
    public static function refresh_google_token($profile_id) {
        $profile_id = (int) $profile_id;
        if ($profile_id <= 0 || !class_exists(__NAMESPACE__ . '\\Avision_Database')) {
            return false;
        }
        if (function_exists('error_log')) {
            error_log('🔄 Token expiré, tentative de rafraîchissement pour le profil ' . $profile_id . '...');
        }
        $db = new Avision_Database();
        return $db->refresh_google_access_token_if_needed($profile_id, true);
    }
}
