<?php
/**
 * Module Radar Heatmap pour le Dashboard Premium.
 *
 * @package Avision
 */

namespace Avision {

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

/**
 * Rend le conteneur Heatmap rattache fonctionnellement au Growth Radar.
 *
 * Ce module utilise les mots-cles deja suivis par Growth Radar et suit sa logique
 * de frequence. Le fichier reprend seulement la convention de rangement Guardian
 * (`includes/class-*.php`, namespace Avision) pour rester coherent avec le plugin.
 * Il ne depend pas de Guardian.
 */
class Avision_Radar_Heatmap {
	private static bool $styles_rendered = false;
	private const ALLOWED_GRID_SIZES = array( 3, 5, 7, 9, 11, 13, 15, 21 );
	private const DEFAULT_GRID_SIZE   = 7;
	private const DEFAULT_RADIUS_KM   = 5.0;

	/**
	 * Enregistre les hooks WordPress du module Heatmap.
	 *
	 * @return void
	 */
	public static function init(): void {
		add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
	}

	/**
	 * Charge Leaflet uniquement pour les utilisateurs ayant accès au module.
	 *
	 * @return void
	 */
	public static function enqueue_assets(): void {
		if ( ! self::can_render_for_current_user() ) {
			return;
		}

		wp_enqueue_style(
			'avi-radar-heatmap-css',
			plugins_url( '../assets/css/avi-radar-heatmap.css', __FILE__ ),
			array(),
			defined( 'AVISION_VERSION' ) ? AVISION_VERSION : null
		);

		wp_enqueue_style(
			'avi-heatmap-leaflet',
			'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css',
			array(),
			'1.9.4'
		);

		wp_enqueue_script(
			'avi-heatmap-leaflet',
			'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js',
			array(),
			'1.9.4',
			true
		);

		wp_enqueue_script(
			'avi-heatmap-xlsx',
			'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js',
			array(),
			'0.18.5',
			true
		);

		wp_enqueue_script(
			'avi-heatmap-jspdf',
			'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js',
			array(),
			'2.5.1',
			true
		);

		wp_enqueue_script(
			'avi-heatmap-jspdf-autotable',
			'https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js',
			array( 'avi-heatmap-jspdf' ),
			'3.8.2',
			true
		);

		wp_enqueue_script(
			'avi-radar-heatmap-js',
			plugins_url( '../assets/js/avi-radar-heatmap.js', __FILE__ ),
			array( 'avi-heatmap-leaflet' ),
			defined( 'AVISION_VERSION' ) ? AVISION_VERSION : null,
			true
		);
	}

	/**
	 * Retourne le HTML complet du module, ou une chaine vide si non visible.
	 *
	 * @param int $profile_id ID avi_saas_profiles.
	 * @return string
	 */
	public static function render( int $profile_id ): string {
		$profile_id = absint( $profile_id );
		if ( $profile_id <= 0 || ! self::can_render_for_current_user() ) {
			return '';
		}

		self::enqueue_assets();

		$keywords = self::get_radar_keywords( $profile_id );
		$first_keyword = '';
		if ( ! empty( $keywords[0]['keyword'] ) ) {
			$first_keyword = trim( (string) $keywords[0]['keyword'] );
		}

		$latest_scan    = $first_keyword !== '' ? self::get_latest_heatmap_scan( $profile_id, $first_keyword ) : null;
		$credit_summary = self::get_credit_summary( $profile_id );
		$center         = self::get_heatmap_center( $profile_id, $latest_scan );

		return self::render_shell( $profile_id, $keywords, $latest_scan, $credit_summary, $center );
	}

	/**
	 * Rendu heatmap sans gate visibilité — mode embed iframe (auth HMAC en amont).
	 * #Sprint44-Plugin
	 *
	 * @param int    $profile_id    ID avi_saas_profiles.
	 * @param string $embed_token   Token REST embed (mode iframe).
	 * @param string $embed_key_id  key_id string embed (LocalEdge).
	 * @return string
	 */
	public static function render_embed( int $profile_id, string $embed_token = '', string $embed_key_id = '', string $embed_lang = '' ): string {
		$profile_id = absint( $profile_id );
		if ( $profile_id <= 0 ) {
			return '';
		}

		if ( ! empty( $embed_key_id ) ) {
			$keywords = self::get_embed_keywords_for_shell( $embed_key_id, $profile_id );
		} else {
			$keywords = self::get_radar_keywords( $profile_id );
		}
		$first_keyword = '';
		if ( ! empty( $keywords[0]['keyword'] ) ) {
			$first_keyword = trim( (string) $keywords[0]['keyword'] );
		}

		$latest_scan    = $first_keyword !== '' ? self::get_latest_heatmap_scan( $profile_id, $first_keyword ) : null;
		$credit_summary = self::get_credit_summary( $profile_id );
		$center         = self::get_heatmap_center( $profile_id, $latest_scan );

		return self::render_shell( $profile_id, $keywords, $latest_scan, $credit_summary, $center, $embed_token, $embed_lang );
	}

	/**
	 * Gate de visibilite : admin, test users, ou option globale active.
	 *
	 * @return bool
	 */
	private static function can_render_for_current_user(): bool {
		if ( function_exists( 'current_user_can' ) && current_user_can( 'manage_options' ) ) {
			return true;
		}

		$user_id = function_exists( 'get_current_user_id' ) ? absint( get_current_user_id() ) : 0;
		if ( $user_id > 0 && in_array( $user_id, self::get_test_user_ids(), true ) ) {
			return true;
		}

		return self::is_module_enabled();
	}

	/**
	 * Liste des comptes WordPress autorises pendant le rollout prive.
	 *
	 * Option supportee :
	 * - array d'IDs
	 * - chaine "12,34,56"
	 *
	 * @return int[]
	 */
	public static function get_test_user_ids(): array {
		$raw = function_exists( 'get_option' ) ? get_option( 'avi_heatmap_test_user_ids', array() ) : array();

		if ( is_string( $raw ) ) {
			$raw = preg_split( '/[,\s]+/', $raw, -1, PREG_SPLIT_NO_EMPTY );
		}

		if ( ! is_array( $raw ) ) {
			return array();
		}

		$ids = array_map( 'absint', $raw );
		$ids = array_filter(
			$ids,
			static function ( int $id ): bool {
				return $id > 0;
			}
		);

		return array_values( array_unique( $ids ) );
	}

	/**
	 * Active le module pour tous les profils si l'option vaut "1".
	 *
	 * @return bool
	 */
	private static function is_module_enabled(): bool {
		if ( ! function_exists( 'get_option' ) ) {
			return false;
		}

		return (string) get_option( 'avi_heatmap_module_enabled', '0' ) === '1';
	}

	/**
	 * Recupere les mots-cles deja trackes par Growth Radar pour un profil.
	 *
	 * Ne cree aucun mot-cle : la Heatmap integree visualise uniquement le stock Radar.
	 *
	 * @param int $profile_id ID avi_saas_profiles.
	 * @return array<int,array<string,mixed>>
	 */
	private static function get_radar_keywords( int $profile_id ): array {
		global $wpdb;

		$profile_id = absint( $profile_id );
		if ( $profile_id <= 0 || ! isset( $wpdb ) || ! $wpdb ) {
			return array();
		}

		$table = self::resolve_table_name( 'avi_saas_keywords' );
		if ( $table === '' ) {
			return array();
		}

		$sql = $wpdb->prepare(
			"SELECT id, keyword, target_city, COALESCE(rank_init, previous_rank, 0) AS rank_init, current_rank, is_strategic, scan_status
			FROM `{$table}`
			WHERE profile_id = %d AND keyword <> ''
			ORDER BY is_strategic DESC, id ASC
			LIMIT 100",
			$profile_id
		);

		$rows = $wpdb->get_results( $sql, ARRAY_A );
		if ( is_array( $rows ) ) {
			return $rows;
		}

		return array();
	}

	/**
	 * Keywords embed pour le shell heatmap (colonne 1 drawer + modal).
	 * #SprintL2-L2.2-col1-embed-kw
	 *
	 * @param string $key_id     key_id public (avi_...).
	 * @param int    $profile_id ID profil SaaS.
	 * @return array<int,array<string,mixed>>
	 */
	private static function get_embed_keywords_for_shell( string $key_id, int $profile_id ): array {
		global $wpdb;

		$key_id     = sanitize_text_field( $key_id );
		$profile_id = absint( $profile_id );
		if ( $key_id === '' || $profile_id <= 0 || ! isset( $wpdb ) || ! $wpdb ) {
			return array();
		}

		$ekid = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT id FROM `{$wpdb->prefix}avi_embed_keys`
				 WHERE key_id = %s AND status = 'active'",
				$key_id
			)
		);
		if ( ! $ekid ) {
			return array();
		}

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT keyword, current_rank, is_strategic, scan_status
				 FROM `{$wpdb->prefix}avi_embed_keywords`
				 WHERE embed_key_id = %d AND profile_id = %d
				 AND keyword <> ''
				 ORDER BY is_strategic DESC, id ASC
				 LIMIT 100",
				$ekid,
				$profile_id
			),
			ARRAY_A
		);

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

		$out = array();
		foreach ( $rows as $row ) {
			$out[] = array(
				'keyword'      => isset( $row['keyword'] ) ? (string) $row['keyword'] : '',
				'current_rank' => isset( $row['current_rank'] ) ? (int) $row['current_rank'] : 0,
				'rank_init'    => 0,
				'is_strategic' => isset( $row['is_strategic'] ) ? (int) $row['is_strategic'] : 0,
				'scan_status'  => isset( $row['scan_status'] ) ? (string) $row['scan_status'] : 'pending',
			);
		}

		return $out;
	}

	/**
	 * Recupere le dernier scan Heatmap disponible pour un mot-cle.
	 *
	 * @param int    $profile_id ID avi_saas_profiles.
	 * @param string $keyword Mot-cle Radar.
	 * @return array<string,mixed>|null
	 */
	private static function get_latest_heatmap_scan( int $profile_id, string $keyword ): ?array {
		global $wpdb;

		$profile_id = absint( $profile_id );
		$keyword    = trim( $keyword );
		if ( $profile_id <= 0 || $keyword === '' || ! isset( $wpdb ) || ! $wpdb ) {
			return null;
		}

		$table = self::resolve_table_name( 'avi_heatmap_results' );
		if ( $table === '' ) {
			return null;
		}

		$row = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT *
				FROM `{$table}`
				WHERE profile_id = %d AND keyword = %s
				ORDER BY scan_date DESC, id DESC
				LIMIT 1",
				$profile_id,
				$keyword
			),
			ARRAY_A
		);

		return is_array( $row ) ? $row : null;
	}

	/**
	 * Recupere le solde Heatmap actuel, en respectant les comptes profil/agence.
	 *
	 * @param int $profile_id ID avi_saas_profiles.
	 * @return array<string,mixed>
	 */
	private static function get_credit_summary( int $profile_id ): array {
		global $wpdb;

		$empty = array(
			'available'        => 0,
			'credits_total'    => 0,
			'credits_bonus'    => 0,
			'credits_used'     => 0,
			'credits_refunded' => 0,
			'owner_type'       => 'profile',
			'missing'          => true,
		);

		$profile_id = absint( $profile_id );
		if ( $profile_id <= 0 || ! isset( $wpdb ) || ! $wpdb ) {
			return $empty;
		}

		$table = self::resolve_table_name( 'avi_heatmap_credits' );
		if ( $table === '' ) {
			return $empty;
		}

		$columns   = self::get_table_columns( $table );
		$agency_id = self::get_profile_agency_id( $profile_id );
		$row       = null;

		if ( in_array( 'owner_type', $columns, true ) ) {
			if ( $agency_id > 0 && in_array( 'agency_id', $columns, true ) ) {
				$row = $wpdb->get_row(
					$wpdb->prepare(
						"SELECT * FROM `{$table}` WHERE owner_type = %s AND agency_id = %d LIMIT 1",
						'agency',
						$agency_id
					),
					ARRAY_A
				);
			}

			if ( ! is_array( $row ) ) {
				$row = $wpdb->get_row(
					$wpdb->prepare(
						"SELECT * FROM `{$table}` WHERE owner_type = %s AND profile_id = %d LIMIT 1",
						'profile',
						$profile_id
					),
					ARRAY_A
				);
			}
		} elseif ( in_array( 'profile_id', $columns, true ) ) {
			$row = $wpdb->get_row(
				$wpdb->prepare(
					"SELECT * FROM `{$table}` WHERE profile_id = %d LIMIT 1",
					$profile_id
				),
				ARRAY_A
			);
		}

		if ( ! is_array( $row ) ) {
			return $empty;
		}

		$total    = isset( $row['credits_total'] ) ? max( 0, (int) $row['credits_total'] ) : 0;
		$bonus    = isset( $row['credits_bonus'] ) ? max( 0, (int) $row['credits_bonus'] ) : 0;
		$used     = isset( $row['credits_used'] ) ? max( 0, (int) $row['credits_used'] ) : 0;
		$refunded = isset( $row['credits_refunded'] ) ? max( 0, (int) $row['credits_refunded'] ) : 0;

		return array(
			'available'        => max( 0, $total + $bonus - $used + $refunded ),
			'credits_total'    => $total,
			'credits_bonus'    => $bonus,
			'credits_used'     => $used,
			'credits_refunded' => $refunded,
			'owner_type'       => isset( $row['owner_type'] ) && in_array( (string) $row['owner_type'], array( 'profile', 'agency' ), true ) ? (string) $row['owner_type'] : 'profile',
			'missing'          => false,
		);
	}

	/**
	 * Recupere le centre de scan depuis le dernier scan, puis depuis le profil.
	 *
	 * @param int                       $profile_id ID avi_saas_profiles.
	 * @param array<string,mixed>|null $scan Dernier scan.
	 * @return array{lat:float,lng:float}|null
	 */
	private static function get_heatmap_center( int $profile_id, ?array $scan ): ?array {
		global $wpdb;

		$profile_id = absint( $profile_id );
		if ( $profile_id > 0 && isset( $wpdb ) && $wpdb ) {
			// #Sprint44-C5 — Priorité 0 : coordonnées exactes GBP
			$gbp_coords = $wpdb->get_row( $wpdb->prepare(
				"SELECT gbp_lat, gbp_lng
				 FROM `{$wpdb->prefix}avi_saas_profiles`
				 WHERE id = %d
				   AND gbp_lat IS NOT NULL
				   AND gbp_lng IS NOT NULL
				 LIMIT 1",
				$profile_id
			) );
			if ( $gbp_coords && $gbp_coords->gbp_lat && $gbp_coords->gbp_lng ) {
				return array(
					'lat'    => (float) $gbp_coords->gbp_lat,
					'lng'    => (float) $gbp_coords->gbp_lng,
					'source' => 'gbp_exact',
				);
			}
		}

		if ( is_array( $scan ) && isset( $scan['center_lat'], $scan['center_lng'] ) ) {
			$lat = is_numeric( $scan['center_lat'] ) ? (float) $scan['center_lat'] : null;
			$lng = is_numeric( $scan['center_lng'] ) ? (float) $scan['center_lng'] : null;
			if ( self::is_valid_lat_lng( $lat, $lng ) ) {
				return array( 'lat' => (float) $lat, 'lng' => (float) $lng );
			}
		}

		return self::get_profile_center( $profile_id );
	}

	/**
	 * Tente de trouver latitude/longitude dans avi_saas_profiles sans imposer un nom de colonne unique.
	 *
	 * @param int $profile_id ID avi_saas_profiles.
	 * @return array{lat:float,lng:float}|null
	 */
	private static function get_profile_center( int $profile_id ): ?array {
		global $wpdb;

		$profile_id = absint( $profile_id );
		if ( $profile_id <= 0 || ! isset( $wpdb ) || ! $wpdb ) {
			return null;
		}

		$table = self::resolve_table_name( 'avi_saas_profiles' );
		if ( $table === '' ) {
			return null;
		}

		$columns = self::get_table_columns( $table );
		$pairs   = array(
			array( 'center_lat', 'center_lng' ),
			array( 'latitude', 'longitude' ),
			array( 'lat', 'lng' ),
			array( 'business_lat', 'business_lng' ),
			array( 'google_lat', 'google_lng' ),
			array( 'location_lat', 'location_lng' ),
			array( 'gbp_lat', 'gbp_lng' ),
		);

		foreach ( $pairs as $pair ) {
			if ( in_array( $pair[0], $columns, true ) && in_array( $pair[1], $columns, true ) ) {
				$row = $wpdb->get_row(
					$wpdb->prepare(
						"SELECT `{$pair[0]}` AS lat, `{$pair[1]}` AS lng FROM `{$table}` WHERE id = %d LIMIT 1",
						$profile_id
					),
					ARRAY_A
				);
				$lat = is_array( $row ) && isset( $row['lat'] ) && is_numeric( $row['lat'] ) ? (float) $row['lat'] : null;
				$lng = is_array( $row ) && isset( $row['lng'] ) && is_numeric( $row['lng'] ) ? (float) $row['lng'] : null;
				if ( self::is_valid_lat_lng( $lat, $lng ) ) {
					return array( 'lat' => (float) $lat, 'lng' => (float) $lng );
				}
			}
		}

		if ( in_array( 'google_data_cache', $columns, true ) ) {
			$raw = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT google_data_cache FROM `{$table}` WHERE id = %d LIMIT 1",
					$profile_id
				)
			);
			$center = self::extract_center_from_json( is_string( $raw ) ? $raw : '' );
			if ( is_array( $center ) ) {
				return $center;
			}
		}

		return null;
	}

	/**
	 * Recupere l'agence liee au profil si la colonne existe.
	 *
	 * @param int $profile_id ID avi_saas_profiles.
	 * @return int
	 */
	private static function get_profile_agency_id( int $profile_id ): int {
		global $wpdb;

		$profile_id = absint( $profile_id );
		if ( $profile_id <= 0 || ! isset( $wpdb ) || ! $wpdb ) {
			return 0;
		}

		$table = self::resolve_table_name( 'avi_saas_profiles' );
		if ( $table === '' || ! in_array( 'agency_id', self::get_table_columns( $table ), true ) ) {
			return 0;
		}

		return absint(
			$wpdb->get_var(
				$wpdb->prepare(
					"SELECT agency_id FROM `{$table}` WHERE id = %d LIMIT 1",
					$profile_id
				)
			)
		);
	}

	/**
	 * Rend les 3 niveaux : ferme, ouvert, modal.
	 *
	 * @param int                              $profile_id ID avi_saas_profiles.
	 * @param array<int,array<string,mixed>>  $keywords Mots-cles Radar.
	 * @param array<string,mixed>|null        $scan Dernier scan.
	 * @param array<string,mixed>             $credits Solde credits Heatmap.
	 * @param array{lat:float,lng:float}|null $center Centre du scan.
	 * @param string                          $embed_token Token REST embed (mode iframe).
	 * @return string
	 */
	private static function render_shell( int $profile_id, array $keywords, ?array $scan, array $credits, ?array $center, string $embed_token = '', string $embed_lang = '' ): string {
		$profile_id    = absint( $profile_id );
		$module_id     = 'avi-radar-heatmap-' . $profile_id;
		$drawer_id     = $module_id . '-drawer';
		$modal_id      = $module_id . '-modal';
		$keyword_count = count( $keywords );
		$status        = is_array( $scan ) && ! empty( $scan['status'] ) ? (string) $scan['status'] : 'not_ready';
		$keyword       = is_array( $scan ) && ! empty( $scan['keyword'] ) ? (string) $scan['keyword'] : ( ! empty( $keywords[0]['keyword'] ) ? (string) $keywords[0]['keyword'] : '' );
		$territory_pct = self::format_metric( $scan['territory_pct'] ?? null, '%' );
		$avg_rank      = self::format_metric( $scan['avg_rank'] ?? null, '' );
		$scan_date     = is_array( $scan ) && ! empty( $scan['scan_date'] ) ? (string) $scan['scan_date'] : '';
		$grid_points   = self::summarize_grid_points( $scan );
		$credit_balance = isset( $credits['available'] ) ? max( 0, (int) $credits['available'] ) : 0;
		$initial_grid_size = self::DEFAULT_GRID_SIZE;
		$initial_cost      = $initial_grid_size * $initial_grid_size;
		$has_center        = is_array( $center ) && self::is_valid_lat_lng( $center['lat'] ?? null, $center['lng'] ?? null );
		$can_scan          = $keyword !== '' && $has_center && $credit_balance >= $initial_cost;
		$allowed_langs_hm  = array( 'en', 'fr', 'es', 'de', 'pt-br', 'tr' );
		if ( $embed_lang !== '' && in_array( $embed_lang, $allowed_langs_hm, true ) ) {
			$lang = $embed_lang;
		} else {
			$raw_lang = substr( (string) get_locale(), 0, 2 );
			$lang     = in_array( $raw_lang, array( 'en', 'fr', 'es', 'de', 'tr' ), true ) ? $raw_lang : ( $raw_lang === 'pt' ? 'pt-br' : 'en' );
		}
		$t                 = array(
			'concurrents'   => array( 'en' => 'Competitors:', 'fr' => 'Concurrents :', 'es' => 'Competidores:' ),
			'solde'         => array( 'en' => 'Balance', 'fr' => 'Solde', 'es' => 'Saldo' ),
			'multi_credits' => array( 'en' => '1 keyword = 1x credits', 'fr' => '1 mot-clé = 1x crédits', 'es' => '1 palabra clave = 1x créditos' ),
			'grille_drawer' => array( 'en' => 'drawer grid', 'fr' => 'grille drawer', 'es' => 'cuadrícula drawer' ),
			'lancer_scan'   => array( 'en' => 'Launch scan', 'fr' => 'Lancer le scan', 'es' => 'Lanzar escaneo' ),
			'rechercher'    => array( 'en' => 'Search...', 'fr' => 'Rechercher...', 'es' => 'Buscar...', 'de' => 'Suchen...', 'pt-br' => 'Buscar...', 'tr' => 'Ara...' ),
			'voir_plus'        => array( 'en' => 'See more', 'fr' => 'Voir plus', 'es' => 'Ver más' ),
			'heatmap_subtitle' => array( 'en' => 'Local dominance map', 'fr' => 'Carte de domination locale', 'es' => 'Mapa de dominación local' ),
			'kw_radar_label'   => array( 'en' => 'Radar keywords', 'fr' => 'mots-clés Radar', 'es' => 'palabras clave Radar', 'de' => 'Radar-Keywords', 'pt-br' => 'palavras-chave Radar', 'tr' => 'Radar anahtar kelimeleri' ),
			'kw_panel_title'   => array( 'en' => 'Radar Keywords', 'fr' => 'Mots-clés Radar', 'es' => 'Palabras clave Radar', 'de' => 'Radar-Keywords', 'pt-br' => 'Palavras-chave Radar', 'tr' => 'Radar Anahtar Kelimeleri' ),
			'suivis'           => array( 'en' => 'tracked', 'fr' => 'suivis', 'es' => 'seguidos' ),
			'open_map_btn'     => array( 'en' => 'Open Map', 'fr' => 'Ouvrir la carte', 'es' => 'Abrir el mapa' ),
			'open_map_no_scan' => array( 'en' => 'Launch a scan first', 'fr' => 'Lancez un scan d\'abord', 'es' => 'Lanza un escaneo primero' ),
			'vue_heatmap'      => array( 'en' => 'Heatmap View', 'fr' => 'Vue Heatmap', 'es' => 'Vista Heatmap' ),
			'design_grid'      => array( 'en' => 'Grid', 'fr' => 'Grille', 'es' => 'Cuadrícula' ),
			'design_points'    => array( 'en' => 'Points', 'fr' => 'Points', 'es' => 'Puntos' ),
			'design_territory' => array( 'en' => 'Territory', 'fr' => 'Territoire', 'es' => 'Territorio' ),
			'territoire_top3'  => array( 'en' => 'Top 3 Territory', 'fr' => 'Territoire Top 3', 'es' => 'Territorio Top 3' ),
			'rang_moyen'       => array( 'en' => 'Average Rank', 'fr' => 'Rang moyen', 'es' => 'Rango promedio' ),
			'dernier_statut'   => array( 'en' => 'Last status', 'fr' => 'Dernier statut', 'es' => 'Último estado' ),
			'nouveau_scan'     => array( 'en' => 'New scan', 'fr' => 'Nouveau scan', 'es' => 'Nuevo escaneo', 'de' => 'Neuer Scan', 'pt-br' => 'Novo scan', 'tr' => 'Yeni tarama' ),
			'credits_dispo'    => array( 'en' => 'credits available', 'fr' => 'crédits disponibles', 'es' => 'créditos disponibles' ),
			'confirm_ok'       => array( 'en' => 'Confirm & Launch', 'fr' => 'Confirmer et lancer', 'es' => 'Confirmar y lanzar' ),
			'confirm_cancel'   => array( 'en' => 'Cancel', 'fr' => 'Annuler', 'es' => 'Cancelar' ),
			'balance_after_label' => array( 'en' => 'Balance after scan', 'fr' => 'Solde après scan', 'es' => 'Saldo después del escaneo' ),
				'insufficient_credits' => array( 'en' => 'Insufficient credits for this grid size.', 'fr' => 'Crédits insuffisants pour cette taille de grille.', 'es' => 'Créditos insuficientes para este tamaño de cuadrícula.' ),
				'avi_recommendations'  => array( 'en' => 'AVI Recommendations', 'fr' => 'Recommandations AVI', 'es' => 'Recomendaciones AVI' ),
				'no_keyword'           => array( 'en' => 'No keyword', 'fr' => 'Aucun mot-clé', 'es' => 'Sin palabra clave' ),
				'active_keyword'       => array( 'en' => 'Active keyword', 'fr' => 'Mot-clé actif', 'es' => 'Palabra clave activa' ),
				'active_kw_map'        => array( 'en' => 'Active KW Map', 'fr' => 'Mots-clés Actifs', 'es' => 'KW Activos' ),
				'no_kw_tracked'        => array( 'en' => 'No Radar keywords tracked for this profile. Add keywords in Growth Radar first.', 'fr' => 'Aucun mot-clé Radar suivi pour ce profil. Ajoutez d\'abord des mots-clés dans Growth Radar.', 'es' => 'No hay palabras clave Radar seguidas para este perfil. Añada primero palabras clave en Growth Radar.' ),
				'pending_kw'           => array( 'en' => 'Pending', 'fr' => 'En attente', 'es' => 'Pendiente', 'de' => 'Ausstehend', 'pt-br' => 'Pendente', 'tr' => 'Beklemede' ),
				'kw_selection_aria'    => array( 'en' => 'Multi-keyword Heatmap selection', 'fr' => 'Sélection multi mots-clés Heatmap', 'es' => 'Selección multi palabras clave Heatmap' ),
			'heatmap_note'     => array( 'en' => 'This view uses your existing Growth Radar keywords. Grid size determines the number of scanned points and credit cost.', 'fr' => 'Cette vue utilise les mots-clés Growth Radar existants. La taille choisie détermine le nombre de points scannés et donc le coût en crédits.', 'es' => 'Esta vista utiliza sus palabras clave Growth Radar existentes. El tamaño de la cuadrícula determina el número de puntos escaneados y el coste en créditos.' ),
			'taille_grille'    => array( 'en' => 'Grid size', 'fr' => 'Taille de grille', 'es' => 'Tamaño de cuadrícula' ),
			'cout'             => array( 'en' => 'Cost', 'fr' => 'Coût', 'es' => 'Coste' ),
			'apres_scan'       => array( 'en' => 'After scan', 'fr' => 'Après scan', 'es' => 'Tras escaneo' ),
			'coverage_score_label' => array( 'en' => 'Top 3 Coverage', 'fr' => 'Couverture Top 3', 'es' => 'Cobertura Top 3', 'de' => 'Top-3-Abdeckung', 'pt-br' => 'Cobertura Top 3', 'tr' => 'Top 3 Kapsaması' ),
			'coverage_score_none'  => array( 'en' => 'No scan yet', 'fr' => 'Aucun scan', 'es' => 'Sin escaneo' ),
			// Anti-régression #Sprint43-scheduler-i18n
			'scan_frequency'    => array( 'en' => 'Scan frequency', 'fr' => 'Fréquence de scan', 'es' => 'Frecuencia de escaneo', 'de' => 'Scan-Häufigkeit', 'pt-br' => 'Frequência de scan', 'tr' => 'Tarama sıklığı' ),
			'freq_once'         => array( 'en' => 'One-time scan', 'fr' => 'Scan unique', 'es' => 'Escaneo único' ),
			'freq_daily'        => array( 'en' => 'Daily', 'fr' => 'Quotidien', 'es' => 'Diario' ),
			'freq_every3days'   => array( 'en' => 'Every 3 days', 'fr' => 'Tous les 3 jours', 'es' => 'Cada 3 días' ),
			'freq_weekly'       => array( 'en' => 'Weekly', 'fr' => 'Hebdomadaire', 'es' => 'Semanal' ),
			'freq_every2weeks'  => array( 'en' => 'Every 2 weeks', 'fr' => 'Toutes les 2 semaines', 'es' => 'Cada 2 semanas' ),
			'freq_monthly'      => array( 'en' => 'Monthly', 'fr' => 'Mensuel', 'es' => 'Mensual' ),
			'schedule_active'   => array( 'en' => 'Scheduled scan active', 'fr' => 'Scan programmé actif', 'es' => 'Escaneo programado activo' ),
			'schedule_saved'    => array( 'en' => 'Schedule saved', 'fr' => 'Programmation enregistrée', 'es' => 'Programación guardada' ),
			'schedule_cancel'   => array( 'en' => 'Cancel schedule', 'fr' => 'Annuler la programmation', 'es' => 'Cancelar programación' ),

		);
		$rest_url          = function_exists( 'rest_url' ) ? rest_url( 'avision/v1/heatmap-scan' ) : '/wp-json/avision/v1/heatmap-scan';
		$latest_rest_url   = function_exists( 'rest_url' ) ? rest_url( 'avision/v1/heatmap-latest' ) : '/wp-json/avision/v1/heatmap-latest';
		$history_rest_url  = function_exists( 'rest_url' ) ? rest_url( 'avision/v1/heatmap-history' ) : '/wp-json/avision/v1/heatmap-history';
		$map_points        = self::extract_map_points( $scan );
		global $wpdb;
		$latest_scan_date_global = '';
		$heatmap_table         = self::resolve_table_name( 'avi_heatmap_results' );
		if ( $heatmap_table !== '' && isset( $wpdb ) && $wpdb ) {
			// Anti-régression #Sprint43-latest-scan-date — date du dernier scan complet uniquement
			// MAX(scan_date) brut tirait des scans partiels (1 point) qui écartaient les scans complets antérieurs
			// Nouvelle logique : scan complet = COUNT(points) >= grid_size² ET status = completed
			// Autorisé Anthony — Sprint 43
			$latest_scan_date_row = $wpdb->get_row(
				$wpdb->prepare(
					"SELECT scan_date as latest_date
					FROM `{$heatmap_table}`
					WHERE profile_id = %d
					AND status = 'completed'
					GROUP BY scan_date, keyword, grid_size
					HAVING COUNT(*) >= (grid_size * grid_size)
					ORDER BY scan_date DESC
					LIMIT 1",
					$profile_id
				),
				ARRAY_A
			);
			$latest_scan_date_global = $latest_scan_date_row
				? (string) ( $latest_scan_date_row['latest_date'] ?? '' )
				: '';
		}
		$json_payload  = self::esc_json_attr(
			array(
				'profile_id'          => $profile_id,
				'keyword'             => $keyword,
				'status'              => $status,
				'center'              => $has_center ? $center : null,
				'map_points'          => $map_points,
				'competitors'         => array(),
				'avg_rank'            => $scan['avg_rank'] ?? null,
				'territory_pct'       => $scan['territory_pct'] ?? null,
				'scan_date'           => isset( $scan['scan_date'] )
					? (string) $scan['scan_date'] : null,
				'latest_scan_date_global' => $latest_scan_date_global,
				'credit_balance'      => $credit_balance,
				'allowed_grid_sizes'  => self::ALLOWED_GRID_SIZES,
				'default_grid_size'   => $initial_grid_size,
				'default_radius_km'   => self::DEFAULT_RADIUS_KM,
				'rest_url'            => $rest_url,
				'latest_rest_url'     => $latest_rest_url,
				'history_rest_url'    => $history_rest_url,
				'nonce'               => wp_create_nonce( 'wp_rest' ),
			)
		);

		ob_start();
		echo self::render_styles(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSS statique interne.
		?>
		<section id="<?php echo esc_attr( $module_id ); ?>" class="avi-radar-heatmap-module is-design-points" data-profile-id="<?php echo esc_attr( (string) $profile_id ); ?>" data-heatmap="<?php echo $json_payload; ?>"
			<?php if ( $embed_token !== '' ) : ?>
			data-embed-token="<?php echo esc_attr( $embed_token ); ?>"
			<?php endif; ?>>
			<button type="button" class="avi-heatmap-closed" data-avi-heatmap-toggle aria-expanded="false" aria-controls="<?php echo esc_attr( $drawer_id ); ?>">
				<span class="avi-heatmap-closed-main">
					<span class="avi-heatmap-icon" aria-hidden="true">◎</span>
					<span>
						<span class="avi-heatmap-eyebrow" style="display:flex;align-items:center;gap:6px">Growth Radar Heatmap <span role="button" tabindex="0" class="avi-hm-info-btn" data-hm-pedago="hm_map_view" aria-label="Learn more" onclick="event.stopPropagation()" style="cursor:pointer;font-size:13px;line-height:1;flex-shrink:0">ℹ️</span></span>
						<span class="avi-heatmap-title"><?php echo esc_html( $t['heatmap_subtitle'][ $lang ] ?? 'Local dominance map' ); ?></span>
					</span>
				</span>
				<span class="avi-heatmap-closed-meta">
					<span><?php echo esc_html( (string) $keyword_count ); ?> <span data-hm-i18n="kw_radar_label"><?php echo esc_html( $t['kw_radar_label'][ $lang ] ?? 'Radar keywords' ); ?></span></span>
					<span class="avi-heatmap-pill avi-heatmap-pill--<?php echo esc_attr( sanitize_html_class( $status ) ); ?>"><?php echo esc_html( self::status_label( $status, $lang ) ); ?></span>
					<span class="avi-hm-coverage-badge" data-avi-hm-coverage>
						<?php if ( $territory_pct !== '—' && $territory_pct !== '' ) : ?>
							<span class="avi-hm-coverage-icon">◎</span>
							<span class="avi-hm-coverage-value"><?php echo esc_html( $territory_pct ); ?></span>
							<span class="avi-hm-coverage-lbl" data-hm-i18n="coverage_score_label"><?php echo esc_html( $t['coverage_score_label'][ $lang ] ?? 'Top 3 Coverage' ); ?></span>
						<?php else : ?>
							<span class="avi-hm-coverage-none"><?php echo esc_html( $t['coverage_score_none'][ $lang ] ?? 'No scan yet' ); ?></span>
						<?php endif; ?>
					</span>
				</span>
			</button>

			<div id="<?php echo esc_attr( $drawer_id ); ?>" class="avi-heatmap-drawer" hidden>
				<div class="avi-heatmap-drawer-grid">
					<div class="avi-heatmap-panel avi-heatmap-panel--keywords">
						<div class="avi-heatmap-panel-head">
							<div class="avi-hm-panel-title-wrap">
								<h4>
									<span class="avi-hm-step-badge">1</span>
									<?php echo esc_html( $t['kw_panel_title'][ $lang ] ?? 'Radar Keywords' ); ?>
									<button type="button" class="avi-hm-info-btn" data-hm-pedago="hm_keywords" aria-label="En savoir plus sur les mots-cles">ℹ️</button>
								</h4>
								<span class="avi-hm-panel-hint" data-hm-i18n="step1_hint">
									<?php echo esc_html( $t['step1_hint'][ $lang ] ?? 'Select 1 to 5 keywords to map' ); ?>
								</span>
							</div>
							<span><?php echo esc_html( (string) $keyword_count ); ?> <span data-hm-i18n="suivis"><?php echo esc_html( $t['suivis'][ $lang ] ?? 'tracked' ); ?></span></span>
							<span class="avi-hm-kw-counter" id="avi-hm-kw-counter-<?php echo esc_attr( (string) $profile_id ); ?>">
								<span class="avi-hm-kw-counter-val">0</span> / 5
								<span data-hm-i18n="selected">
									<?php echo esc_html( $t['selected'][ $lang ] ?? 'selected' ); ?>
								</span>
							</span>
						</div>
						<?php if ( empty( $keywords ) ) : ?>
							<p class="avi-heatmap-empty"><?php echo esc_html( $t['no_kw_tracked'][ $lang ] ?? 'No Radar keywords tracked for this profile. Add keywords in Growth Radar first.' ); ?></p>
						<?php else : ?>
							<div class="avi-heatmap-keyword-controls" data-avi-heatmap-kw-controls="drawer">
								<input type="text"
									class="avi-heatmap-kw-search"
									data-avi-heatmap-kw-search="drawer"
									placeholder="<?php echo esc_attr( $t['rechercher'][ $lang ] ?? 'Search...' ); ?>"
									autocomplete="off">
								<div class="avi-heatmap-kw-sort-btns">
									<button type="button" class="avi-heatmap-kw-sort is-active" data-avi-heatmap-kw-sort="drawer" data-sort-mode="rank" data-hm-i18n="sort_rank"><?php echo esc_html( $t['sort_rank'][ $lang ] ?? 'Rank' ); ?></button>
									<button type="button" class="avi-heatmap-kw-sort" data-avi-heatmap-kw-sort="drawer" data-sort-mode="alpha">A-Z</button>
								</div>
							</div>
							<div class="avi-heatmap-keyword-list" data-avi-heatmap-kw-list="drawer" style="max-height:400px;overflow-y:auto">
								<?php foreach ( $keywords as $row ) : ?>
									<?php
									$row_keyword = isset( $row['keyword'] ) ? trim( (string) $row['keyword'] ) : '';
									if ( $row_keyword === '' ) {
										continue;
									}
									$current_rank = isset( $row['current_rank'] ) && $row['current_rank'] !== null ? (int) $row['current_rank'] : 0;
									?>
									<button type="button" class="avi-heatmap-keyword" data-heatmap-keyword="<?php echo esc_attr( $row_keyword ); ?>" data-keyword-rank="<?php echo esc_attr( (string) $current_rank ); ?>">
										<span><?php echo esc_html( $row_keyword ); ?></span>
										<span><?php echo $current_rank > 0 ? esc_html( '#' . $current_rank ) : esc_html( $t['pending_kw'][ $lang ] ?? 'Pending' ); ?></span>
									</button>
								<?php endforeach; ?>
							</div>
						<?php endif; ?>
					</div>

					<div class="avi-heatmap-panel avi-heatmap-panel--preview">
						<div class="avi-heatmap-panel-head">
							<div class="avi-hm-panel-title-wrap">
								<h4>
									<span class="avi-hm-step-badge">2</span>
									<?php echo esc_html( $t['vue_heatmap'][ $lang ] ?? 'Heatmap View' ); ?>
									<button data-hm-pedago="hm_map_view" type="button" class="avi-hm-info-btn" aria-label="En savoir plus sur la carte">ℹ️</button>
								</h4>
								<span class="avi-hm-panel-hint" data-hm-i18n="step2_hint">
									<?php echo esc_html( $t['step2_hint'][ $lang ] ?? 'Choose your map style and preview' ); ?>
								</span>
							</div>
							<span><?php echo $scan_date !== '' ? esc_html( $scan_date ) : esc_html__( 'Aucun scan', 'avision-dashboard' ); ?></span>
						</div>
						<div class="avi-heatmap-design-switch" role="group" aria-label="<?php esc_attr_e( 'Style de visualisation Heatmap', 'avision-dashboard' ); ?>" style="display:none;">
							<button type="button" data-avi-heatmap-design="grid" data-hm-i18n="design_grid" style="display:none;" aria-hidden="true"><?php echo esc_html( $t['design_grid'][ $lang ] ?? 'Grid' ); ?></button>
							<button type="button" class="is-active" data-avi-heatmap-design="points" data-hm-i18n="design_points"><?php echo esc_html( $t['design_points'][ $lang ] ?? 'Points' ); ?></button>
							<button type="button" data-avi-heatmap-design="territory" data-hm-i18n="design_territory" style="display:none;" aria-hidden="true"><?php echo esc_html( $t['design_territory'][ $lang ] ?? 'Territory' ); ?></button>
						</div>
						<div class="avi-heatmap-grid-preview">
							<?php foreach ( $grid_points as $point ) : ?>
								<span class="<?php echo esc_attr( $point['class'] ); ?>"><?php echo esc_html( $point['label'] ); ?></span>
							<?php endforeach; ?>
							<button type="button" class="avi-heatmap-expand-btn" data-avi-heatmap-modal-open aria-label="<?php esc_attr_e( 'Agrandir la carte Heatmap', 'avision-dashboard' ); ?>">⛶</button>
						</div>
						<button
							type="button"
							class="avi-hm-open-map-btn<?php echo $scan_date === '' ? ' is-disabled' : ''; ?>"
							data-avi-heatmap-modal-open
							<?php disabled( $scan_date === '' ); ?>
							aria-label="<?php esc_attr_e( 'Open interactive map', 'avision-dashboard' ); ?>">
							<span class="avi-hm-open-map-icon">🗺️</span>
							<span class="avi-hm-open-map-label" data-hm-i18n="open_map_btn">
								<?php echo esc_html( $t['open_map_btn'][ $lang ] ?? 'Open Map' ); ?>
							</span>
							<?php if ( $scan_date === '' ) : ?>
								<span class="avi-hm-open-map-hint" data-hm-i18n="open_map_no_scan">
									<?php echo esc_html( $t['open_map_no_scan'][ $lang ] ?? 'Launch a scan first' ); ?>
								</span>
							<?php endif; ?>
						</button>
						<div class="avi-heatmap-metrics">
							<div>
								<strong data-avi-hm-metric="territory"><?php echo esc_html( $territory_pct ); ?></strong>
								<span data-hm-i18n="territoire_top3"><?php echo esc_html( $t['territoire_top3'][ $lang ] ?? 'Top 3 Territory' ); ?>
									<button type="button" class="avi-hm-info-btn avi-hm-info-btn--inline" data-hm-pedago="hm_territory" aria-label="En savoir plus sur le territoire Top 3">ℹ️</button>
								</span>
							</div>
							<div>
								<strong data-avi-hm-metric="avg-rank"><?php echo esc_html( $avg_rank ); ?></strong>
								<span data-hm-i18n="rang_moyen"><?php echo esc_html( $t['rang_moyen'][ $lang ] ?? 'Average Rank' ); ?>
									<button type="button" class="avi-hm-info-btn avi-hm-info-btn--inline" data-hm-pedago="hm_avg_rank" aria-label="En savoir plus sur le rang moyen">ℹ️</button>
								</span>
							</div>
							<div>
								<strong data-avi-hm-metric="status"><?php echo esc_html( self::status_label( $status, $lang ) ); ?></strong>
								<span data-hm-i18n="dernier_statut"><?php echo esc_html( $t['dernier_statut'][ $lang ] ?? 'Last status' ); ?></span>
							</div>
						</div>
					</div>

					<div class="avi-heatmap-panel avi-heatmap-panel--actions">
						<div class="avi-heatmap-panel-head">
							<div class="avi-hm-panel-title-wrap">
								<h4>
									<span class="avi-hm-step-badge">3</span>
									<?php echo esc_html( $t['nouveau_scan'][ $lang ] ?? 'New scan' ); ?>
									<button type="button" class="avi-hm-info-btn" data-hm-pedago="hm_scan" aria-label="En savoir plus sur les scans">ℹ️</button>
								</h4>
								<span class="avi-hm-panel-hint" data-hm-i18n="step3_hint">
									<?php echo esc_html( $t['step3_hint'][ $lang ] ?? 'Select grid size and launch scan' ); ?>
								</span>
							</div>
							<span><?php echo esc_html( (string) $credit_balance ); ?> <span data-hm-i18n="credits_dispo"><?php echo esc_html( $t['credits_dispo'][ $lang ] ?? 'credits available' ); ?></span>
								<button type="button" class="avi-hm-info-btn avi-hm-info-btn--inline" data-hm-pedago="hm_credits" aria-label="En savoir plus sur les credits">ℹ️</button>
							</span>
						</div>
						<p class="avi-heatmap-note" data-hm-i18n="heatmap_note"><?php echo esc_html( $t['heatmap_note'][ $lang ] ?? 'This view uses your existing Growth Radar keywords. Grid size determines the number of scanned points and credit cost.' ); ?></p>
						<div class="avi-heatmap-scan-controls">
							<label class="avi-heatmap-field">
								<span data-hm-i18n="taille_grille"><?php echo esc_html( $t['taille_grille'][ $lang ] ?? 'Grid size' ); ?>
									<button type="button" class="avi-hm-info-btn avi-hm-info-btn--inline" data-hm-pedago="hm_grid" aria-label="En savoir plus sur la grille">ℹ️</button>
								</span>
								<select data-avi-heatmap-grid-size>
									<?php foreach ( self::ALLOWED_GRID_SIZES as $grid_size ) : ?>
										<?php $cost = (int) $grid_size * (int) $grid_size; ?>
										<option value="<?php echo esc_attr( (string) $grid_size ); ?>" <?php selected( $grid_size, $initial_grid_size ); ?>>
											<?php echo esc_html( $grid_size . 'x' . $grid_size . ' - ' . $cost . ' credits' ); ?>
										</option>
									<?php endforeach; ?>
								</select>
								<p class="avi-heatmap-grid-desc" data-avi-heatmap-grid-desc></p>
							</label>
							<div class="avi-heatmap-credit-row">
								<span><span data-hm-i18n="solde"><?php echo esc_html( $t['solde'][ $lang ] ?? 'Balance' ); ?></span> <strong data-avi-heatmap-balance><?php echo esc_html( (string) $credit_balance ); ?></strong></span>
								<span><span data-hm-i18n="cout"><?php echo esc_html( $t['cout'][ $lang ] ?? 'Cost' ); ?></span> <strong data-avi-heatmap-cost><?php echo esc_html( (string) $initial_cost ); ?></strong></span>
								<span><span data-hm-i18n="apres_scan"><?php echo esc_html( $t['apres_scan'][ $lang ] ?? 'After scan' ); ?></span> <strong data-avi-heatmap-balance-after><?php echo esc_html( (string) max( 0, $credit_balance - $initial_cost ) ); ?></strong></span>
							</div>
							<!-- Anti-régression #Sprint43-scheduler-ui — sélecteur fréquence scan heatmap -->
							<div class="avi-hm-schedule-selector" data-avi-hm-schedule-selector>
								<label class="avi-heatmap-field">
									<span data-hm-i18n="scan_frequency"><?php echo esc_html( $t['scan_frequency'][ $lang ] ?? 'Scan frequency' ); ?>
										<button type="button" class="avi-hm-info-btn avi-hm-info-btn--inline" data-hm-pedago="hm_schedule" aria-label="En savoir plus sur les scans programmés">ℹ️</button>
									</span>
									<select data-avi-hm-frequency class="avi-hm-frequency-select">
										<option value="once"><?php echo esc_html( $t['freq_once'][ $lang ] ?? '🔁 One-time scan' ); ?></option>
										<option value="daily"><?php echo esc_html( $t['freq_daily'][ $lang ] ?? '📅 Daily' ); ?></option>
										<option value="every3days"><?php echo esc_html( $t['freq_every3days'][ $lang ] ?? '📅 Every 3 days' ); ?></option>
										<option value="weekly" selected><?php echo esc_html( $t['freq_weekly'][ $lang ] ?? '📅 Weekly' ); ?></option>
										<option value="every2weeks"><?php echo esc_html( $t['freq_every2weeks'][ $lang ] ?? '📅 Every 2 weeks' ); ?></option>
										<option value="monthly"><?php echo esc_html( $t['freq_monthly'][ $lang ] ?? '📅 Monthly' ); ?></option>
									</select>
								</label>
								<p class="avi-hm-schedule-note" data-avi-hm-schedule-note style="font-size:.75rem;color:#6d28d9;margin:4px 0 0"></p>
							</div>
							<p class="avi-heatmap-scan-feedback" data-avi-heatmap-scan-feedback>
								<?php
								if ( $keyword === '' ) {
									echo esc_html__( 'Selectionne d abord un mot-cle Radar.', 'avision-dashboard' );
								} elseif ( ! $has_center ) {
									echo esc_html__( 'Centre GBP indisponible pour ce profil : lance un premier scan depuis une source geolocalisee ou reconnecte Google.', 'avision-dashboard' );
								} elseif ( ! $can_scan ) {
									echo esc_html( $t['insufficient_credits'][ $lang ] ?? 'Insufficient credits for this grid size.' );
								} else {
									echo esc_html__( 'Pret a lancer un scan standard.', 'avision-dashboard' );
								}
								?>
							</p>
						</div>
						<div class="avi-heatmap-actions">
							<button type="button" class="btn-upgrade avi-heatmap-scan-submit" data-avi-heatmap-scan-submit <?php disabled( ! $can_scan ); ?> data-hm-i18n="lancer_scan_drawer">
								<?php echo esc_html( $t['lancer_scan'][ $lang ] ?? 'Launch scan' ); ?>
							</button>
							<div class="avi-hm-confirm-panel" data-avi-hm-confirm-panel hidden>
								<div class="avi-hm-confirm-summary">
									<span class="avi-hm-confirm-icon">🗺️</span>
									<div class="avi-hm-confirm-details">
										<strong class="avi-hm-confirm-title" data-avi-hm-confirm-title></strong>
										<span class="avi-hm-confirm-sub" data-avi-hm-confirm-sub></span>
									</div>
								</div>
								<div class="avi-hm-confirm-btns">
									<button type="button" class="avi-hm-confirm-ok" data-avi-hm-confirm-ok data-hm-i18n="confirm_ok">
										<?php echo esc_html( $t['confirm_ok'][ $lang ] ?? 'Confirm' ); ?>
									</button>
									<button type="button" class="avi-hm-confirm-cancel" data-avi-hm-confirm-cancel data-hm-i18n="confirm_cancel">
										<?php echo esc_html( $t['confirm_cancel'][ $lang ] ?? 'Cancel' ); ?>
									</button>
								</div>
							</div>
						</div>
					</div>
				</div>
			</div>

			<div id="<?php echo esc_attr( $modal_id ); ?>" class="avi-heatmap-modal" hidden role="dialog" aria-modal="true" aria-labelledby="<?php echo esc_attr( $modal_id ); ?>-title">
				<div class="avi-heatmap-modal-backdrop" data-avi-heatmap-modal-close></div>
				<div class="avi-heatmap-modal-card">
					<button type="button" class="avi-heatmap-modal-close" data-avi-heatmap-modal-close aria-label="<?php esc_attr_e( 'Fermer', 'avision-dashboard' ); ?>">×</button>
					<div class="avi-heatmap-modal-layout">
						<!-- Anti-régression #Sprint43-C12-sidebar-over-drawer — overlay par-dessus drawer gauche -->
						<div class="avi-hm-battle-sidebar" data-avi-hm-battle-sidebar hidden></div>
						<aside class="avi-heatmap-modal-info">
							<span class="avi-heatmap-eyebrow">Growth Radar Heatmap</span>
							<div class="avi-heatmap-modal-title-row">
								<h3 id="<?php echo esc_attr( $modal_id ); ?>-title" data-hm-i18n="modal_title">Interactive local map</h3>
								<button data-hm-pedago="hm_map_view" type="button" class="avi-hm-info-btn avi-hm-info-btn--inline" aria-label="Learn more about the map">
									ℹ️
								</button>
							</div>
							<div class="avi-heatmap-modal-disclaimer-wrap" data-avi-hm-disclaimer-wrap>
								<button type="button" class="avi-heatmap-modal-disclaimer-summary" data-avi-hm-disclaimer-toggle aria-expanded="false">
									⚠️ <span data-hm-i18n="disclaimer_short">Google Algorithm Warning</span>
									<span class="avi-hm-chevron" aria-hidden="true">›</span>
								</button>
								<div class="avi-heatmap-modal-disclaimer" role="note" data-hm-i18n="disclaimer" data-avi-hm-disclaimer-body hidden>
									Lecture prudente : utilisez ces signaux sans modification artificielle de fiche Google Business Profile.
								</div>
							</div>
							<div class="avi-hm-modal-section-head">
								<span data-hm-i18n="modal_kw_section_title">Sélection mots-clés</span>
								<button type="button" class="avi-hm-info-btn avi-hm-info-btn--inline" data-hm-pedago="hm_keywords_modal" aria-label="En savoir plus sur la sélection">ℹ️</button>
							</div>
							<div class="avi-heatmap-keyword-controls" data-avi-heatmap-kw-controls="modal">
								<input type="text"
									class="avi-heatmap-kw-search"
									data-avi-heatmap-kw-search="modal"
									placeholder="<?php echo esc_attr( $t['rechercher'][ $lang ] ?? 'Search...' ); ?>"
									autocomplete="off">
								<div class="avi-heatmap-kw-sort-btns">
									<button type="button" class="avi-heatmap-kw-sort is-active" data-avi-heatmap-kw-sort="modal" data-sort-mode="rank" data-hm-i18n="sort_rank">Rang</button>
									<button type="button" class="avi-heatmap-kw-sort" data-avi-heatmap-kw-sort="modal" data-sort-mode="alpha">A-Z</button>
									<button type="button" class="avi-heatmap-kw-sort" data-avi-heatmap-kw-sort="modal" data-sort-mode="scanned" data-hm-i18n="sort_scanned">Scannés</button>
								</div>
							</div>
							<div class="avi-heatmap-modal-keywords" aria-label="<?php echo esc_attr( $t['kw_selection_aria'][ $lang ] ?? 'Multi-keyword Heatmap selection' ); ?>">
								<?php foreach ( $keywords as $row ) : ?>
									<?php
									$row_keyword = isset( $row['keyword'] ) ? trim( (string) $row['keyword'] ) : '';
									if ( $row_keyword === '' ) {
										continue;
									}
									$current_rank_modal = isset( $row['current_rank'] ) && $row['current_rank'] !== null ? (int) $row['current_rank'] : 0;
									?>
									<button type="button" class="avi-heatmap-modal-keyword" data-avi-heatmap-modal-keyword="<?php echo esc_attr( $row_keyword ); ?>" data-keyword-rank="<?php echo esc_attr( (string) $current_rank_modal ); ?>">
										<span><?php echo esc_html( $row_keyword ); ?></span>
										<span style="font-size:.72rem;font-weight:700;color:#6d28d9;white-space:nowrap">
											<?php echo $current_rank_modal > 0 ? esc_html( '#' . $current_rank_modal ) : '—'; ?>
										</span>
										<i aria-hidden="true"></i>
									</button>
								<?php endforeach; ?>
							</div>
							<button type="button" class="avi-heatmap-kw-showmore" data-avi-heatmap-kw-showmore="modal" style="display:none;">
								<?php echo esc_html( $t['voir_plus'][ $lang ] ?? 'See more' ); ?>
								<span data-avi-heatmap-kw-showmore-count></span>
							</button>
							<div class="avi-heatmap-modal-advice-placeholder">
								<strong data-hm-i18n="avi_recommendations"><?php echo esc_html( $t['avi_recommendations'][ $lang ] ?? 'AVI Recommendations' ); ?></strong>
								<span data-hm-i18n="avi_recommendations_placeholder">Keyword-specific advice will appear here after RAG connection.</span>
							</div>
							<p class="avi-heatmap-modal-missing" data-avi-heatmap-modal-missing></p>
						</aside>
						<div class="avi-heatmap-modal-map">
							<div class="avi-heatmap-modal-tabs">
								<button type="button" class="avi-hm-tab is-active" data-avi-heatmap-tab="map" data-hm-i18n="tab_map">🗺️ Carte</button>
								<button type="button" class="avi-hm-tab" data-avi-heatmap-tab="evolution" data-hm-i18n="<?php echo esc_attr( 'tab_' . 'evolution' ); ?>">📊 Évolution</button>
								<button type="button" class="avi-hm-tab" data-avi-heatmap-tab="competitors" data-hm-i18n="tab_competitors">
									<?php echo esc_html( $t['tab_competitors'][ $lang ] ?? '🥊 Competitors' ); ?>
								</button>
							</div>
							<div data-avi-heatmap-panel="map">
								<div class="avi-heatmap-modal-map-stats">
									<div class="avi-hm-stats-left">
										<div class="avi-heatmap-modal-kpis">
											<div><strong data-avi-heatmap-current-keyword><?php echo esc_html( $keyword !== '' ? $keyword : ( $t['no_keyword'][ $lang ] ?? 'No keyword' ) ); ?></strong><span data-hm-i18n="active_keyword"><?php echo esc_html( $t['active_keyword'][ $lang ] ?? 'Active keyword' ); ?></span></div>
											<div><strong data-avi-heatmap-modal-avg-rank><?php echo esc_html( $avg_rank ); ?></strong><span data-hm-i18n="rang_moyen"><?php echo esc_html( $t['rang_moyen'][ $lang ] ?? 'Average Rank' ); ?>
												<button type="button" class="avi-hm-info-btn avi-hm-info-btn--inline" data-hm-pedago="hm_avg_rank" aria-label="En savoir plus">ℹ️</button>
											</span></div>
											<div><strong data-avi-heatmap-modal-territory><?php echo esc_html( $territory_pct ); ?></strong><span data-hm-i18n="territoire_top3"><?php echo esc_html( $t['territoire_top3'][ $lang ] ?? 'Top 3 Territory' ); ?>
												<button type="button" class="avi-hm-info-btn avi-hm-info-btn--inline" data-hm-pedago="hm_territory" aria-label="En savoir plus">ℹ️</button>
											</span></div>
										</div>
										<div class="avi-heatmap-modal-legend" aria-label="<?php esc_attr_e( 'Legende Heatmap', 'avision-dashboard' ); ?>">
											<span><i class="avi-heatmap-cell--top3"></i><span data-hm-i18n="legend_top3">Top 3</span></span>
											<span><i class="avi-heatmap-cell--found"></i><span data-hm-i18n="legend_found">Rank 4-10</span></span>
											<span><i class="avi-heatmap-cell--found avi-heatmap-cell--weak"></i><span data-hm-i18n="legend_weak">Rank 11-20</span></span>
											<span><i class="avi-heatmap-cell--missing"></i><span data-hm-i18n="legend_missing">&gt;20</span></span>
											<span><i class="avi-heatmap-cell--competitor"></i><span data-hm-i18n="legend_competitor">Competitor</span></span>
										</div>
										<p class="avi-heatmap-modal-selection-note" data-avi-heatmap-modal-selection-note></p>
									</div>
									<!-- Anti-régression #Sprint43-C12-ci-removed — CI mergé dans sidebar bataille -->
							</div>
							<div class="avi-hm-map-wrapper">
								<div id="<?php echo esc_attr( 'avi-heatmap-leaflet-' . $profile_id ); ?>" class="avi-heatmap-leaflet" data-avi-heatmap-leaflet></div>
								<button type="button" class="avi-hm-fs-btn" data-avi-hm-fullscreen title="Fullscreen" aria-label="Toggle fullscreen map">⛶</button>
							</div>
							<div class="avi-hm-intel-overlay" data-avi-hm-intel-overlay hidden>
								<div class="avi-hm-intel-overlay-header">
									<strong data-hm-i18n="intel_title">
										<?php echo esc_html( $t['intel_title'][ $lang ] ?? 'Competitor Intelligence' ); ?>
									</strong>
									<button type="button" class="avi-hm-intel-overlay-close" data-avi-hm-intel-overlay-close aria-label="Close">×</button>
								</div>
								<div class="avi-hm-intel-overlay-body" data-avi-hm-intel-overlay-body></div>
							</div>
							<div class="avi-heatmap-competitors-bar" data-avi-heatmap-competitors-bar style="display:none;">
								<span class="avi-heatmap-competitors-label" data-hm-i18n="concurrents">
									<?php echo esc_html( $t['concurrents'][ $lang ] ?? $t['concurrents']['en'] ?? 'Competitors:' ); ?>
									<button type="button" class="avi-hm-info-btn avi-hm-info-btn--inline" data-hm-pedago="hm_competitors_info" aria-label="En savoir plus sur les concurrents">ℹ️</button>
								</span>
								<div class="avi-heatmap-competitors-buttons" data-avi-heatmap-competitors-buttons></div>
							</div>
							<div class="avi-heatmap-map-overlay" data-avi-heatmap-map-overlay hidden></div>
							</div>
							<div data-avi-heatmap-panel="evolution" hidden>
								<div class="avi-hm-evolution-header">
									<p class="avi-hm-evolution-intro" data-hm-i18n="evolution_intro">
										Compare your scans over time. Select up to 5 scans to display side by side.
									</p>
								</div>
								<div class="avi-hm-evolution-selector" data-avi-hm-evolution-selector>
									<p class="avi-hm-evolution-loading" data-avi-hm-evolution-loading>
										Loading scan history...
									</p>
								</div>
								<div class="avi-hm-evolution-grid" data-avi-hm-evolution-grid></div>
								<div class="avi-hm-evolution-kpis" data-avi-hm-evolution-kpis></div>
								<div class="avi-hm-focus-overlay" id="aviHmFocusOverlay" data-avi-hm-focus-overlay hidden>
									<div class="avi-hm-focus-header">
										<div class="avi-hm-focus-header-left">
											<span class="avi-hm-focus-title" data-avi-hm-focus-title></span>
											<span class="avi-hm-focus-subtitle" data-avi-hm-focus-subtitle></span>
										</div>
										<div class="avi-hm-focus-header-center" data-avi-hm-focus-kw-btns></div>
										<div class="avi-hm-focus-header-right">
											<div class="avi-hm-focus-layout-btns" data-avi-hm-focus-layout>
												<button type="button" class="avi-hm-focus-layout-btn is-active" data-avi-hm-focus-per-page="2" title="2 per page">⊟</button>
												<button type="button" class="avi-hm-focus-layout-btn" data-avi-hm-focus-per-page="1" title="1 per page">□</button>
												<button type="button" class="avi-hm-focus-layout-btn" data-avi-hm-focus-per-page="3" title="3 per page">⊞</button>
											</div>
											<button type="button" class="avi-hm-focus-close" data-avi-hm-focus-close aria-label="Close focus">✕</button>
										</div>
									</div>
									<div class="avi-hm-focus-grid" data-avi-hm-focus-grid></div>
								</div>
							</div>
							<div data-avi-heatmap-panel="competitors" data-scan-date="<?php echo esc_attr( $scan_date ?? '' ); ?>" hidden class="avi-hm-competitors-panel-root">
								<div class="avi-hm-comp-tab-header">
									<div class="avi-hm-comp-tab-top">
										<strong class="avi-hm-comp-tab-title" data-hm-i18n="comp_tab_title">
											<?php echo esc_html( $t['comp_tab_title'][ $lang ] ?? 'Competitor Analysis' ); ?>
										</strong>
										<span class="avi-hm-comp-tab-sub" data-hm-i18n="comp_tab_sub">
											<?php echo esc_html( $t['comp_tab_sub'][ $lang ] ?? 'All competitors ranked by threat level' ); ?>
										</span>
									</div>
									<div class="avi-hm-export-buttons">
										<button type="button" class="avi-hm-export-btn" data-avi-hm-export="excel" data-hm-i18n="comp_export_excel">
											<?php echo esc_html( $t['comp_export_excel'][ $lang ] ?? 'Export Excel' ); ?>
										</button>
										<button type="button" class="avi-hm-export-btn" data-avi-hm-export="pdf" data-hm-i18n="comp_export_pdf">
											<?php echo esc_html( $t['comp_export_pdf'][ $lang ] ?? 'Export PDF' ); ?>
										</button>
									</div>
									<div class="avi-hm-comp-kw-selector" data-avi-hm-comp-kw-selector></div>
								</div>
								<div class="avi-hm-comp-tab-body" data-avi-hm-comp-tab-body>
									<p class="avi-hm-comp-tab-empty" data-hm-i18n="comp_tab_empty">
										<?php echo esc_html( $t['comp_tab_empty'][ $lang ] ?? 'Launch a scan and activate competitors to see the analysis.' ); ?>
									</p>
								</div>
							</div>
						</div>
					</div>
				</div>
			</div>

			<div class="avi-hm-pedago-overlay" id="aviHmPedagoOverlay" role="dialog" aria-modal="true" aria-labelledby="aviHmPedagoTitle" aria-hidden="true" style="display:none">
				<div class="avi-hm-pedago-box" id="aviHmPedagoBox">
					<div class="avi-hm-pedago-icon" id="aviHmPedagoIcon" aria-hidden="true"></div>
					<h4 class="avi-hm-pedago-title" id="aviHmPedagoTitle"></h4>
					<div class="avi-hm-pedago-body" id="aviHmPedagoBody"></div>
					<button type="button" class="avi-hm-pedago-cta" id="aviHmPedagoCta"></button>
				</div>
			</div>

		</section>
		<?php
		return (string) ob_get_clean();
	}

	/**
	 * CSS scope au module Heatmap.
	 *
	 * @return string
	 */
	private static function render_styles(): string {
		if ( self::$styles_rendered ) {
			return '';
		}
		self::$styles_rendered = true;

		// CSS externalisé — voir assets/css/avi-radar-heatmap.css (enqueued via enqueue_assets())
		return '';
	}

	/**
	 * Encode un payload JSON pour attribut HTML.
	 *
	 * @param array<string,mixed> $payload Donnees.
	 * @return string
	 */
	private static function esc_json_attr( array $payload ): string {
		$json = function_exists( 'wp_json_encode' ) ? wp_json_encode( $payload ) : json_encode( $payload );
		return esc_attr( is_string( $json ) ? $json : '{}' );
	}

	/**
	 * Determine la table preferee en respectant le prefixe WordPress.
	 *
	 * @param string $base Base sans prefixe.
	 * @return string
	 */
	private static function resolve_table_name( string $base ): string {
		global $wpdb;

		if ( ! isset( $wpdb ) || ! $wpdb ) {
			return '';
		}

		$prefixed = $wpdb->prefix . $base;
		if ( self::table_exists( $prefixed ) ) {
			return $prefixed;
		}

		if ( self::table_exists( $base ) ) {
			return $base;
		}

		return '';
	}

	/**
	 * Verifie l'existence d'une table.
	 *
	 * @param string $table Nom de table.
	 * @return bool
	 */
	private static function table_exists( string $table ): bool {
		global $wpdb;

		if ( $table === '' || ! isset( $wpdb ) || ! $wpdb ) {
			return false;
		}

		$like = $wpdb->esc_like( $table );
		return (string) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $like ) ) === $table;
	}

	/**
	 * Liste les colonnes d'une table validee.
	 *
	 * @param string $table Nom de table.
	 * @return string[]
	 */
	private static function get_table_columns( string $table ): array {
		static $cache = array();
		global $wpdb;

		if ( $table === '' || ! isset( $wpdb ) || ! $wpdb ) {
			return array();
		}

		if ( isset( $cache[ $table ] ) ) {
			return $cache[ $table ];
		}

		$rows = $wpdb->get_results( "SHOW COLUMNS FROM `{$table}`", ARRAY_A );
		if ( ! is_array( $rows ) ) {
			$cache[ $table ] = array();
			return array();
		}

		$columns = array();
		foreach ( $rows as $row ) {
			if ( isset( $row['Field'] ) && is_string( $row['Field'] ) && $row['Field'] !== '' ) {
				$columns[] = $row['Field'];
			}
		}

		$cache[ $table ] = $columns;
		return $columns;
	}

	/**
	 * Valide un couple latitude/longitude.
	 *
	 * @param mixed $lat Latitude.
	 * @param mixed $lng Longitude.
	 * @return bool
	 */
	private static function is_valid_lat_lng( $lat, $lng ): bool {
		if ( ! is_numeric( $lat ) || ! is_numeric( $lng ) ) {
			return false;
		}

		$lat = (float) $lat;
		$lng = (float) $lng;

		return $lat >= -90 && $lat <= 90 && $lng >= -180 && $lng <= 180 && ( abs( $lat ) > 0.000001 || abs( $lng ) > 0.000001 );
	}

	/**
	 * Cherche prudemment un centre dans un cache JSON Google.
	 *
	 * @param string $raw JSON brut.
	 * @return array{lat:float,lng:float}|null
	 */
	private static function extract_center_from_json( string $raw ): ?array {
		if ( trim( $raw ) === '' ) {
			return null;
		}

		$data = json_decode( $raw, true );
		if ( ! is_array( $data ) ) {
			return null;
		}

		$candidates = array(
			$data['latlng'] ?? null,
			$data['lat_lng'] ?? null,
			$data['coordinates'] ?? null,
			$data['location'] ?? null,
			$data['metadata']['latlng'] ?? null,
			$data['profile']['latlng'] ?? null,
		);

		foreach ( $candidates as $candidate ) {
			if ( ! is_array( $candidate ) ) {
				continue;
			}

			$lat = $candidate['lat'] ?? $candidate['latitude'] ?? null;
			$lng = $candidate['lng'] ?? $candidate['longitude'] ?? null;
			if ( self::is_valid_lat_lng( $lat, $lng ) ) {
				return array( 'lat' => (float) $lat, 'lng' => (float) $lng );
			}
		}

		$lat = $data['lat'] ?? $data['latitude'] ?? null;
		$lng = $data['lng'] ?? $data['longitude'] ?? null;
		if ( self::is_valid_lat_lng( $lat, $lng ) ) {
			return array( 'lat' => (float) $lat, 'lng' => (float) $lng );
		}

		return null;
	}

	/**
	 * Formate une metrique numerique.
	 *
	 * @param mixed  $value Valeur brute.
	 * @param string $suffix Suffixe.
	 * @return string
	 */
	private static function format_metric( $value, string $suffix ): string {
		if ( $value === null || $value === '' || ! is_numeric( $value ) ) {
			return '—';
		}

		$formatted = rtrim( rtrim( number_format( (float) $value, 2, ',', ' ' ), '0' ), ',' );
		return $formatted . $suffix;
	}

	/**
	 * Libelle public d'un statut de scan.
	 *
	 * @param string $status Statut DB.
	 * @return string
	 */
	private static function status_label( string $status, string $lang = 'en' ): string {
		$labels = array(
			'en' => array(
				'pending'    => 'Pending',
				'processing' => 'Scanning',
				'partial'    => 'Partial',
				'completed'  => 'Available',
				'error'      => 'Error',
				'not_ready'  => 'Not ready',
			),
			'fr' => array(
				'pending'    => 'En attente',
				'processing' => 'Scan en cours',
				'partial'    => 'Partiel',
				'completed'  => 'Disponible',
				'error'      => 'Erreur',
				'not_ready'  => 'À préparer',
			),
			'es' => array(
				'pending'    => 'Pendiente',
				'processing' => 'Escaneando',
				'partial'    => 'Parcial',
				'completed'  => 'Disponible',
				'error'      => 'Error',
				'not_ready'  => 'No listo',
			),
			'de' => array(
				'pending'    => 'Ausstehend',
				'processing' => 'Wird gescannt',
				'partial'    => 'Teilweise',
				'completed'  => 'Verfügbar',
				'error'      => 'Fehler',
				'not_ready'  => 'Nicht bereit',
			),
			'pt-br' => array(
				'pending'    => 'Pendente',
				'processing' => 'Escaneando',
				'partial'    => 'Parcial',
				'completed'  => 'Disponível',
				'error'      => 'Erro',
				'not_ready'  => 'Não pronto',
			),
			'tr' => array(
				'pending'    => 'Beklemede',
				'processing' => 'Taranıyor',
				'partial'    => 'Kısmi',
				'completed'  => 'Mevcut',
				'error'      => 'Hata',
				'not_ready'  => 'Hazır değil',
			),
		);
		$map = $labels[ $lang ] ?? $labels['en'];
		return $map[ $status ] ?? $map['not_ready'];
	}

	/**
	 * Extrait tous les points cartographiques publics du dernier scan.
	 *
	 * @param array<string,mixed>|null $scan Dernier scan.
	 * @return array<int,array{lat:float,lng:float,rank:int|null,zone:string}>
	 */
	private static function extract_map_points( ?array $scan ): array {
		$raw  = is_array( $scan ) && ! empty( $scan['grid_data'] ) ? $scan['grid_data'] : '';
		$data = is_string( $raw ) ? json_decode( $raw, true ) : ( is_array( $raw ) ? $raw : array() );

		if ( ! is_array( $data ) ) {
			return array();
		}

		$items = isset( $data['points'] ) && is_array( $data['points'] ) ? $data['points'] : $data;
		$points = array();
		foreach ( $items as $item ) {
			if ( ! is_array( $item ) || ! isset( $item['lat'], $item['lng'] ) || ! is_numeric( $item['lat'] ) || ! is_numeric( $item['lng'] ) ) {
				continue;
			}

			$rank = isset( $item['rank'] ) && $item['rank'] !== null && is_numeric( $item['rank'] ) ? (int) $item['rank'] : null;
			$points[] = array(
				'lat'      => (float) $item['lat'],
				'lng'      => (float) $item['lng'],
				'rank'     => $rank,
				'zone'     => self::rank_zone( $rank ),
				'point_id' => isset( $item['point_id'] ) ? (string) $item['point_id'] : null,
			);
		}

		return $points;
	}

	/**
	 * Normalise un rang en zone publique Heatmap.
	 *
	 * @param int|null $rank Rang local.
	 * @return string
	 */
	private static function rank_zone( ?int $rank ): string {
		if ( $rank !== null && $rank > 0 && $rank <= 3 ) {
			return 'top3';
		}
		if ( $rank !== null && $rank > 0 && $rank <= 10 ) {
			return 'found';
		}
		if ( $rank !== null && $rank > 10 && $rank <= 20 ) {
			return 'weak';
		}

		return 'missing';
	}

	/**
	 * Construit un apercu 5x5 depuis grid_data, sinon une grille neutre.
	 *
	 * @param array<string,mixed>|null $scan Dernier scan.
	 * @return array<int,array{class:string,label:string}>
	 */
	private static function summarize_grid_points( ?array $scan ): array {
		$points = array();
		$raw    = is_array( $scan ) && ! empty( $scan['grid_data'] ) ? $scan['grid_data'] : '';
		$data   = is_string( $raw ) ? json_decode( $raw, true ) : ( is_array( $raw ) ? $raw : array() );

		if ( is_array( $data ) ) {
			foreach ( $data as $item ) {
				if ( count( $points ) >= 25 ) {
					break;
				}
				$rank = is_array( $item ) && isset( $item['rank'] ) && $item['rank'] !== null ? (int) $item['rank'] : 0;
				if ( $rank > 0 && $rank <= 3 ) {
					$points[] = array( 'class' => 'avi-heatmap-cell--top3', 'label' => (string) $rank );
				} elseif ( $rank > 10 && $rank <= 20 ) {
					$points[] = array( 'class' => 'avi-heatmap-cell--found avi-heatmap-cell--weak', 'label' => (string) $rank );
				} elseif ( $rank > 20 ) {
					$points[] = array( 'class' => 'avi-heatmap-cell--missing', 'label' => '•' );
				} elseif ( $rank > 0 ) {
					$points[] = array( 'class' => 'avi-heatmap-cell--found', 'label' => (string) $rank );
				} else {
					$points[] = array( 'class' => 'avi-heatmap-cell--missing', 'label' => '•' );
				}
			}
		}

		while ( count( $points ) < 25 ) {
			$points[] = array( 'class' => 'avi-heatmap-cell--missing', 'label' => '•' );
		}

		return $points;
	}
}

Avision_Radar_Heatmap::init();
}
