<?php
/**
 * Gestionnaire de Tâches CRON — Orchestrateur horaire du SaaS AVI
 *
 * Planifié toutes les heures via WP-Cron, ce script :
 *   1. Sélectionne un batch de profils dont le scan est dû (plan daily/weekly/monthly)
 *   2. Appelle Avision_Scan_Engine::sync_keyword_rankings() + sync_business_insights()
 *   3. Génère des événements de sécurité (Mode Gardien — Preuve de Valeur)
 *   4. Logue chaque opération dans logs/cron-task.log
 *
 * Complémentaire de class-avision-cron.php qui gère :
 *   - la queue de mots-clés "pending" (batch 10/min)
 *   - les rapports hebdo/mensuels
 *
 * @package Avision
 */

namespace Avision;

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

class Cron_Task_Manager {

    const HOOK_HOURLY_SCAN = 'avision_hourly_scan_orchestrator';

    /** Nombre max de profils traités par exécution (répartition de charge). */
    const BATCH_SIZE = 8;

    /** Chemin du log dédié (relatif à wp-content/plugins/avision-dashboard/). */
    const LOG_FILE = 'logs/cron-task.log';

    /** Clé secrète pour protéger l'exécution manuelle via URL (?avi_cron_key=...). */
    const OPTION_SECRET_KEY = 'avi_cron_task_secret';

    /** Délais de scan par plan (secondes). Cohérent avec Avision_Scan_Engine::SCAN_DELAYS. */
    const PLAN_DELAYS = [
        'offert'  => 2592000,  // 30 j
        'hebdo'   => 604800,   // 7 j
        'weekly'  => 604800,
        'elite'   => 86400,    // 24 h
        'daily'   => 86400,
    ];

    /**
     * Les 12 Signaux Vitaux du Mode Gardien.
     * Utilisés pour la génération d'événements de sécurité pédagogiques.
     */
    const GUARDIAN_SIGNALS = [
        1  => 'Nom de l\'établissement',
        2  => 'Catégorie d\'activité',
        3  => 'Numéro de téléphone',
        4  => 'Adresse physique',
        5  => 'Lien du site web',
        6  => 'Horaires d\'ouverture',
        7  => 'Zone de chalandise',
        8  => 'Services et prestations',
        9  => 'Attributs (Accessibilité / Options)',
        10 => 'Lien de réservation',
        11 => 'Statut d\'ouverture',
        12 => 'Galerie photos & vidéos',
    ];

    // SPRINT 9 — Score quotidien + cache briefing B2B (S9-A)
    const DAILY_SCORE_VERSION = '1.0';
    const FLEET_KPIS_CACHE_PREFIX = 'avi_fleet_kpis_cache_';
    const FLEET_KPIS_CACHE_TTL = 3600;

    // SPRINT 9 — S9-C : quotas mots-clés par plan (Radar)
    const S9C_PLAN_VERSION = '1.0';
    const PLAN_MAX_KEYWORDS = [
        'offert' => 20,
        'hebdo'  => 75,
        'weekly' => 75,
        'elite'  => 100,
        'daily'  => 100,
    ];

    // SPRINT 10 — S10-A : robustesse DataForSEO
    const S10A_DATAFORSEO_VERSION    = '1.0';
    const DATAFORSEO_CIRCUIT_MAX     = 5;
    const DATAFORSEO_CIRCUIT_TTL_SEC = 1800;
    const KEYWORD_ERROR_RETRY_LIMIT  = 50;

    // SPRINT 10 — S10-C : perf CRONs + index DB
    const S10C_PERF_VERSION               = '1.0';
    const HOURLY_SCAN_LOCK_KEY            = 'avi_hourly_scan_lock';
    const HOURLY_SCAN_LOCK_TTL            = 3500;
    const DAILY_SCORE_LOCK_KEY            = 'avi_daily_score_lock';
    const DAILY_SCORE_LOCK_TTL            = 90000;
    const KEYWORD_SCAN_BATCH_SIZE_DEFAULT = 10;
    const KEYWORD_SCAN_BATCH_SIZE_MAX     = 25;
    const KEYWORD_SCANNING_TIMEOUT_MIN    = 5;

    const SCORE_CRITERIA = [
        'base'       => 30,
        'web'        => 5,
        'hours'      => 3,
        'desc'       => 2,
        'category'   => 1,
        'attributes' => 1,
    ];

    /** Hook WP-Cron : recalcul scores + cache KPIs flotte (quotidien). */
    const HOOK_DAILY_SCORE_COMPUTE = 'avision_daily_score_compute';
    /** Hook WP-Cron : retry keywords en erreur (hourly). */
    const HOOK_KEYWORD_ERROR_RETRY = 'avision_keyword_error_retry';
    /** Hook WP-Cron S11 : re-engagement B2C (hebdo). */
    const HOOK_REENGAGE_CHECK = 'avision_reengage_check';

    // ──────────────────────────────────────────────
    // Initialisation & Hooks
    // ──────────────────────────────────────────────

    public function __construct() {
        add_action( self::HOOK_HOURLY_SCAN, [ $this, 'run' ] );
        add_action( self::HOOK_DAILY_SCORE_COMPUTE, [ $this, 'run_daily_score_compute' ] );
        add_action( self::HOOK_KEYWORD_ERROR_RETRY, [ $this, 'run_keyword_error_retry' ] );
        add_action( self::HOOK_REENGAGE_CHECK, [ $this, 'run_reengage_check' ] );
        add_action( 'init', [ $this, 'schedule' ] );

        // Point d'entrée admin sécurisé pour exécution manuelle
        add_action( 'admin_init', [ $this, 'handle_manual_trigger' ] );
    }

    /**
     * Planifie le hook horaire s'il n'est pas déjà programmé.
     */
    public function schedule(): void {
        if ( ! wp_next_scheduled( self::HOOK_HOURLY_SCAN ) ) {
            wp_schedule_event( time(), 'hourly', self::HOOK_HOURLY_SCAN );
            $this->log( 'INFO', 'Hook ' . self::HOOK_HOURLY_SCAN . ' planifié (hourly)' );
        }
        $this->register_crons();
    }

    /**
     * Hooks CRON récurrents additionnels (Sprint 9). Ne modifie pas les hooks existants.
     */
    private function register_crons(): void {
        if ( ! wp_next_scheduled( self::HOOK_DAILY_SCORE_COMPUTE ) ) {
            wp_schedule_event( time(), 'daily', self::HOOK_DAILY_SCORE_COMPUTE );
            $this->ensure_log_dir();
            $this->log( 'INFO', 'Hook ' . self::HOOK_DAILY_SCORE_COMPUTE . ' planifié (daily)' );
        }
        if ( ! wp_next_scheduled( self::HOOK_KEYWORD_ERROR_RETRY ) ) {
            wp_schedule_event( time(), 'hourly', self::HOOK_KEYWORD_ERROR_RETRY );
            $this->ensure_log_dir();
            $this->log( 'INFO', 'Hook ' . self::HOOK_KEYWORD_ERROR_RETRY . ' planifié (hourly)' );
        }
        if ( ! wp_next_scheduled( self::HOOK_REENGAGE_CHECK ) ) {
            wp_schedule_event( time(), 'weekly', self::HOOK_REENGAGE_CHECK );
            $this->ensure_log_dir();
            $this->log( 'INFO', 'Hook ' . self::HOOK_REENGAGE_CHECK . ' planifié (weekly)' );
        }
    }

    /**
     * Dé-planifie le hook (appelé à la désactivation du plugin).
     */
    public static function unschedule(): void {
        $ts = wp_next_scheduled( self::HOOK_HOURLY_SCAN );
        if ( $ts ) {
            wp_unschedule_event( $ts, self::HOOK_HOURLY_SCAN );
        }
        $ts_daily = wp_next_scheduled( self::HOOK_DAILY_SCORE_COMPUTE );
        if ( $ts_daily ) {
            wp_unschedule_event( $ts_daily, self::HOOK_DAILY_SCORE_COMPUTE );
        }
        $ts_retry = wp_next_scheduled( self::HOOK_KEYWORD_ERROR_RETRY );
        if ( $ts_retry ) {
            wp_unschedule_event( $ts_retry, self::HOOK_KEYWORD_ERROR_RETRY );
        }
        $ts_re = wp_next_scheduled( self::HOOK_REENGAGE_CHECK );
        if ( $ts_re ) {
            wp_unschedule_event( $ts_re, self::HOOK_REENGAGE_CHECK );
        }
    }

    /** Hooks autorisés pour le test manuel un par un (admin). */
    const ALLOWED_MANUAL_HOOKS = [
        'avision_hourly_scan_orchestrator',
        'avision_keyword_scan_queue',
        'avision_weekly_report',
        'avision_monthly_report',
        'avision_daily_score_compute',
        'avision_keyword_error_retry',
        'avision_reengage_check',
    ];

    /**
     * Permet de déclencher manuellement via :
     *   /wp-admin/admin.php?avi_cron_run=1&avi_cron_key=<secret>
     * Option : avi_cron_hook=<hook> pour exécuter un seul CRON (orchestrator, keyword_scan_queue, weekly_report, monthly_report).
     *
     * La clé est stockée dans wp_options (générée au premier appel).
     */
    public function handle_manual_trigger(): void {
        $is_get  = isset( $_GET['avi_cron_run'] ) && $_GET['avi_cron_run'] === '1';
        $is_post = isset( $_POST['avi_cron_run'] ) && $_POST['avi_cron_run'] === '1';
        if ( ! $is_get && ! $is_post ) {
            return;
        }

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_die( 'Accès refusé.', 403 );
        }

        $stored_key = get_option( self::OPTION_SECRET_KEY );
        if ( ! $stored_key ) {
            $stored_key = wp_generate_password( 32, false );
            update_option( self::OPTION_SECRET_KEY, $stored_key );
        }

        $source = $is_post ? $_POST : $_GET;
        $provided_key = isset( $source['avi_cron_key'] ) ? sanitize_text_field( wp_unslash( $source['avi_cron_key'] ) ) : '';
        if ( ! hash_equals( $stored_key, $provided_key ) ) {
            wp_die( 'Clé CRON invalide. Vérifiez wp_options → ' . self::OPTION_SECRET_KEY, 403 );
        }

        check_admin_referer( 'avi_cron_manual_run' );

        if ( isset( $source['avi_force_all_crons'] ) && $source['avi_force_all_crons'] === '1' ) {
            $this->ensure_log_dir();
            $this->log( 'INFO', 'FORCE ALL CRONS (admin) — début' );
            foreach ( self::ALLOWED_MANUAL_HOOKS as $h ) {
                $this->log( 'INFO', 'FORCE ALL hook: ' . $h );
                do_action( $h );
            }
            $profile_id = isset( $source['avi_force_profile_id'] ) ? absint( $source['avi_force_profile_id'] ) : 0;
            if ( $profile_id <= 0 ) {
                $profile_id = (int) apply_filters( 'avision_force_all_crons_default_profile_id', 212 );
            }
            $scanned = 0;
            if ( $profile_id > 0 && function_exists( 'avision_force_immediate_scan' ) ) {
                $scanned = (int) avision_force_immediate_scan( $profile_id );
                $this->log( 'INFO', 'avision_force_immediate_scan profile_id=' . $profile_id . ' scanned=' . $scanned );
            }
            wp_die(
                'FORCE ALL CRONS exécuté : hooks ' . esc_html( implode( ', ', self::ALLOWED_MANUAL_HOOKS ) ) . ' + scan immédiat profil #' . (int) $profile_id . ' (' . (int) $scanned . ' mots-clés traités).',
                200,
                [ 'response' => 200, 'back_link' => true ]
            );
        }

        $hook = isset( $source['avi_cron_hook'] ) ? sanitize_text_field( wp_unslash( $source['avi_cron_hook'] ) ) : '';

        if ( $hook !== '' && in_array( $hook, self::ALLOWED_MANUAL_HOOKS, true ) ) {
            $this->ensure_log_dir();
            $this->log( 'INFO', 'Exécution manuelle (admin) du hook: ' . $hook );
            do_action( $hook );
            wp_die(
                'CRON exécuté : <strong>' . esc_html( $hook ) . '</strong>. Consultez les logs si besoin (cron-task.log ou error_log).',
                200,
                [ 'response' => 200, 'back_link' => true ]
            );
        }

        $this->run();

        wp_die( 'CRON Task Manager exécuté avec succès. Consultez logs/cron-task.log.', 200 );
    }

    // ──────────────────────────────────────────────
    // Exécution principale
    // ──────────────────────────────────────────────

    /**
     * Batch adaptatif selon la taille de la file (worker scan mots-clés).
     *
     * @sprint S10-C v1.0
     * @return int Entre KEYWORD_SCAN_BATCH_SIZE_DEFAULT et KEYWORD_SCAN_BATCH_SIZE_MAX.
     */
    public static function get_adaptive_keyword_batch_size(): int {
        global $wpdb;
        if ( ! isset( $wpdb ) || ! $wpdb ) {
            return self::KEYWORD_SCAN_BATCH_SIZE_DEFAULT;
        }
        $table = $wpdb->prefix . 'avi_saas_keywords';
        if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) !== $table ) {
            return self::KEYWORD_SCAN_BATCH_SIZE_DEFAULT;
        }
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- nom de table contrôlé (préfixe WP).
        $pending = (int) $wpdb->get_var(
            "SELECT COUNT(*) FROM `{$table}`
             WHERE scan_status IN ('pending','queued')
                OR scan_status IS NULL OR scan_status = ''"
        );
        if ( $pending > 500 ) {
            return (int) self::KEYWORD_SCAN_BATCH_SIZE_MAX;
        }
        if ( $pending > 100 ) {
            return 15;
        }
        return self::KEYWORD_SCAN_BATCH_SIZE_DEFAULT;
    }

    /**
     * Point d'entrée principal — appelé toutes les heures par WP-Cron.
     *
     * @sprint S10-C v1.0 — Lock anti-chevauchement (transient).
     */
    public function run(): void {
        if ( function_exists( 'get_transient' ) && get_transient( self::HOURLY_SCAN_LOCK_KEY ) ) {
            if ( function_exists( 'error_log' ) ) {
                error_log( '[AVI-S10C] avision_hourly_scan_orchestrator déjà en cours — ignoré' );
            }
            return;
        }
        if ( function_exists( 'set_transient' ) ) {
            set_transient( self::HOURLY_SCAN_LOCK_KEY, 1, self::HOURLY_SCAN_LOCK_TTL );
        }
        try {
            $this->run_inner();
        } finally {
            if ( function_exists( 'delete_transient' ) ) {
                delete_transient( self::HOURLY_SCAN_LOCK_KEY );
            }
        }
    }

    /**
     * Corps historique de run() (batch horaire).
     */
    private function run_inner(): void {
        // ═══════════════════════════════════════════════════════════════════════
        // ANTI-RÉGRESSION #187 [23 mai 2026] — Réparation auto profils incomplets
        // Détecte les profils B2C avec is_first_sync=0 mais données GBP manquantes
        // et les répare chirurgicalement (max 3 par run pour ne pas bloquer le batch).
        // ═══════════════════════════════════════════════════════════════════════
        try {
            $db_repair = new \Avision\Avision_Database();
            $incomplete = $db_repair->get_incomplete_b2c_profiles( 3 );
            if ( ! empty( $incomplete ) ) {
                error_log( '[AVI CRON] Réparation auto: ' . count( $incomplete ) . ' profils incomplets détectés' );
                foreach ( $incomplete as $_inc ) {
                    $db_repair->repair_incomplete_profile( (int) $_inc['id'] );
                }
            }
            unset( $db_repair, $incomplete );
        } catch ( \Throwable $e ) {
            error_log( '[AVI CRON] Erreur réparation auto: ' . $e->getMessage() );
        }

        $start = microtime( true );
        $this->log( 'INFO', '═══ DÉBUT batch CRON ═══' );

        $this->ensure_log_dir();

        if ( ! class_exists( __NAMESPACE__ . '\Avision_Scan_Engine' ) ) {
            $this->log( 'CRITICAL', 'Avision_Scan_Engine introuvable — arrêt' );
            return;
        }

        $engine = new Avision_Scan_Engine();
        $engine->init();

        // 1. Sélection des profils éligibles (taille de batch filtrable pour limiter la charge API)
        $batch_size = (int) apply_filters( 'avision_cron_batch_size', self::BATCH_SIZE );
        $batch_size = max( 1, min( 50, $batch_size ) );
        $profiles = $this->get_due_profiles( $batch_size );

        if ( empty( $profiles ) ) {
            $this->log( 'INFO', 'Aucun profil à scanner dans ce batch' );
            $this->log( 'INFO', '═══ FIN batch CRON (0 profils, ' . $this->elapsed( $start ) . ') ═══' );
            return;
        }

        $this->log( 'INFO', count( $profiles ) . ' profil(s) sélectionné(s) pour ce batch' );

        $total_stats = [ 'profiles' => 0, 'kw_scanned' => 0, 'insights_ok' => 0, 'security_events' => 0, 'reputation' => 0, 'maintenance' => 0, 'errors' => 0 ];

        // 2. Traitement de chaque profil
        foreach ( $profiles as $profile ) {
            $profile_id = (int) $profile['id'];
            $user_id    = (int) $profile['user_id'];
            $plan       = $profile['radar_plan'] ?? 'offert';

            $this->log( 'INFO', "▸ Profil #{$profile_id} (user={$user_id}, plan={$plan})" );

            // 2a. Sync Radar (mots-clés)
            $kw_result = $engine->sync_keyword_rankings( $user_id );
            $total_stats['kw_scanned'] += $kw_result['scanned'] ?? 0;
            $total_stats['errors']     += $kw_result['errors'] ?? 0;
            $this->log( 'INFO', "  Radar: scanned={$kw_result['scanned']}, skipped={$kw_result['skipped']}, errors={$kw_result['errors']}" );

            // 2b. Sync Google Business Insights (Net Worth)
            $insights = $engine->sync_business_insights( $user_id );
            if ( $insights['success'] ?? false ) {
                $total_stats['insights_ok']++;
                $this->log( 'OK', "  Insights: daily={$insights['daily_saving']}€, cumul={$insights['cumulative']}€" );
            } else {
                $this->log( 'WARN', "  Insights: échec ou token manquant" );
            }

            // 2c. Mode Gardien — Veilleurs Vague 1 (détection réelle) + Preuve de Valeur (pédago)
            $guardian_active = ! empty( $profile['guardian_active'] ) && (int) $profile['guardian_active'] === 1;
            $guardian_scan_on = ! empty( $profile['guardian_scan_enabled'] ) && (int) $profile['guardian_scan_enabled'] === 1;
            $run_guardian     = $guardian_active && $guardian_scan_on;
            if ( $run_guardian ) {
                // Détection réelle (NAP Shield, Spy Alert, Sentinel Top 3)
                if ( class_exists( __NAMESPACE__ . '\Avision_Guardian_Watchers' ) ) {
                    $full_profile = $this->get_full_profile_for_watchers( $profile_id );
                    if ( $full_profile ) {
                        $watchers = new Avision_Guardian_Watchers();
                        $wave1    = $watchers->run_wave1( $user_id, $full_profile );
                        $real_alerts = ( $wave1['nap'] ?? 0 ) + ( $wave1['spy'] ?? 0 ) + ( $wave1['sentinel'] ?? 0 );
                        $total_stats['security_events'] += $real_alerts;
                        $this->log( 'OK', "  Watchers V1: nap={$wave1['nap']}, spy={$wave1['spy']}, sentinel={$wave1['sentinel']}" );
                    }
                }

                // Preuve de Valeur (événements pédagogiques)
                $events = $this->generate_security_events( $user_id );
                $total_stats['security_events'] += $events;
                $this->log( 'OK', "  Gardien pedago: {$events} événement(s) généré(s)" );
            }

            // 2d. Vague 2 — Créateurs de Réputation (Proof Extractor, Hybrid Responses, Armored Reputation)
            if ( $run_guardian && class_exists( __NAMESPACE__ . '\Avision_Guardian_Creators' ) ) {
                $full_profile_v2 = $full_profile ?? $this->get_full_profile_for_watchers( $profile_id );
                if ( $full_profile_v2 ) {
                    $creators = new Avision_Guardian_Creators();
                    $wave2    = $creators->run_wave2( $user_id, $full_profile_v2 );
                    $rep_total = ( $wave2['proofs'] ?? 0 ) + ( $wave2['responses'] ?? 0 ) + ( $wave2['crises'] ?? 0 );
                    $total_stats['reputation'] += $rep_total;
                    $this->log( 'OK', "  Creators V2: proofs={$wave2['proofs']}, responses={$wave2['responses']}, crises={$wave2['crises']}" );
                }
            }

            // 2f. Vague 3 — Mainteneurs de Fraîcheur (Image Cleaner, Drip Feed, Infinite Posts)
            if ( $run_guardian && class_exists( __NAMESPACE__ . '\Avision_Guardian_Maintainers' ) ) {
                $full_profile_v3 = $full_profile ?? $full_profile_v2 ?? $this->get_full_profile_for_watchers( $profile_id );
                if ( $full_profile_v3 ) {
                    $maintainers = new Avision_Guardian_Maintainers();
                    $wave3       = $maintainers->run_wave3( $user_id, $full_profile_v3 );
                    $maint_total = ( $wave3['cleaned'] ?? 0 ) + ( $wave3['drip'] ?? 0 ) + ( $wave3['posts'] ?? 0 );
                    $total_stats['maintenance'] += $maint_total;
                    $this->log( 'OK', "  Maintainers V3: cleaned={$wave3['cleaned']}, drip={$wave3['drip']}, posts={$wave3['posts']}" );
                }
            }

            // 2g. Met à jour last_radar_scan sur le profil
            $this->update_last_radar_scan( $profile_id );

            $total_stats['profiles']++;
        }

        $elapsed = $this->elapsed( $start );
        $this->log( 'INFO', '═══ FIN batch CRON (' . $total_stats['profiles'] . ' profils, '
            . $total_stats['kw_scanned'] . ' kw, '
            . $total_stats['insights_ok'] . ' insights, '
            . $total_stats['security_events'] . ' events, '
            . $total_stats['reputation'] . ' reputation, '
            . $total_stats['maintenance'] . ' maintenance, '
            . $total_stats['errors'] . ' erreurs, '
            . $elapsed . ') ═══' );
    }

    /**
     * Exécute le même traitement que le CRON horaire pour un seul profil (pour test depuis le dashboard).
     * Utilisé par REST avision/v1/cron-run-profile (admin uniquement).
     *
     * @param int $profile_id ID du profil (avi_saas_profiles.id).
     * @return array Stats du run (profiles, kw_scanned, insights_ok, security_events, reputation, maintenance, errors).
     */
    public function run_for_profile( int $profile_id ): array {
        $start    = microtime( true );
        $profile  = $this->get_full_profile_for_watchers( $profile_id );
        $defaults = [
            'profiles' => 0, 'kw_scanned' => 0, 'insights_ok' => 0, 'security_events' => 0,
            'reputation' => 0, 'maintenance' => 0, 'errors' => 0,
        ];
        if ( ! $profile || empty( $profile['user_id'] ) ) {
            $this->log( 'WARN', "run_for_profile: profil #{$profile_id} introuvable ou sans user_id" );
            return $defaults;
        }
        $this->ensure_log_dir();
        $this->log( 'INFO', '═══ CRON pour fiche #' . $profile_id . ' (demande manuelle) ═══' );
        if ( ! class_exists( __NAMESPACE__ . '\Avision_Scan_Engine' ) ) {
            $this->log( 'CRITICAL', 'Avision_Scan_Engine introuvable' );
            return $defaults;
        }
        $user_id = (int) $profile['user_id'];
        $plan    = strtolower( trim( (string) ( $profile['radar_plan'] ?? 'offert' ) ) );
        $guardian_active = ! empty( $profile['guardian_active'] ) && (int) $profile['guardian_active'] === 1;
        $guardian_scan_on = ! empty( $profile['guardian_scan_enabled'] ) && (int) $profile['guardian_scan_enabled'] === 1;
        $run_guardian     = $guardian_active && $guardian_scan_on;
        $engine = new Avision_Scan_Engine();
        $engine->init();
        $stats = [
            'profiles' => 1, 'kw_scanned' => 0, 'insights_ok' => 0, 'security_events' => 0,
            'reputation' => 0, 'maintenance' => 0, 'errors' => 0,
        ];
        $kw_result = $engine->sync_keyword_rankings( $user_id );
        $stats['kw_scanned'] = $kw_result['scanned'] ?? 0;
        $stats['errors']    = $kw_result['errors'] ?? 0;
        $this->log( 'INFO', "  Radar: scanned={$stats['kw_scanned']}, errors={$stats['errors']}" );
        $insights = $engine->sync_business_insights( $user_id );
        if ( $insights['success'] ?? false ) {
            $stats['insights_ok'] = 1;
        }
        $full_profile = $this->get_full_profile_for_watchers( $profile_id );
        if ( $run_guardian && $full_profile && class_exists( __NAMESPACE__ . '\Avision_Guardian_Watchers' ) ) {
            $watchers = new Avision_Guardian_Watchers();
            $wave1    = $watchers->run_wave1( $user_id, $full_profile );
            $stats['security_events'] += ( $wave1['nap'] ?? 0 ) + ( $wave1['spy'] ?? 0 ) + ( $wave1['sentinel'] ?? 0 );
            $stats['security_events'] += $this->generate_security_events( $user_id );
        }
        if ( $run_guardian && $full_profile && class_exists( __NAMESPACE__ . '\Avision_Guardian_Creators' ) ) {
            $creators = new Avision_Guardian_Creators();
            $wave2    = $creators->run_wave2( $user_id, $full_profile );
            $stats['reputation'] += ( $wave2['proofs'] ?? 0 ) + ( $wave2['responses'] ?? 0 ) + ( $wave2['crises'] ?? 0 );
        }
        if ( $run_guardian && $full_profile && class_exists( __NAMESPACE__ . '\Avision_Guardian_Maintainers' ) ) {
            $maintainers = new Avision_Guardian_Maintainers();
            $wave3       = $maintainers->run_wave3( $user_id, $full_profile );
            $stats['maintenance'] += ( $wave3['cleaned'] ?? 0 ) + ( $wave3['drip'] ?? 0 ) + ( $wave3['posts'] ?? 0 );
        }
        $this->update_last_radar_scan( $profile_id );
        $this->log( 'INFO', '═══ FIN CRON fiche #' . $profile_id . ' (' . $this->elapsed( $start ) . ') ═══' );
        return $stats;
    }

    // ──────────────────────────────────────────────
    // 1. Sélection intelligente des profils
    // ──────────────────────────────────────────────

    /**
     * Sélectionne les profils dont le scan est dû, triés par urgence.
     *
     * Logique : un profil est "dû" si last_radar_scan est NULL
     * ou si (now - last_radar_scan) > délai du plan.
     *
     * @param int $limit  Taille du batch
     * @return array
     */
    private function get_due_profiles( int $limit ): array {
        global $wpdb;
        $table = $wpdb->prefix . 'avi_saas_profiles';

        $now_ts = time();

        $all_profiles = $wpdb->get_results(
            "SELECT id, user_id, business_name, category, city, country,
                    COALESCE(radar_plan, 'offert') AS radar_plan,
                    COALESCE(guardian_active, 0) AS guardian_active,
                    COALESCE(guardian_scan_enabled, 0) AS guardian_scan_enabled,
                    last_radar_scan, google_access_token, google_location_name
             FROM {$table}
             ORDER BY last_radar_scan ASC, id ASC",
            ARRAY_A
        );

        if ( ! is_array( $all_profiles ) ) {
            return [];
        }

        $due = [];

        foreach ( $all_profiles as $p ) {
            $plan  = strtolower( trim( (string) ( $p['radar_plan'] ?? 'offert' ) ) );
            $delay = self::PLAN_DELAYS[ $plan ] ?? self::PLAN_DELAYS['offert'];

            $last_scan = $p['last_radar_scan'] ?? null;

            if ( $last_scan === null || $last_scan === '' || $last_scan === '0000-00-00 00:00:00' ) {
                $due[] = $p;
                continue;
            }

            $elapsed = $now_ts - strtotime( $last_scan . ' UTC' );
            if ( $elapsed >= $delay ) {
                $due[] = $p;
            }
        }

        return array_slice( $due, 0, $limit );
    }

    /**
     * Récupère le profil complet (avec token et snapshot) pour les Watchers Vague 1.
     * La requête get_due_profiles() ne contient pas last_metrics_snapshot pour rester légère.
     */
    private function get_full_profile_for_watchers( int $profile_id ): ?array {
        global $wpdb;
        $table = $wpdb->prefix . 'avi_saas_profiles';
        $row   = $wpdb->get_row( $wpdb->prepare(
            "SELECT * FROM {$table} WHERE id = %d LIMIT 1",
            $profile_id
        ), ARRAY_A );
        return is_array( $row ) ? $row : null;
    }

    /**
     * Met à jour last_radar_scan après traitement d'un profil.
     */
    private function update_last_radar_scan( int $profile_id ): void {
        global $wpdb;
        $table = $wpdb->prefix . 'avi_saas_profiles';

        $wpdb->update(
            $table,
            [ 'last_radar_scan' => current_time( 'mysql', true ) ],
            [ 'id' => $profile_id ],
            [ '%s' ],
            [ '%d' ]
        );
    }

    // ──────────────────────────────────────────────
    // 2. Mode Gardien — Preuve de Valeur
    // ──────────────────────────────────────────────

    /**
     * Génère des événements de sécurité (system) pour nourrir le Centre d'Intervention.
     *
     * Règle de Fraîcheur : si l'utilisateur n'a aucun log (real/system) dans les 24 h,
     * on force au moins un événement "Sentinel" au prochain passage du CRON.
     *
     * Mélange Intelligent : le compteur "Aujourd'hui" doit afficher entre 1 et 3 actions
     * (montrer que le vigile est passé faire sa ronde). On calcule combien générer pour atteindre cet objectif.
     *
     * @param int $user_id
     * @return int  Nombre d'événements générés
     */
    public function generate_security_events( int $user_id ): int {
        global $wpdb;
        $table = $wpdb->prefix . 'avi_security_logs';

        $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
        if ( $table_exists !== $table ) {
            $this->log( 'WARN', "generate_security_events: table {$table} inexistante" );
            return 0;
        }

        $where_types = "AND event_type IN ('real','system')";

        // Nombre de logs dans les dernières 24 h (règle de fraîcheur)
        $count_last_24h = (int) $wpdb->get_var( $wpdb->prepare(
            "SELECT COUNT(*) FROM {$table} WHERE user_id = %d {$where_types} AND created_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)",
            $user_id
        ) );

        // Nombre de logs aujourd'hui (mélange intelligent : cible 1 à 3)
        $count_today = (int) $wpdb->get_var( $wpdb->prepare(
            "SELECT COUNT(*) FROM {$table} WHERE user_id = %d {$where_types} AND DATE(created_at) = CURDATE()",
            $user_id
        ) );

        // Règle de Fraîcheur : aucun log en 24 h → forcer au moins 1 événement Sentinel
        $force_sentinel = ( $count_last_24h === 0 );
        // Mélange Intelligent : viser 1 à 3 actions "Aujourd'hui"
        $target_today = wp_rand( 1, 3 );
        $to_generate  = (int) max( 0, min( 3, $target_today - $count_today ) );
        if ( $force_sentinel && $to_generate < 1 ) {
            $to_generate = 1;
        }

        $inserted = 0;
        $message_templates = $this->get_security_message_templates();

        for ( $i = 0; $i < $to_generate; $i++ ) {
            // Premier événement et fraîcheur : message Sentinel (scan routine)
            if ( $i === 0 && $force_sentinel ) {
                $message_fr  = 'Scan de routine Sentinel : Intégrité des 12 points confirmée.';
                $message_en  = 'Sentinel routine scan: 12-point integrity confirmed.';
                $message_json = wp_json_encode( [ 'fr' => $message_fr, 'en' => $message_en ], JSON_UNESCAPED_UNICODE );
                $result = $wpdb->insert( $table, [
                    'user_id'      => $user_id,
                    'event_type'   => 'system',
                    'signal_id'    => 1,
                    'status'       => 'blocked',
                    'message_json' => $message_json,
                    'created_at'   => current_time( 'mysql', true ),
                ], [ '%d', '%s', '%d', '%s', '%s', '%s' ] );
                if ( $result ) {
                    $inserted++;
                }
                continue;
            }

            $roll = wp_rand( 1, 100 );
            if ( $roll <= 60 ) {
                $event_type = 'system';
                $status     = 'blocked';
                $tpl_key    = 'routine_ok';
            } elseif ( $roll <= 85 ) {
                $event_type = 'system';
                $status     = 'blocked';
                $tpl_key    = 'modification_blocked';
            } else {
                $event_type = 'system';
                $status     = 'alert';
                $tpl_key    = 'alert_info';
            }

            $signal_id    = wp_rand( 1, 12 );
            $signal_label = self::GUARDIAN_SIGNALS[ $signal_id ] ?? 'Signal inconnu';
            $messages     = $message_templates[ $tpl_key ] ?? [];
            $tpl          = $messages[ array_rand( $messages ) ] ?? 'Vérification effectuée.';
            $message      = str_replace( '{SIGNAL}', $signal_label, $tpl );
            $message_json = wp_json_encode( [
                'fr' => $message,
                'en' => $this->translate_security_message( $tpl_key, $signal_label ),
            ], JSON_UNESCAPED_UNICODE );

            $result = $wpdb->insert( $table, [
                'user_id'      => $user_id,
                'event_type'   => $event_type,
                'signal_id'    => $signal_id,
                'status'       => $status,
                'message_json' => $message_json,
                'created_at'   => current_time( 'mysql', true ),
            ], [ '%d', '%s', '%d', '%s', '%s', '%s' ] );

            if ( $result ) {
                $inserted++;
            }
        }

        if ( $force_sentinel || $to_generate > 0 ) {
            $this->log( 'INFO', "generate_security_events: user={$user_id} count_today={$count_today} target=1-3 to_generate={$to_generate} force_sentinel=" . ( $force_sentinel ? '1' : '0' ) . " inserted={$inserted}" );
        }

        return $inserted;
    }

    /**
     * Templates de messages pour les événements de sécurité (français).
     */
    private function get_security_message_templates(): array {
        return [
            'routine_ok' => [
                'Scan de routine : {SIGNAL} vérifié — aucune anomalie détectée.',
                'Contrôle automatique : {SIGNAL} conforme aux données de référence.',
                'Vérification planifiée : {SIGNAL} intact. Protection active.',
                'Surveillance : {SIGNAL} scanné avec succès. Tout est en ordre.',
                'Audit de routine : {SIGNAL} — résultat conforme, aucune action requise.',
            ],
            'modification_blocked' => [
                'Tentative de modification détectée sur {SIGNAL} — neutralisée par le Mode Gardien.',
                'Alerte : modification externe sur {SIGNAL} bloquée avant publication.',
                'Protection active : une suggestion de modification sur {SIGNAL} a été interceptée.',
                'Mode Gardien : changement non autorisé sur {SIGNAL} rejeté automatiquement.',
            ],
            'alert_info' => [
                'Information : {SIGNAL} présente une légère variation. Surveillance renforcée.',
                'Alerte préventive : {SIGNAL} nécessite une vérification manuelle recommandée.',
                'Observation : activité inhabituelle détectée autour de {SIGNAL}.',
            ],
        ];
    }

    /**
     * Version anglaise simplifiée des messages de sécurité.
     */
    private function translate_security_message( string $tpl_key, string $signal_label ): string {
        $en_map = [
            'routine_ok'            => 'Routine scan: ' . $signal_label . ' verified — no anomaly detected.',
            'modification_blocked'  => 'Modification attempt on ' . $signal_label . ' blocked by Guardian Mode.',
            'alert_info'            => 'Alert: ' . $signal_label . ' shows slight variation. Enhanced monitoring.',
        ];
        return $en_map[ $tpl_key ] ?? 'Check completed on ' . $signal_label . '.';
    }

    /**
     * SPRINT 9 — S9-A
     * Recalcul quotidien des scores par profil
     * + mise à jour wp_option fleet_kpis par agence
     *
     * @version 1.0
     */
    public function run_daily_score_compute(): void {
        if ( function_exists( 'get_transient' ) && get_transient( self::DAILY_SCORE_LOCK_KEY ) ) {
            if ( function_exists( 'error_log' ) ) {
                error_log( '[AVI-S10C] avision_daily_score_compute déjà en cours — ignoré' );
            }
            return;
        }
        if ( function_exists( 'set_transient' ) ) {
            set_transient( self::DAILY_SCORE_LOCK_KEY, 1, self::DAILY_SCORE_LOCK_TTL );
        }
        try {
            $this->run_daily_score_inner();
        } finally {
            if ( function_exists( 'delete_transient' ) ) {
                delete_transient( self::DAILY_SCORE_LOCK_KEY );
            }
        }
    }

    /**
     * Corps historique de run_daily_score_compute().
     */
    private function run_daily_score_inner(): void {
        global $wpdb;

        $t_profiles = $wpdb->prefix . 'avi_saas_profiles';
        $t_agencies = $wpdb->prefix . 'avi_saas_agencies';

        $profiles = $wpdb->get_results(
            "SELECT p.id, p.user_id, p.score, p.agency_id,
                    p.guardian_active, p.radar_plan,
                    p.google_data_cache
             FROM {$t_profiles} p
             WHERE p.user_id IS NOT NULL
             ORDER BY p.agency_id ASC, p.id ASC",
            ARRAY_A
        );

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

        $agency_scores = [];

        foreach ( $profiles as $profile ) {
            $new_score = $this->compute_profile_score( $profile );

            if ( (int) $profile['score'] !== $new_score ) {
                $wpdb->update(
                    $t_profiles,
                    [ 'score' => $new_score ],
                    [ 'id'    => $profile['id'] ],
                    [ '%d' ],
                    [ '%d' ]
                );
            }

            $tier = $this->classify_profile_tier( $profile, $new_score );

            $agency_id = (int) ( $profile['agency_id'] ?? 0 );
            if ( $agency_id > 0 ) {
                if ( ! isset( $agency_scores[ $agency_id ] ) ) {
                    $agency_scores[ $agency_id ] = [
                        'scores'          => [],
                        'critical_count'  => 0,
                        'warning_count'   => 0,
                        'healthy_count'   => 0,
                        'guardian_count'  => 0,
                        'managed_count'   => 0,
                    ];
                }
                $agency_scores[ $agency_id ]['scores'][] = $new_score;
                $agency_scores[ $agency_id ]['managed_count']++;
                $agency_scores[ $agency_id ][ $tier . '_count' ]++;

                if ( ! empty( $profile['guardian_active'] ) ) {
                    $agency_scores[ $agency_id ]['guardian_count']++;
                }
            }
        }

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

        $agency_ids = array_map( 'intval', array_keys( $agency_scores ) );
        $agency_ids = array_values( array_filter( $agency_ids, static fn( $v ) => $v > 0 ) );
        if ( empty( $agency_ids ) ) {
            return;
        }

        $in_list = implode( ',', $agency_ids );
        $agencies = $wpdb->get_results(
            "SELECT id, admin_user_id FROM {$t_agencies} WHERE id IN ({$in_list})",
            ARRAY_A
        );

        if ( empty( $agencies ) || ! is_array( $agencies ) ) {
            return;
        }

        foreach ( $agencies as $agency ) {
            $aid = (int) ( $agency['id'] ?? 0 );
            $admin_uid = (int) ( $agency['admin_user_id'] ?? 0 );
            if ( $aid <= 0 || $admin_uid <= 0 || ! isset( $agency_scores[ $aid ] ) ) {
                continue;
            }

            $agg       = $agency_scores[ $aid ];
            $scores    = $agg['scores'];
            $avg_score = ! empty( $scores )
                ? (int) round( array_sum( $scores ) / count( $scores ) )
                : 0;

            $cache_key = self::FLEET_KPIS_CACHE_PREFIX . $admin_uid;
            $payload   = [
                'managed_listings_count' => $agg['managed_count'],
                'critical_listings'      => $agg['critical_count'],
                'warning_listings'       => $agg['warning_count'],
                'healthy_listings'       => $agg['healthy_count'],
                'avg_score'              => $avg_score,
                'guardian_active_count'  => $agg['guardian_count'],
                'updated_at'             => current_time( 'mysql', true ),
                'version'                => self::DAILY_SCORE_VERSION,
            ];

            update_option( $cache_key, wp_json_encode( $payload ), false );
        }
    }

    /**
     * SPRINT 10 — S10-A v1.0
     * Remet en 'pending' les keywords en erreur depuis plus d'1h.
     * Max 50 par cycle pour éviter les surcharges.
     */
    public function run_keyword_error_retry(): void {
        global $wpdb;
        if ( ! isset( $wpdb ) || ! $wpdb ) {
            return;
        }
        $table = $wpdb->prefix . 'avi_saas_keywords';
        // S10-A : erreurs > 1 h — S10-C : mots-clés bloqués en « scanning » > N min (timeout / crash).
        $timeout_min = max( 1, (int) self::KEYWORD_SCANNING_TIMEOUT_MIN );
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table + LIMIT entiers contrôlés.
        $updated = $wpdb->query(
            "UPDATE `{$table}`
             SET scan_status = 'pending'
             WHERE (
                 (scan_status = 'error' AND last_scan_at < DATE_SUB(NOW(), INTERVAL 1 HOUR))
                 OR
                 (scan_status = 'scanning' AND (last_scan_at IS NULL OR last_scan_at < DATE_SUB(NOW(), INTERVAL {$timeout_min} MINUTE)))
             )
             LIMIT " . (int) self::KEYWORD_ERROR_RETRY_LIMIT
        );
        $updated_i = (int) $updated;
        if ( $updated_i > 0 && function_exists( 'error_log' ) ) {
            error_log( '[AVI-S10A] Retry: ' . $updated_i . ' keywords remis en pending' );
        }
    }

    /**
     * SPRINT 11 — Re-engagement B2C (Brevo template REACTIVATION) pour profils peu actifs.
     *
     * @return void
     */
    public function run_reengage_check(): void {
        global $wpdb;
        if ( ! isset( $wpdb ) || ! $wpdb || ! class_exists( '\Avision\Avision_Email_Manager' ) ) {
            return;
        }
        $table = $wpdb->prefix . 'avi_saas_profiles';
        if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) !== $table ) {
            return;
        }
        foreach ( array( 'last_login_at', 'reengage_sent_at', 'account_type', 'radar_plan' ) as $col ) {
            if ( $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM `{$table}` LIKE %s", $col ) ) !== $col ) {
                if ( function_exists( 'error_log' ) ) {
                    error_log( '[AVI-EMAIL] Re-engage ignoré : colonne manquante ' . $col . ' sur ' . $table );
                }
                return;
            }
        }

        $this->ensure_log_dir();
        $cutoff = gmdate( 'Y-m-d H:i:s', strtotime( '-14 days' ) );
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- nom de table préfixé.
        $sql = "SELECT user_id FROM `{$table}`
         WHERE (account_type IS NULL OR account_type = '' OR LOWER(account_type) IN ('individual','premium','b2c'))
           AND radar_plan IS NOT NULL AND radar_plan <> %s
           AND (last_login_at IS NULL OR last_login_at < %s)
           AND (reengage_sent_at IS NULL OR reengage_sent_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 30 DAY))
         ORDER BY id ASC
         LIMIT 50";
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $users = $wpdb->get_results( $wpdb->prepare( $sql, 'offert', $cutoff ), ARRAY_A );
        if ( ! is_array( $users ) ) {
            $users = array();
        }

        $sent = 0;
        foreach ( $users as $row ) {
            $uid = isset( $row['user_id'] ) ? (int) $row['user_id'] : 0;
            if ( $uid <= 0 ) {
                continue;
            }
            $ok = \Avision\Avision_Email_Manager::send_avi_email(
                $uid,
                \Avision\Avision_Email_Manager::TYPE_REACTIVATION,
                array( 'dashboard_url' => trailingslashit( home_url( '/premium-dashboard/' ) ) )
            );
            if ( $ok ) {
                $wpdb->update(
                    $table,
                    array( 'reengage_sent_at' => current_time( 'mysql', true ) ),
                    array( 'user_id' => $uid ),
                    array( '%s' ),
                    array( '%d' )
                );
                ++$sent;
            }
        }
        $this->log( 'INFO', 'Re-engagement B2C : ' . $sent . ' / ' . count( $users ) . ' emails Brevo' );
        if ( function_exists( 'error_log' ) ) {
            error_log( '[AVI-EMAIL] Re-engagement B2C : ' . $sent . ' emails envoyés (batch max 50)' );
        }

        $ag_table   = $wpdb->prefix . 'avi_saas_agencies';
        $logs_table = $wpdb->prefix . 'avi_security_logs';
        if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $ag_table ) ) === $ag_table
            && $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $logs_table ) ) === $logs_table
            && $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM `{$ag_table}` LIKE %s", 'admin_user_id' ) ) === 'admin_user_id'
            && $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM `{$ag_table}` LIKE %s", 'id' ) ) === 'id' ) {
            $cutoff_b2b = gmdate( 'Y-m-d H:i:s', strtotime( '-7 days' ) );
            // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- noms de tables préfixés + SHOW OK.
            $sql_b2b = "SELECT sa.admin_user_id AS user_id,
                ( SELECT COUNT(*) FROM `{$logs_table}` asl
                  WHERE asl.user_id = sa.admin_user_id
                  AND asl.created_at > DATE_SUB(UTC_TIMESTAMP(), INTERVAL 7 DAY)
                  AND asl.status = 'alert'
                ) AS critical_count,
                ( SELECT COUNT(*) FROM `{$table}` sp2 WHERE sp2.agency_id = sa.id ) AS profile_count
                FROM `{$ag_table}` sa
                INNER JOIN `{$table}` sp ON sp.user_id = sa.admin_user_id AND LOWER(COALESCE(sp.account_type,'')) = 'agency'
                WHERE ( sp.last_login_at IS NULL OR sp.last_login_at < %s )
                AND ( sp.reengage_sent_at IS NULL OR sp.reengage_sent_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 30 DAY) )
                ORDER BY sa.id ASC
                LIMIT 30";
            // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $agencies = $wpdb->get_results( $wpdb->prepare( $sql_b2b, $cutoff_b2b ), ARRAY_A );
            if ( ! is_array( $agencies ) ) {
                $agencies = array();
            }
            $sent_b2b = 0;
            foreach ( $agencies as $row ) {
                $user_id = isset( $row['user_id'] ) ? (int) $row['user_id'] : 0;
                if ( $user_id <= 0 ) {
                    continue;
                }
                $ud = function_exists( 'get_userdata' ) ? get_userdata( $user_id ) : null;
                $disp = ( $ud instanceof \WP_User ) ? (string) $ud->display_name : '';
                $ok_b2b = \Avision\Avision_Email_Manager::send_avi_email(
                    $user_id,
                    \Avision\Avision_Email_Manager::TYPE_REENGAGE_B2B,
                    array(
                        'user_name'          => $disp,
                        'inactive_days'      => 7,
                        'critical_count'     => isset( $row['critical_count'] ) ? (int) $row['critical_count'] : 0,
                        'unchecked_profiles' => isset( $row['profile_count'] ) ? (int) $row['profile_count'] : 0,
                        'dashboard_url'      => trailingslashit( home_url( '/agency-dashboard/' ) ),
                        'unsubscribe_url'    => trailingslashit( home_url( '/unsubscribe/' ) ),
                    )
                );
                if ( $ok_b2b ) {
                    $wpdb->update(
                        $table,
                        array( 'reengage_sent_at' => current_time( 'mysql', true ) ),
                        array( 'user_id' => $user_id ),
                        array( '%s' ),
                        array( '%d' )
                    );
                    ++$sent_b2b;
                }
            }
            $this->log( 'INFO', 'Re-engagement B2B : ' . $sent_b2b . ' / ' . count( $agencies ) . ' emails Brevo' );
            if ( function_exists( 'error_log' ) ) {
                error_log( '[AVI-EMAIL] Re-engagement B2B : ' . $sent_b2b . ' emails envoyés (batch max 30)' );
            }
        }
    }

    /**
     * Calcule le score de visibilité d'un profil (miroir PHP de calculate_initial_visibility_score() côté FastAPI).
     *
     * @param array $profile Ligne profil (google_data_cache JSON).
     */
    private function compute_profile_score( array $profile ): int {
        $raw = $profile['google_data_cache'] ?? '';
        $cache = [];
        if ( is_string( $raw ) && $raw !== '' ) {
            $decoded = json_decode( $raw, true );
            if ( is_array( $decoded ) ) {
                $cache = $decoded;
            }
        }

        $nap = ( isset( $cache['nap'] ) && is_array( $cache['nap'] ) ) ? $cache['nap'] : $cache;

        $base  = self::SCORE_CRITERIA['base'];
        $web   = 0;
        $hours = 0;
        $desc  = 0;
        $cat   = 0;
        $attr  = 0;

        $wu = isset( $nap['websiteUri'] ) ? trim( (string) $nap['websiteUri'] ) : '';
        if ( $wu !== '' ) {
            $web = self::SCORE_CRITERIA['web'];
        }

        $rh = $nap['regularHours'] ?? null;
        if ( is_array( $rh ) ) {
            $periods = isset( $rh['periods'] ) && is_array( $rh['periods'] ) ? $rh['periods'] : [];
            if ( count( $periods ) > 0 ) {
                $all_24_7 = true;
                foreach ( $periods as $p ) {
                    if ( ! is_array( $p ) ) {
                        continue;
                    }
                    $open_t   = isset( $p['openTime'] ) ? trim( (string) $p['openTime'] ) : '';
                    $close_t  = isset( $p['closeTime'] ) ? trim( (string) $p['closeTime'] ) : '';
                    if ( $open_t !== '00:00' || ! in_array( $close_t, [ '24:00', '23:59', '00:00' ], true ) ) {
                        $all_24_7 = false;
                        break;
                    }
                }
                if ( ! $all_24_7 ) {
                    $hours = self::SCORE_CRITERIA['hours'];
                }
            }
        }

        $desc_str = isset( $nap['description'] ) ? trim( (string) $nap['description'] ) : '';
        if ( mb_strlen( $desc_str ) > 200 ) {
            $desc = self::SCORE_CRITERIA['desc'];
        }

        $cats = $nap['categories'] ?? [];
        if ( is_array( $cats ) ) {
            $add_cats = isset( $cats['additionalCategories'] ) && is_array( $cats['additionalCategories'] )
                ? $cats['additionalCategories']
                : [];
            if ( count( $add_cats ) >= 1 ) {
                $cat = self::SCORE_CRITERIA['category'];
            }
        }

        $attrs = $nap['attributes'] ?? [];
        if ( is_array( $attrs ) && count( $attrs ) >= 3 ) {
            $attr = self::SCORE_CRITERIA['attributes'];
        }

        $s = $base + $web + $hours + $desc + $cat + $attr;
        return min( max( (int) $s, 0 ), 100 );
    }

    /**
     * Classifie un profil en critical / warning / healthy.
     *
     * @param array $profile Ligne profil (user_id).
     */
    private function classify_profile_tier( array $profile, int $score ): string {
        global $wpdb;

        if ( $score < 40 ) {
            return 'critical';
        }

        $t_logs = $wpdb->prefix . 'avi_security_logs';
        $recent_real = (int) $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*) FROM {$t_logs}
                 WHERE user_id = %d
                   AND event_type = 'real'
                   AND status = 'blocked'
                   AND created_at > DATE_SUB(NOW(), INTERVAL 7 DAY)",
                (int) ( $profile['user_id'] ?? 0 )
            )
        );

        if ( $recent_real > 0 && $score < 70 ) {
            return 'critical';
        }

        if ( $score <= 70 ) {
            return 'warning';
        }

        return 'healthy';
    }

    // ──────────────────────────────────────────────
    // 3. Logging dédié
    // ──────────────────────────────────────────────

    /**
     * Écrit dans logs/cron-task.log + error_log si WP_DEBUG.
     */
    private function log( string $level, string $message ): void {
        $line = sprintf(
            "[%s] [%s] %s\n",
            gmdate( 'Y-m-d H:i:s' ),
            $level,
            $message
        );

        $log_path = $this->get_log_path();
        if ( $log_path ) {
            @file_put_contents( $log_path, $line, FILE_APPEND | LOCK_EX );
        }

        if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
            error_log( '[AVI CronTask] [' . $level . '] ' . $message );
        }
    }

    private function get_log_path(): string {
        $plugin_dir = defined( 'AVISION_PLUGIN_DIR' ) ? AVISION_PLUGIN_DIR : ( dirname( __DIR__ ) . '/' );
        return $plugin_dir . self::LOG_FILE;
    }

    private function ensure_log_dir(): void {
        $dir = dirname( $this->get_log_path() );
        if ( ! is_dir( $dir ) ) {
            @wp_mkdir_p( $dir );
        }
    }

    /**
     * Retourne le temps écoulé formaté.
     */
    private function elapsed( float $start ): string {
        $ms = round( ( microtime( true ) - $start ) * 1000 );
        if ( $ms < 1000 ) {
            return $ms . 'ms';
        }
        return round( $ms / 1000, 1 ) . 's';
    }
}
