/**
 * Activité principale de l'application Best 2048.
 * Gère l'interface utilisateur (plateau de jeu, affichage des scores, boutons),
 * coordonne les interactions de l'utilisateur (swipes, clics) avec la logique du jeu
 * (classe {@link Game}) et la gestion des statistiques (classe {@link GameStats}).
 * Prend également en charge le cycle de vie de l'application, la persistance de l'état du jeu
 * via SharedPreferences, les effets sonores via SoundPool, et les notifications.
 */
package legion.muyue.best2048;

import static androidx.core.content.ContextCompat.startActivity;

import android.annotation.SuppressLint;
import android.app.AlertDialog;
import android.content.Context;
import android.app.NotificationChannel; // Conservé pour référence mais createNotificationChannel est dans Helper
import android.app.NotificationManager; // Conservé pour référence mais createNotificationChannel est dans Helper
import android.app.PendingIntent;
import android.content.ActivityNotFoundException;
import android.content.pm.PackageManager;
import android.os.Build;
import android.provider.Settings;
import com.google.android.material.switchmaterial.SwitchMaterial;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log; // Utiliser Log pour le débogage
import android.util.TypedValue;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewStub;
import android.view.animation.AnimationUtils;
import android.widget.TextView;
import androidx.activity.EdgeToEdge;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.app.NotificationCompat; // Conservé pour référence, mais showNotification est dans Helper
import androidx.core.app.NotificationManagerCompat; // Conservé pour référence, mais showNotification est dans Helper
import androidx.core.content.ContextCompat;
import androidx.gridlayout.widget.GridLayout;
import android.widget.Button;
import android.widget.Toast;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.view.ViewTreeObserver;
import android.media.AudioAttributes;
import android.media.SoundPool;

import java.util.ArrayList;
import java.util.List;


public class MainActivity extends AppCompatActivity {

    // --- Constantes ---
    /** Tag pour les logs de débogage spécifiques à cette activité. */
    private static final String TAG = "MainActivity";
    /** Taille du plateau de jeu (nombre de lignes/colonnes). Doit correspondre à Game.BOARD_SIZE. */
    private static final int BOARD_SIZE = 4;
    /** Identifiant unique du canal de notification. Doit correspondre à NotificationHelper.CHANNEL_ID. */
    private static final String NOTIFICATION_CHANNEL_ID = "BEST_2048_CHANNEL";
    /** Nom du fichier de préférences partagées. Doit correspondre à GameStats.PREFS_NAME. */
    private static final String PREFS_NAME = "Best2048_Prefs";
    /** Clé pour sauvegarder/charger le meilleur score global. Doit correspondre à GameStats.HIGH_SCORE_KEY. */
    private static final String HIGH_SCORE_KEY = "high_score";
    /** Clé pour sauvegarder/charger l'état sérialisé du jeu (plateau + score courant). */
    private static final String GAME_STATE_KEY = "game_state";
    /** Clé pour sauvegarder/charger le timestamp de la dernière partie jouée (pour les notifications d'inactivité). */
    private static final String LAST_PLAYED_TIME_KEY = "last_played_time";
    /** Clé pour sauvegarder/charger la préférence d'activation du son. */
    private static final String SOUND_ENABLED_KEY = "sound_enabled";
    /** Clé pour sauvegarder/charger la préférence d'activation des notifications. */
    private static final String NOTIFICATIONS_ENABLED_KEY = "notifications_enabled";

    // --- UI Elements ---
    /** Layout de grille affichant les tuiles du jeu. */
    private GridLayout boardGridLayout;
    /** TextView affichant le score de la partie en cours. */
    private TextView currentScoreTextView;
    /** TextView affichant le meilleur score global. */
    private TextView highestScoreTextView;
    /** Bouton pour démarrer une nouvelle partie (après confirmation). */
    private Button newGameButton;
    /** Bouton pour accéder aux fonctionnalités multijoueur (placeholder actuel). */
    private Button multiplayerButton;
    /** Bouton pour afficher/masquer le panneau des statistiques. */
    private Button statisticsButton;
    /** Bouton pour ouvrir le menu principal (dialogue). */
    private Button menuButton;
    /** ViewStub pour charger paresseusement le layout des statistiques. */
    private ViewStub statisticsViewStub;
    /** Référence au layout des statistiques une fois qu'il est gonflé par le ViewStub. */
    private View inflatedStatsView;
    /** Références aux TextViews représentant les tuiles actuellement affichées sur le plateau. */
    private TextView[][] tileViews = new TextView[BOARD_SIZE][BOARD_SIZE];

    // --- Game Logic & Stats ---
    /** Instance de la classe Game contenant la logique métier du jeu (plateau, mouvements, score). */
    private Game game;
    /** Instance de la classe GameStats gérant la collecte et la persistance des statistiques. */
    private GameStats gameStats;

    // --- State Management ---
    /** Indique si le panneau des statistiques est actuellement visible. */
    private boolean statisticsVisible = false;
    /** Énumération pour suivre l'état actuel du flux de jeu (en cours, gagné, perdu). */
    private enum GameFlowState { PLAYING, WON_DIALOG_SHOWN, GAME_OVER }
    /** État actuel du flux de jeu. */
    private GameFlowState currentGameState = GameFlowState.PLAYING;
    /** Indique si les notifications sont activées par l'utilisateur et si la permission est accordée (sur API 33+). */
    private boolean notificationsEnabled = false;
    /** Indique si les effets sonores sont activés par l'utilisateur. */
    private boolean soundEnabled = true; // Son activé par défaut

    // --- Preferences ---
    /** Instance de SharedPreferences pour la persistance des données. */
    private SharedPreferences preferences;

    // --- Sound ---
    /** Pool pour gérer et jouer les effets sonores courts. */
    private SoundPool soundPool;
    /** ID du son chargé pour un mouvement de tuile. */
    private int soundMoveId = -1;
    /** ID du son chargé pour une fusion de tuiles. */
    private int soundMergeId = -1;
    /** ID du son chargé pour la victoire (atteinte de 2048). */
    private int soundWinId = -1;
    /** ID du son chargé pour la fin de partie (Game Over). */
    private int soundGameOverId = -1;
    /** Indicateur si tous les sons ont été chargés avec succès dans le SoundPool. */
    private boolean soundPoolLoaded = false;

    /**
     * Lanceur d'activité pour demander la permission POST_NOTIFICATIONS (Android 13+).
     * Gère la réponse de l'utilisateur (accordée ou refusée).
     */
    private final ActivityResultLauncher<String> requestPermissionLauncher =
            registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> {
                if (isGranted) {
                    // Permission accordée !
                    Log.i(TAG, "Permission POST_NOTIFICATIONS accordée.");
                    notificationsEnabled = true;
                    saveNotificationPreference(true);
                    Toast.makeText(this, R.string.notifications_enabled, Toast.LENGTH_SHORT).show();
                    startNotificationService(); // Démarrer le service maintenant que la permission est OK
                } else {
                    // Permission refusée.
                    Log.w(TAG, "Permission POST_NOTIFICATIONS refusée.");
                    notificationsEnabled = false;
                    saveNotificationPreference(false);
                    // Met à jour l'interrupteur dans les paramètres si l'utilisateur vient de refuser
                    updateNotificationSwitchState(false); // Informe l'utilisateur et met à jour l'UI potentielle
                    Toast.makeText(this, R.string.notifications_disabled, Toast.LENGTH_SHORT).show();
                    stopNotificationService(); // Arrêter le service si la permission est refusée
                    // Optionnel : Afficher une explication pourquoi la notif est utile.
                    // showNotificationPermissionRationale();
                }
            });

    // --- Activity Lifecycle ---

    /**
     * Appelée lors de la création initiale de l'activité.
     * Configure la vue, initialise les composants (jeu, stats, son, listeners),
     * et charge l'état sauvegardé si disponible.
     *
     * @param savedInstanceState Si l'activité est recréée après avoir été détruite,
     * ce Bundle contient les données qu'elle a fournies le plus récemment
     * dans onSaveInstanceState(Bundle). Sinon, il est null.
     */
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        EdgeToEdge.enable(this); // Active l'affichage bord à bord si le thème le supporte
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main); // Définit le layout de l'activité

        Log.d(TAG, "onCreate: Initialisation de l'activité.");

        // Initialisation des composants
        findViews(); // Récupère les références des vues du layout
        NotificationHelper.createNotificationChannel(this); // Crée le canal (nécessaire pour API 26+)
        initializeSoundPool(); // Charge les effets sonores
        initializeGameAndStats(); // Initialise Game, GameStats et charge les données sauvegardées
        setupListeners(); // Attache les listeners aux boutons et au plateau

        // Démarrer le service de notification SEULEMENT si les notifications sont activées ET la permission est OK (implicite si < API 33)
        if (notificationsEnabled) {
            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
                startNotificationService();
            } else {
                Log.w(TAG, "Notifications activées dans les prefs, mais permission manquante sur API 33+. Service non démarré.");
                // On pourrait demander la permission ici si on veut être proactif
                // requestNotificationPermission();
            }
        }
    }

    /**
     * Appelée lorsque l'activité devient visible à l'utilisateur.
     * Redémarre le chronomètre de la partie si elle était en cours.
     * S'assure que le panneau de statistiques est affiché correctement s'il était visible
     * avant la mise en pause.
     */
    @Override
    protected void onResume() {
        super.onResume();
        Log.d(TAG, "onResume: Reprise de l'activité.");

        // Redémarre le timer seulement si une partie est activement en cours
        if (game != null && gameStats != null && currentGameState == GameFlowState.PLAYING) {
            // Utilise le temps sauvegardé lors du dernier onPause s'il existe,
            // ou redémarre simplement le chrono. Ici, on redémarre simplement.
            gameStats.setCurrentGameStartTimeMs(System.currentTimeMillis());
            Log.d(TAG, "onResume: Chronomètre de partie redémarré.");
        }

        // Gère le réaffichage potentiel des statistiques si l'activité reprend
        if (statisticsVisible) {
            if (inflatedStatsView != null) { // Si déjà gonflé
                updateStatisticsTextViews(); // Met à jour les données affichées
                inflatedStatsView.setVisibility(View.VISIBLE); // Assure la visibilité
                multiplayerButton.setVisibility(View.GONE); // Assure que le bouton multi est masqué
                Log.d(TAG, "onResume: Panneau de statistiques ré-affiché.");
            } else {
                // Si pas encore gonflé (cas rare mais possible), on le fait afficher
                // Normalement, toggleStatistics devrait être appelé, mais on force ici pour couvrir le cas.
                Log.w(TAG, "onResume: Panneau stats visible mais vue non gonflée. Tentative d'affichage via toggleStatistics.");
                statisticsVisible = false; // Force à re-basculer pour gonfler
                toggleStatistics();
            }
        } else {
            if (inflatedStatsView != null) {
                inflatedStatsView.setVisibility(View.GONE); // Assure qu'il est masqué si non visible
            }
            multiplayerButton.setVisibility(View.VISIBLE); // Assure que le bouton multi est visible
        }
    }

    /**
     * Appelée lorsque l'activité est sur le point de passer en arrière-plan.
     * Sauvegarde l'état actuel du jeu, les statistiques, et met à jour le temps de jeu total.
     * Enregistre également le timestamp actuel comme dernier moment d'activité.
     */
    @Override
    protected void onPause() {
        super.onPause();
        Log.d(TAG, "onPause: Mise en pause de l'activité.");

        // Sauvegarde l'état et les stats si le jeu existe
        if (game != null && gameStats != null) {
            Log.d(TAG, "onPause: Sauvegarde de l'état du jeu et des statistiques.");
            // Met à jour le temps total SI la partie était activement en cours
            if (currentGameState == GameFlowState.PLAYING) {
                long sessionDuration = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs();
                gameStats.addPlayTime(sessionDuration); // Ajoute le temps de la session au total
                Log.d(TAG, "onPause: Temps de jeu ajouté: " + sessionDuration + "ms");
            }
            saveGame(); // Sauvegarde l'état du jeu (plateau + score courant) et le HS via prefs
            gameStats.saveStats(); // Sauvegarde toutes les statistiques via GameStats (y compris HS)
            saveLastPlayedTime(); // Enregistre le moment actuel
        } else {
            Log.w(TAG, "onPause: Jeu ou GameStats est null, sauvegarde annulée.");
        }
    }

    /**
     * Appelée juste avant que l'activité ne soit détruite.
     * Libère les ressources critiques, notamment le SoundPool.
     */
    @Override
    protected void onDestroy() {
        super.onDestroy();
        Log.d(TAG, "onDestroy: Destruction de l'activité.");
        // Libère les ressources du SoundPool pour éviter les fuites de mémoire
        if (soundPool != null) {
            Log.d(TAG, "onDestroy: Libération du SoundPool.");
            soundPool.release();
            soundPool = null;
        }
        // Optionnel: Arrêter le service ici aussi, bien que ce soit moins courant
        // stopNotificationService();
    }

    // --- Initialisation ---

    /**
     * Récupère les références des vues principales du layout (activity_main.xml) via leur ID.
     * Doit être appelée dans onCreate après setContentView.
     */
    private void findViews() {
        Log.d(TAG, "findViews: Récupération des références des vues.");
        boardGridLayout = findViewById(R.id.gameBoard);
        currentScoreTextView = findViewById(R.id.scoreLabel);
        highestScoreTextView = findViewById(R.id.highScoreLabel);
        newGameButton = findViewById(R.id.restartButton);
        statisticsButton = findViewById(R.id.statsButton);
        menuButton = findViewById(R.id.menuButton);
        multiplayerButton = findViewById(R.id.multiplayerButton);
        statisticsViewStub = findViewById(R.id.statsViewStub);
        // inflatedStatsView sera initialisé lorsque le ViewStub est gonflé
    }

    /**
     * Initialise les objets Game et GameStats.
     * Charge les préférences utilisateur (son, notifications).
     * Charge l'état du jeu sauvegardé (s'il existe) et synchronise le meilleur score.
     * Met à jour l'interface utilisateur initiale. Si aucun jeu n'est chargé, démarre une nouvelle partie.
     */
    private void initializeGameAndStats() {
        Log.d(TAG, "initializeGameAndStats: Initialisation du jeu et des statistiques.");
        preferences = getSharedPreferences(PREFS_NAME, MODE_PRIVATE);

        // Charger les préférences utilisateur avant d'initialiser les objets qui en dépendent
        loadSoundPreference();
        loadNotificationPreference();

        // Initialiser GameStats (qui charge ses propres données depuis SharedPreferences)
        gameStats = new GameStats(this);

        // Charger l'état du jeu (Game) s'il existe
        loadGame(); // Tente de charger 'game' depuis SharedPreferences

        // Si loadGame n'a pas réussi à charger une partie valide (ou s'il n'y avait pas de sauvegarde)
        if (game == null) {
            Log.i(TAG, "initializeGameAndStats: Aucun état de jeu valide trouvé, démarrage d'une nouvelle partie.");
            startNewGame(); // Crée une nouvelle instance de 'game' et initialise l'UI
        } else {
            Log.i(TAG, "initializeGameAndStats: État de jeu chargé avec succès.");
            // Assure que le meilleur score dans l'objet Game chargé est synchronisé avec GameStats
            game.setHighestScore(gameStats.getOverallHighScore());
            updateUI(); // Met à jour l'UI avec le jeu chargé
        }
        // L'état 'currentGameState' est défini dans loadGame ou startNewGame
        Log.d(TAG, "initializeGameAndStats: État initial du jeu: " + currentGameState);
    }

    /**
     * Initialise le {@link SoundPool} et charge les différents effets sonores
     * utilisés dans le jeu depuis les ressources raw.
     * Met en place un listener pour savoir quand les sons sont prêts à être joués.
     */
    private void initializeSoundPool() {
        Log.d(TAG, "initializeSoundPool: Initialisation du SoundPool.");
        // Configuration pour les effets sonores de jeu
        AudioAttributes attributes = new AudioAttributes.Builder()
                .setUsage(AudioAttributes.USAGE_GAME) // Type d'usage approprié
                .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) // Type de contenu
                .build();

        // Crée le SoundPool avec un nombre maximum de flux simultanés
        soundPool = new SoundPool.Builder()
                .setMaxStreams(3) // Permet de jouer jusqu'à 3 sons en même temps
                .setAudioAttributes(attributes)
                .build();

        // Listener pour détecter la fin du chargement des sons
        soundPool.setOnLoadCompleteListener((soundPool, sampleId, status) -> {
            if (status == 0) {
                // Succès du chargement pour 'sampleId'
                Log.d(TAG, "SoundPool: Son ID " + sampleId + " chargé avec succès.");
                // Pourrait vérifier si tous les sons sont chargés ici, mais un flag global est plus simple
                // Vérifier si tous les ID attendus sont > 0 avant de mettre le flag
                if (soundMoveId > 0 && soundMergeId > 0 && soundWinId > 0 && soundGameOverId > 0) {
                    Log.i(TAG, "SoundPool: Tous les sons sont chargés.");
                    soundPoolLoaded = true;
                }
            } else {
                // Échec du chargement
                Log.e(TAG, "SoundPool: Échec du chargement du son ID " + sampleId + ", status: " + status);
                // On pourrait désactiver le son ou juste ignorer ce son spécifique
                soundEnabled = false; // Désactiver globalement par sécurité en cas d'échec
                saveSoundPreference(false); // Sauvegarde la désactivation
            }
        });

        // Charge les sons depuis res/raw. Les IDs retournés sont stockés.
        // Le 3ème argument (priority) est généralement 1 (priorité normale).
        try {
            Log.d(TAG, "SoundPool: Chargement des sons...");
            soundMoveId = soundPool.load(this, R.raw.move, 1);
            soundMergeId = soundPool.load(this, R.raw.merge, 1);
            soundWinId = soundPool.load(this, R.raw.win, 1);
            soundGameOverId = soundPool.load(this, R.raw.game_over, 1);
            // Vérifier immédiatement si les ID sont valides (load retourne 0 en cas d'erreur interne)
            if (soundMoveId == 0 || soundMergeId == 0 || soundWinId == 0 || soundGameOverId == 0) {
                throw new RuntimeException("SoundPool.load a retourné 0, échec du chargement d'un son.");
            }
        } catch (Exception e) {
            Log.e(TAG, "SoundPool: Erreur lors du chargement des sons.", e);
            // Gérer l'erreur, par exemple désactiver complètement le son
            soundEnabled = false;
            saveSoundPreference(false);
            Toast.makeText(this, "Erreur au chargement des sons, son désactivé.", Toast.LENGTH_LONG).show();
        }
    }

    /**
     * Configure les listeners pour les éléments interactifs :
     * - Boutons (Nouvelle Partie, Stats, Menu, Multijoueur)
     * - Plateau de jeu (détection des swipes via {@link OnSwipeTouchListener}).
     */
    private void setupListeners() {
        Log.d(TAG, "setupListeners: Configuration des listeners.");
        // Listener pour le bouton Nouvelle Partie
        newGameButton.setOnClickListener(v -> {
            v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press)); // Effet visuel
            showRestartConfirmationDialog(); // Affiche dialogue de confirmation
        });

        // Listener pour le bouton Statistiques
        statisticsButton.setOnClickListener(v -> {
            v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press));
            toggleStatistics(); // Affiche/Masque le panneau
        });

        // Listener pour le bouton Menu
        menuButton.setOnClickListener(v -> {
            v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press));
            showMenu(); // Affiche le dialogue du menu principal
        });

        // Listener pour le bouton Multijoueur (placeholder)
        multiplayerButton.setOnClickListener(v -> {
            v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press));
            showMultiplayerScreen(); // Affiche dialogue placeholder
        });

        // Attache le listener de swipe au GridLayout du plateau
        setupSwipeListener();
    }

    // --- Mise à jour UI ---

    /**
     * Met à jour l'ensemble de l'interface utilisateur pour refléter l'état actuel
     * du jeu et des statistiques. Appelle {@link #syncBoardView()} et {@link #updateScores()}.
     */
    private void updateUI() {
        Log.d(TAG, "updateUI: Mise à jour de l'interface utilisateur.");
        if (game == null) {
            Log.w(TAG, "updateUI: Tentative de mise à jour avec 'game' null.");
            return; // Ne rien faire si le jeu n'est pas initialisé
        }
        // Utilise syncBoardView pour redessiner le plateau de manière robuste
        syncBoardView();
        // Met à jour les labels de score
        updateScores();
        // Si les statistiques sont visibles, les mettre à jour aussi
        if (statisticsVisible && inflatedStatsView != null) {
            updateStatisticsTextViews();
        }
    }

    /**
     * Synchronise COMPLÈTEMENT le {@link GridLayout} (boardGridLayout) avec l'état actuel
     * du plateau logique dans l'objet {@link Game} (game.board).
     * Cette méthode est conçue pour être appelée après chaque modification logique du plateau.
     * Étapes :
     * 1. Supprime toutes les vues existantes du GridLayout.
     * 2. Réinitialise le tableau `tileViews` qui stocke les références aux TextViews des tuiles.
     * 3. Ajoute 16 vues de fond ({@code View}) pour structurer la grille visuellement.
     * 4. Ajoute les {@code TextView} pour chaque tuile réelle (valeur > 0) par-dessus les fonds,
     * en utilisant {@link #createTileTextView(int, int, int)}.
     * 5. Stocke les références des TextViews des tuiles réelles dans `tileViews`.
     * 6. Met à jour l'affichage textuel des scores via {@link #updateScores()}.
     */
    private void syncBoardView() {
        // Vérifications de sécurité
        if (game == null || boardGridLayout == null) {
            Log.e(TAG, "syncBoardView: Impossible de synchroniser, Game ou GridLayout est null !");
            return;
        }
        // Log.d(TAG, "syncBoardView: Synchronisation du plateau...");

        // --- Réinitialisation ---
        boardGridLayout.removeAllViews(); // Vide complètement le GridLayout visuel
        // Réinitialise le tableau qui stocke les références aux *vraies* tuiles
        for (int r = 0; r < BOARD_SIZE; r++) {
            for (int c = 0; c < BOARD_SIZE; c++) {
                tileViews[r][c] = null;
            }
        }

        // Récupère la marge et le contexte une seule fois pour l'optimisation
        int gridMargin = (int) getResources().getDimension(R.dimen.tile_margin);
        Context context = this; // Contexte de l'activité

        // --- Étape 1: Ajouter les 16 vues de fond pour définir la grille ---
        for (int r = 0; r < BOARD_SIZE; r++) {
            for (int c = 0; c < BOARD_SIZE; c++) {
                // Utiliser un simple View pour le fond
                View backgroundCell = new View(context);

                // Appliquer le style d'une cellule vide (fond + coins arrondis)
                backgroundCell.setBackgroundResource(R.drawable.tile_background);
                backgroundCell.getBackground().setTintList(ContextCompat.getColorStateList(context, R.color.tile_empty));


                // Définir les LayoutParams pour positionner cette vue de fond
                GridLayout.LayoutParams params = new GridLayout.LayoutParams();
                params.width = 0; // Largeur gérée par poids
                params.height = 0; // Hauteur gérée par poids
                // Spécifier ligne, colonne, span=1, et poids=1 pour occuper la cellule
                params.rowSpec = GridLayout.spec(r, 1, 1f);
                params.columnSpec = GridLayout.spec(c, 1, 1f);
                params.setMargins(gridMargin, gridMargin, gridMargin, gridMargin);
                backgroundCell.setLayoutParams(params);

                // Ajouter cette vue de fond au GridLayout
                boardGridLayout.addView(backgroundCell);
            }
        }

        // --- Étape 2: Ajouter les TextViews des tuiles réelles (par-dessus les fonds) ---
        for (int r = 0; r < BOARD_SIZE; r++) {
            for (int c = 0; c < BOARD_SIZE; c++) {
                int value = game.getCellValue(r, c); // Récupère la valeur logique

                // Si la case logique contient une tuile réelle (valeur > 0)
                if (value > 0) {
                    // Crée la TextView stylisée pour cette tuile via la méthode helper
                    TextView tileTextView = createTileTextView(value, r, c);

                    // Stocke la référence à cette TextView de tuile
                    tileViews[r][c] = tileTextView;

                    // Ajoute la TextView de la tuile au GridLayout. Elle se superposera au fond.
                    boardGridLayout.addView(tileTextView);
                    // Log.d(TAG, "syncBoardView: Ajout de la tuile [" + r + "," + c + "] valeur " + value);
                }
            }
        }
        // Log.d(TAG, "syncBoardView: Synchronisation terminée.");

        // Met à jour l'affichage textuel des scores après avoir redessiné le plateau
        updateScores();
    }

    /**
     * Crée et configure une {@link TextView} pour représenter une tuile du jeu.
     * Applique le style visuel approprié (couleur, taille de texte) basé sur la valeur
     * de la tuile via {@link #setTileStyle(TextView, int)}.
     * Configure les {@link GridLayout.LayoutParams} pour positionner correctement la tuile
     * dans la grille avec les marges appropriées.
     *
     * @param value La valeur numérique de la tuile (doit être > 0).
     * @param row   L'indice de la ligne (0 à BOARD_SIZE-1) où la tuile doit être placée.
     * @param col   L'indice de la colonne (0 à BOARD_SIZE-1) où la tuile doit être placée.
     * @return La {@code TextView} configurée et prête à être ajoutée au GridLayout.
     */
    @NonNull
    private TextView createTileTextView(int value, int row, int col) {
        TextView tileTextView = new TextView(this);
        setTileStyle(tileTextView, value); // Applique le style visuel (couleur, taille texte)

        // Configure les LayoutParams pour le positionnement dans le GridLayout
        GridLayout.LayoutParams params = new GridLayout.LayoutParams();
        params.width = 0; // Largeur gérée par poids
        params.height = 0; // Hauteur gérée par poids
        // Spécifie la position (ligne, colonne), le span (1x1) et le poids (1f)
        params.rowSpec = GridLayout.spec(row, 1, 1f);
        params.columnSpec = GridLayout.spec(col, 1, 1f);

        // Applique les marges externes
        int margin = (int) getResources().getDimension(R.dimen.tile_margin);
        params.setMargins(margin, margin, margin, margin);
        tileTextView.setLayoutParams(params);

        return tileTextView;
    }


    /**
     * Met à jour les TextViews affichant le score courant et le meilleur score global.
     * Utilise les valeurs actuelles de l'objet {@link Game}.
     */
    private void updateScores() {
        if (game != null && currentScoreTextView != null && highestScoreTextView != null) {
            // Log.d(TAG, "updateScores: Score=" + game.getCurrentScore() + ", HighScore=" + game.getHighestScore());
            currentScoreTextView.setText(getString(R.string.score_placeholder, game.getCurrentScore()));
            highestScoreTextView.setText(getString(R.string.high_score_placeholder, game.getHighestScore()));
        } else {
            Log.w(TAG, "updateScores: Impossible de mettre à jour les scores (game ou TextViews null).");
        }
    }

    /**
     * Applique le style visuel (couleur de fond, couleur de texte, taille de texte)
     * à une {@link TextView} représentant une tuile, en fonction de sa valeur numérique.
     * Gère également le cas des cases vides (valeur 0).
     *
     * @param tileTextView La TextView de la tuile à styliser.
     * @param value        La valeur numérique de la tuile (0 pour une case vide).
     */
    private void setTileStyle(@NonNull TextView tileTextView, int value) {
        // Définit le texte (valeur numérique ou chaîne vide pour 0)
        tileTextView.setText(value > 0 ? String.valueOf(value) : "");
        tileTextView.setGravity(Gravity.CENTER); // Centre le texte
        tileTextView.setTypeface(null, android.graphics.Typeface.BOLD); // Texte en gras

        int backgroundColorId;
        int textColorId;
        int textSizeId;

        // Sélectionne les couleurs et la taille de texte en fonction de la valeur de la tuile
        switch (value) {
            case 0:    backgroundColorId = R.color.tile_empty; textColorId = android.R.color.transparent; textSizeId = R.dimen.text_size_tile_small; break; // Case vide
            case 2:    backgroundColorId = R.color.tile_2;     textColorId = R.color.text_tile_low;     textSizeId = R.dimen.text_size_tile_small; break;
            case 4:    backgroundColorId = R.color.tile_4;     textColorId = R.color.text_tile_low;     textSizeId = R.dimen.text_size_tile_small; break;
            case 8:    backgroundColorId = R.color.tile_8;     textColorId = R.color.text_tile_high;    textSizeId = R.dimen.text_size_tile_small; break;
            case 16:   backgroundColorId = R.color.tile_16;    textColorId = R.color.text_tile_high;    textSizeId = R.dimen.text_size_tile_small; break;
            case 32:   backgroundColorId = R.color.tile_32;    textColorId = R.color.text_tile_high;    textSizeId = R.dimen.text_size_tile_small; break;
            case 64:   backgroundColorId = R.color.tile_64;    textColorId = R.color.text_tile_high;    textSizeId = R.dimen.text_size_tile_small; break;
            case 128:  backgroundColorId = R.color.tile_128;   textColorId = R.color.text_tile_high;    textSizeId = R.dimen.text_size_tile_medium; break; // Taille texte réduite pour 3 chiffres
            case 256:  backgroundColorId = R.color.tile_256;   textColorId = R.color.text_tile_high;    textSizeId = R.dimen.text_size_tile_medium; break;
            case 512:  backgroundColorId = R.color.tile_512;   textColorId = R.color.text_tile_high;    textSizeId = R.dimen.text_size_tile_medium; break;
            case 1024: backgroundColorId = R.color.tile_1024;  textColorId = R.color.text_tile_high;    textSizeId = R.dimen.text_size_tile_large; break; // Taille texte encore réduite pour 4 chiffres
            case 2048: backgroundColorId = R.color.tile_2048;  textColorId = R.color.text_tile_high;    textSizeId = R.dimen.text_size_tile_large; break;
            default:   backgroundColorId = R.color.tile_super;   textColorId = R.color.text_tile_high;    textSizeId = R.dimen.text_size_tile_large; break; // Tuiles > 2048
        }

        // Applique le fond (drawable avec coins arrondis) et la teinte
        tileTextView.setBackgroundResource(R.drawable.tile_background);
        tileTextView.getBackground().setTint(ContextCompat.getColor(this, backgroundColorId));

        // Applique la couleur du texte
        tileTextView.setTextColor(ContextCompat.getColor(this, textColorId));

        // Applique la taille du texte (convertie de sp/dp en pixels)
        tileTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, getResources().getDimension(textSizeId));
    }


    // --- Gestion des Actions Utilisateur ---

    /**
     * Configure le listener pour détecter les gestes de balayage (swipe) sur le
     * {@link GridLayout} représentant le plateau de jeu. Utilise une instance
     * de {@link OnSwipeTouchListener}.
     */
    @SuppressLint("ClickableViewAccessibility") // Nécessaire car setOnTouchListener est utilisé
    private void setupSwipeListener() {
        if (boardGridLayout == null) {
            Log.e(TAG, "setupSwipeListener: boardGridLayout est null, impossible d'attacher le listener.");
            return;
        }
        Log.d(TAG, "setupSwipeListener: Configuration du listener de swipe sur le plateau.");
        boardGridLayout.setOnTouchListener(new OnSwipeTouchListener(this, new OnSwipeTouchListener.SwipeListener() {
            @Override public void onSwipeTop()    { handleSwipe(Direction.UP);    }
            @Override public void onSwipeBottom() { handleSwipe(Direction.DOWN);  }
            @Override public void onSwipeLeft()   { handleSwipe(Direction.LEFT);  }
            @Override public void onSwipeRight()  { handleSwipe(Direction.RIGHT); }
        }));
    }

    /**
     * Gère un geste de swipe détecté sur le plateau.
     * 1. Vérifie si le jeu est dans un état où un mouvement est autorisé.
     * 2. Appelle la méthode de logique de jeu appropriée (`game.pushX()`).
     * 3. Si le plateau a changé :
     * a. Joue les sons appropriés (mouvement, fusion).
     * b. Enregistre les statistiques (mouvement, fusion, tuile max, score).
     * c. Ajoute une nouvelle tuile logique via `game.addNewTile()`.
     * d. Met à jour l'affichage COMPLET du plateau via `syncBoardView()`.
     * e. Lance les animations locales (apparition, fusion) sur les vues mises à jour via `animateChanges()`.
     * 4. Si le plateau n'a pas changé, vérifie quand même si c'est un état de Game Over.
     * 5. La vérification finale des conditions de victoire/défaite est déléguée à `checkEndGameConditions()`,
     * appelée après la fin des animations.
     *
     * @param direction La direction du swipe détecté ({@link Direction}).
     */
    private void handleSwipe(Direction direction) {
        Log.d(TAG, "handleSwipe: Geste détecté -> " + direction);

        // Bloque les mouvements si le jeu n'est pas initialisé ou est terminé
        if (game == null || gameStats == null || currentGameState == GameFlowState.GAME_OVER) {
            Log.d(TAG, "handleSwipe: Mouvement ignoré (jeu non prêt ou terminé). State: " + currentGameState);
            return;
        }
        // On pourrait aussi bloquer pendant une animation si nécessaire, mais l'approche actuelle
        // met à jour l'UI puis anime, ce qui rend un blocage moins critique.

        // Sauvegarde l'état avant le mouvement pour les animations et la détection de changement
        int scoreBefore = game.getCurrentScore();
        int[][] boardBeforePush = game.getBoard(); // Copie profonde de l'état avant

        boolean boardChanged = false; // Indicateur si le mouvement a modifié le plateau logique

        // Appelle la méthode logique correspondante dans Game
        switch (direction) {
            case UP:    boardChanged = game.pushUp();    break;
            case DOWN:  boardChanged = game.pushDown();  break;
            case LEFT:  boardChanged = game.pushLeft();  break;
            case RIGHT: boardChanged = game.pushRight(); break;
        }

        // Si le mouvement a effectivement modifié quelque chose sur le plateau logique...
        if (boardChanged) {
            Log.d(TAG, "handleSwipe: Le plateau a changé.");
            playSound(soundMoveId); // Joue le son de mouvement

            // Capture l'état APRÈS le push mais AVANT l'ajout de la nouvelle tuile (utile pour animer les fusions)
            int[][] boardAfterPush = game.getBoard();

            // --- Mise à jour des statistiques ---
            gameStats.recordMove(); // Enregistre le mouvement
            int scoreAfter = game.getCurrentScore();
            if (scoreAfter > scoreBefore) { // Si le score a augmenté, une fusion a eu lieu
                Log.d(TAG, "handleSwipe: Fusion détectée (score " + scoreBefore + " -> " + scoreAfter + ")");
                playSound(soundMergeId); // Joue le son de fusion
                // Simplifié : on compte 1 pour chaque mouvement entraînant une augmentation de score.
                // Pour être précis, il faudrait que game.pushX retourne le nombre de fusions.
                gameStats.recordMerge(1); // TODO: Améliorer pour compter précisément les fusions si nécessaire

                // Vérifie et met à jour le meilleur score si nécessaire
                if (scoreAfter > game.getHighestScore()) {
                    Log.i(TAG, "handleSwipe: Nouveau High Score! " + scoreAfter);
                    game.setHighestScore(scoreAfter); // Met à jour dans l'objet Game
                    gameStats.setHighestScore(scoreAfter); // Met à jour dans GameStats (sera sauvegardé via saveStats)
                    // On pourrait envoyer une notif de nouveau HS ici si désiré et permis
                    // showNotification(this, "Nouveau Record !", "Meilleur score : " + scoreAfter, 4);
                }
            }
            // Met à jour la stat de la plus haute tuile atteinte globalement
            gameStats.updateHighestTile(game.getHighestTileValue());

            // --- Ajout de la nouvelle tuile ---
            Log.d(TAG, "handleSwipe: Ajout d'une nouvelle tuile...");
            game.addNewTile();
            int[][] boardAfterAdd = game.getBoard(); // État logique final après ajout

            // --- Mise à jour VISUELLE du plateau ---
            Log.d(TAG, "handleSwipe: Synchronisation de la vue du plateau...");
            syncBoardView(); // Redessine TOUT le plateau basé sur boardAfterAdd

            // --- Lancement des ANIMATIONS locales ---
            Log.d(TAG, "handleSwipe: Lancement des animations...");
            animateChanges(boardBeforePush, boardAfterPush, boardAfterAdd); // Anime apparition/fusion

            // La vérification de fin de partie (checkEndGameConditions) est maintenant appelée
            // à la fin des animations dans animateChanges.

        } else {
            // Le mouvement n'a rien changé sur le plateau logique.
            Log.d(TAG, "handleSwipe: Mouvement invalide (aucune tuile n'a bougé).");
            // Vérifier si c'est un état de Game Over (plateau plein et bloqué)
            // Note: game.pushX() a déjà appelé checkGameOverCondition, on vérifie juste le flag.
            if (game.isGameOver() && currentGameState != GameFlowState.GAME_OVER) {
                Log.i(TAG, "handleSwipe: Mouvement invalide ET état Game Over détecté.");
                currentGameState = GameFlowState.GAME_OVER; // Met à jour l'état
                long timeTaken = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs();
                gameStats.recordLoss(); // Enregistre la défaite
                // gameStats.endGame(timeTaken); // endGame est appelé par recordLoss/recordWin
                showGameOverDialog(); // Affiche le dialogue de fin
            }
        }
    }

    /**
     * Joue un effet sonore via le SoundPool, si celui-ci est chargé,
     * si le son est globalement activé par l'utilisateur (`soundEnabled`),
     * si le SoundPool lui-même est initialisé et si l'ID du son est valide.
     * Ne fait rien si une de ces conditions n'est pas remplie.
     *
     * @param soundId L'ID entier du son à jouer, tel que retourné par {@code soundPool.load()}.
     * Doit être supérieur à 0 pour être valide.
     */
    private void playSound(int soundId) {
        // Vérifie toutes les conditions avant de tenter de jouer le son
        if (soundPoolLoaded && soundEnabled && soundPool != null && soundId > 0) {
            // Arguments de soundPool.play:
            // soundID: L'ID du son chargé.
            // leftVolume: Volume pour le canal gauche (0.0 à 1.0).
            // rightVolume: Volume pour le canal droit (0.0 à 1.0).
            // priority: Priorité du flux (0 = la plus basse). Non utilisé pour la dépréemption.
            // loop: Mode de boucle (-1 = boucle infinie, 0 = ne pas boucler, >0 = nombre de répétitions).
            // rate: Vitesse de lecture (1.0 = vitesse normale, 0.5 = demi-vitesse, 2.0 = double vitesse).
            soundPool.play(soundId, 1.0f, 1.0f, 1, 0, 1.0f);
            // Log.d(TAG, "playSound: Joué son ID " + soundId); // Décommenter pour débogage
        } else {
            // Log pour déboguer pourquoi le son n'est pas joué
            /*
            Log.d(TAG, "playSound: Son ID " + soundId + " non joué. Conditions: " +
                       "soundPoolLoaded=" + soundPoolLoaded +
                       ", soundEnabled=" + soundEnabled +
                       ", soundPool != null=" + (soundPool != null) +
                       ", soundId > 0=" + (soundId > 0));
            */
        }
    }

    /**
     * Analyse les différences entre l'état du plateau avant et après un mouvement
     * pour identifier les tuiles qui sont apparues ou qui ont fusionné.
     * Lance des animations locales (scale, alpha) sur les {@link TextView} correspondantes
     * qui ont DÉJÀ été mises à jour et positionnées par {@link #syncBoardView()}.
     * À la fin de l'ensemble des animations, appelle {@link #checkEndGameConditions()}.
     *
     * @param boardBeforePush État du plateau (valeurs) avant le début du mouvement/fusion.
     * @param boardAfterPush  État du plateau après le mouvement/fusion mais AVANT l'ajout de la nouvelle tuile.
     * @param boardAfterAdd   État final du plateau après l'ajout de la nouvelle tuile.
     */
    private void animateChanges(@NonNull int[][] boardBeforePush, @NonNull int[][] boardAfterPush, @NonNull int[][] boardAfterAdd) {
        List<Animator> animations = new ArrayList<>(); // Liste pour regrouper les animations

        // Parcourt le plateau final
        for (int r = 0; r < BOARD_SIZE; r++) {
            for (int c = 0; c < BOARD_SIZE; c++) {
                TextView currentView = tileViews[r][c]; // Récupère la vue TextView à la position finale [r][c]

                // Si pas de vue à cette position (case vide dans l'état final), on passe
                if (currentView == null) continue;

                int valueAfterAdd = boardAfterAdd[r][c];    // Valeur finale
                int valueAfterPush = boardAfterPush[r][c]; // Valeur intermédiaire (après fusion, avant ajout)
                // int valueBeforePush = boardBeforePush[r][c]; // Valeur initiale à [r][c] (pas toujours utile ici)

                // Trouver la ou les tuiles d'origine qui ont mené à cette position
                // C'est complexe. Simplifions : animons basé sur ce qui s'est passé à la position [r][c]

                // 1. Animation d'Apparition :
                // Si la case [r][c] était vide après le push, mais a une valeur maintenant (après add),
                // c'est la NOUVELLE tuile qui vient d'apparaître à cette position.
                if (valueAfterPush == 0 && valueAfterAdd > 0) {
                    // Log.d(TAG, "animateChanges: Animation APPARITION pour [" + r + "," + c + "]");
                    // Prépare la vue pour l'animation (petite et transparente)
                    currentView.setScaleX(0.3f);
                    currentView.setScaleY(0.3f);
                    currentView.setAlpha(0f);
                    // Crée et ajoute l'animation d'apparition
                    animations.add(createAppearAnimation(currentView));
                }
                // 2. Animation de Fusion :
                // Si la valeur à [r][c] APRÈS le push est plus grande que la valeur AVANT le push
                // à CETTE MÊME position [r][c] (cela signifie qu'une fusion s'est produite ICI).
                // Note: Cette condition est approximative. Une tuile peut s'être déplacée PUIS avoir fusionné ailleurs.
                // Ou une tuile peut avoir fusionné DANS cette case.
                // Pour une animation de "pulse" simple sur la tuile résultante de la fusion :
                boolean fusionOccurredAtThisPosition = false;
                // Vérifier si la valeur a augmenté à cette position pendant la phase de push/merge
                if (valueAfterPush > boardBeforePush[r][c] && boardBeforePush[r][c] != 0) {
                    fusionOccurredAtThisPosition = true;
                }
                // Vérifier aussi si une tuile s'est déplacée vers ici et a fusionné (valeur avant=0, valeur après push > 0 ET différente de la valeur après add si c'était la nouvelle tuile)
                // C'est complexe. L'approche la plus simple est d'animer si la valeur a augmenté par rapport à l'état AVANT le push.
                else {
                    // Chercher si une tuile adjacente de même valeur a disparu lors du push
                    // ... logique complexe ...
                }

                // Si on détecte une fusion à cette position [r][c] (par augmentation de valeur par ex)
                // ET que ce n'est pas la nouvelle tuile qui vient d'apparaître (géré au-dessus)
                if (fusionOccurredAtThisPosition && !(valueAfterPush == 0 && valueAfterAdd > 0)) {
                    // Log.d(TAG, "animateChanges: Animation FUSION pour [" + r + "," + c + "]");
                    // Crée et ajoute l'animation de fusion (pulse)
                    animations.add(createMergeAnimation(currentView));
                }

                // Note : Les tuiles qui ont simplement bougé ne sont pas explicitement animées ici.
                // L'effet visuel vient du fait que syncBoardView les a placées à leur nouvelle position.
            }
        }

        // Si des animations ont été créées, on les joue ensemble
        if (!animations.isEmpty()) {
            Log.d(TAG, "animateChanges: Démarrage de " + animations.size() + " animations.");
            AnimatorSet animatorSet = new AnimatorSet();
            animatorSet.playTogether(animations); // Joue toutes les animations en parallèle
            animatorSet.addListener(new AnimatorListenerAdapter() {
                @Override
                public void onAnimationEnd(Animator animation) {
                    // Appelée quand TOUTES les animations de l'AnimatorSet sont terminées
                    Log.d(TAG, "animateChanges: Fin des animations.");
                    // Vérifie maintenant les conditions de fin de partie
                    checkEndGameConditions();
                }
            });
            animatorSet.start(); // Démarre l'ensemble des animations
        } else {
            // Si aucune animation n'a été générée (ex: mouvement simple sans fusion ni nouvelle tuile)
            Log.d(TAG, "animateChanges: Aucune animation à jouer.");
            // Vérifie quand même immédiatement les conditions de fin de partie
            checkEndGameConditions();
        }
    }

    /**
     * Crée une animation d'apparition pour une vue (typiquement une nouvelle tuile).
     * L'animation combine une augmentation de taille (scale) et une apparition en fondu (alpha).
     *
     * @param view La vue (TextView de la tuile) à animer.
     * @return Un objet {@link Animator} représentant l'animation d'apparition.
     */
    private Animator createAppearAnimation(@NonNull View view) {
        // Animation de 0.3 à 1.0 pour la largeur et la hauteur
        ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 0.3f, 1f);
        ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 0.3f, 1f);
        // Animation de 0.0 (transparent) à 1.0 (opaque) pour l'alpha
        ObjectAnimator alpha = ObjectAnimator.ofFloat(view, "alpha", 0f, 1f);

        // Regroupe les animations pour les jouer en même temps
        AnimatorSet set = new AnimatorSet();
        set.playTogether(scaleX, scaleY, alpha);
        set.setDuration(150); // Durée courte pour l'apparition (en ms)
        return set;
    }

    /**
     * Crée une animation de "pulse" (léger grossissement puis retour à la normale)
     * pour une vue (typiquement une tuile résultant d'une fusion).
     *
     * @param view La vue (TextView de la tuile fusionnée) à animer.
     * @return Un objet {@link Animator} représentant l'animation de fusion.
     */
    private Animator createMergeAnimation(@NonNull View view) {
        // Animation de 1.0 à 1.2 puis retour à 1.0 pour la largeur et la hauteur
        ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 1f, 1.2f, 1f);
        ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 1f, 1.2f, 1f);

        // Regroupe les animations
        AnimatorSet set = new AnimatorSet();
        set.playTogether(scaleX, scaleY);
        set.setDuration(120); // Durée très courte pour l'effet "pulse" (en ms)
        return set;
    }

    /**
     * Vérifie les conditions de fin de partie (Victoire ou Game Over) après qu'un
     * mouvement (et ses animations éventuelles) a été complété.
     * Met à jour l'état du jeu (`currentGameState`) et affiche les dialogues appropriés.
     * Enregistre également les statistiques de victoire/défaite.
     */
    private void checkEndGameConditions() {
        Log.d(TAG, "checkEndGameConditions: Vérification de l'état de fin de partie.");
        if (game == null || gameStats == null) {
            Log.w(TAG, "checkEndGameConditions: Jeu ou Stats non initialisé.");
            return;
        }

        // Vérifier la condition de VICTOIRE
        // On vérifie si le jeu est gagné ET si on n'a pas déjà montré le dialogue de victoire
        if (game.isGameWon() && currentGameState == GameFlowState.PLAYING) {
            Log.i(TAG, "checkEndGameConditions: Victoire détectée !");
            currentGameState = GameFlowState.WON_DIALOG_SHOWN; // Marque que le dialogue va être montré
            long timeTaken = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs();
            gameStats.recordWin(timeTaken); // Enregistre la victoire et met à jour les temps
            showGameWonKeepPlayingDialog(); // Affiche le dialogue "Gagné / Continuer ?"
            // Si une notification d'accomplissement est souhaitée :
            showAchievementNotification(game.getHighestTileValue());

            // Vérifier la condition de GAME OVER (si pas déjà gagné ou terminé)
        } else if (game.isGameOver() && currentGameState != GameFlowState.GAME_OVER) {
            Log.i(TAG, "checkEndGameConditions: Game Over détecté !");
            currentGameState = GameFlowState.GAME_OVER; // Met à jour l'état
            long timeTaken = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs();
            gameStats.recordLoss(); // Enregistre la défaite
            // gameStats.endGame(timeTaken); // Est appelé par recordLoss
            showGameOverDialog(); // Affiche le dialogue "Game Over"

            // Sinon, le jeu continue normalement
        } else {
            Log.d(TAG, "checkEndGameConditions: Le jeu continue.");
        }
        // Sauvegarder l'état après chaque vérification pourrait être utile
        // saveGame();
        // gameStats.saveStats();
    }

    /** Énumération interne simple pour représenter les directions de swipe. */
    private enum Direction { UP, DOWN, LEFT, RIGHT }

    // --- Dialogues ---

    /**
     * Affiche une boîte de dialogue demandant à l'utilisateur de confirmer
     * s'il souhaite réellement redémarrer la partie en cours.
     * Utilise le layout personnalisé `dialog_restart_confirm.xml`.
     */
    private void showRestartConfirmationDialog() {
        Log.d(TAG, "Affichage dialogue confirmation redémarrage.");
        AlertDialog.Builder builder = new AlertDialog.Builder(this);
        LayoutInflater inflater = getLayoutInflater();
        View dialogView = inflater.inflate(R.layout.dialog_restart_confirm, null); // Gonfle le layout personnalisé
        builder.setView(dialogView);
        // Empêche de fermer en cliquant à l'extérieur
        builder.setCancelable(false);

        Button cancelButton = dialogView.findViewById(R.id.dialogCancelButton);
        Button confirmButton = dialogView.findViewById(R.id.dialogConfirmButton);

        final AlertDialog dialog = builder.create(); // Crée la dialogue mais ne l'affiche pas encore

        // Listener pour le bouton Annuler
        cancelButton.setOnClickListener(v -> {
            Log.d(TAG, "Dialogue redémarrage: Annulé.");
            dialog.dismiss(); // Ferme la dialogue
        });

        // Listener pour le bouton Confirmer
        confirmButton.setOnClickListener(v -> {
            Log.d(TAG, "Dialogue redémarrage: Confirmé.");
            dialog.dismiss(); // Ferme la dialogue
            startNewGame(); // Lance une nouvelle partie
        });

        dialog.show(); // Affiche la dialogue
    }

    /**
     * Démarre une nouvelle partie.
     * 1. Informe `GameStats` que la nouvelle partie commence (pour réinitialiser les stats de partie).
     * 2. Crée une nouvelle instance de `Game`.
     * 3. Applique le meilleur score global (connu par `GameStats`) à cette nouvelle partie.
     * 4. Met l'état du jeu (`currentGameState`) à `PLAYING`.
     * 5. Ferme le panneau de statistiques s'il était ouvert.
     * 6. Met à jour l'interface utilisateur (plateau, scores) pour refléter la nouvelle partie.
     */
    private void startNewGame() {
        Log.i(TAG, "startNewGame: Démarrage d'une nouvelle partie.");
        if (gameStats == null) {
            Log.e(TAG, "startNewGame: GameStats est null! Tentative de réinitialisation.");
            gameStats = new GameStats(this); // Précaution
        }
        // Informe GameStats du début de la partie (reset timers/compteurs de partie)
        gameStats.startGame();

        // Crée une nouvelle instance de la logique du jeu
        game = new Game();
        // Applique le meilleur score global connu au nouvel objet Game
        game.setHighestScore(gameStats.getOverallHighScore());

        // Définit l'état du flux de jeu
        currentGameState = GameFlowState.PLAYING;

        // Ferme le panneau de statistiques s'il était ouvert
        if (statisticsVisible) {
            Log.d(TAG, "startNewGame: Fermeture du panneau de statistiques.");
            toggleStatistics(); // Utilise la méthode existante pour masquer proprement
        }

        // Met à jour l'interface utilisateur pour afficher le nouveau plateau et les scores (0)
        updateUI(); // Appellera syncBoardView et updateScores
    }


    /**
     * Affiche la boîte de dialogue signalant que le joueur a gagné (atteint 2048).
     * Utilise le layout personnalisé `dialog_game_won.xml`.
     * Propose de continuer à jouer ("Keep Playing") ou de commencer une nouvelle partie.
     * Joue le son de victoire.
     */
    private void showGameWonKeepPlayingDialog() {
        Log.i(TAG, "Affichage dialogue de victoire.");
        playSound(soundWinId); // Joue le son de victoire

        AlertDialog.Builder builder = new AlertDialog.Builder(this);
        LayoutInflater inflater = getLayoutInflater();
        View dialogView = inflater.inflate(R.layout.dialog_game_won, null); // Gonfle le layout
        builder.setView(dialogView);
        builder.setCancelable(false); // Empêche de fermer sans choisir une option

        Button keepPlayingButton = dialogView.findViewById(R.id.dialogKeepPlayingButton);
        Button newGameButtonDialog = dialogView.findViewById(R.id.dialogNewGameButtonWon); // Renommé pour éviter conflit

        final AlertDialog dialog = builder.create();

        // Listener pour "Continuer à jouer"
        keepPlayingButton.setOnClickListener(v -> {
            Log.d(TAG, "Dialogue victoire: Continué.");
            // L'état est déjà WON_DIALOG_SHOWN, le jeu peut continuer si l'utilisateur swipe.
            // On ferme juste la dialogue.
            dialog.dismiss();
        });

        // Listener pour "Nouvelle Partie"
        newGameButtonDialog.setOnClickListener(v -> {
            Log.d(TAG, "Dialogue victoire: Nouvelle partie.");
            dialog.dismiss();
            startNewGame(); // Démarre une nouvelle partie
        });

        dialog.show();
    }

    /**
     * Affiche la boîte de dialogue signalant la fin de partie (Game Over).
     * Utilise le layout personnalisé `dialog_game_over.xml`.
     * Affiche le score final et propose de commencer une Nouvelle Partie ou de Quitter l'application.
     * Joue le son de fin de partie.
     */
    private void showGameOverDialog() {
        Log.i(TAG, "Affichage dialogue Game Over. Score final: " + (game != null ? game.getCurrentScore() : "N/A"));
        playSound(soundGameOverId); // Joue le son de Game Over

        AlertDialog.Builder builder = new AlertDialog.Builder(this);
        LayoutInflater inflater = getLayoutInflater();
        View dialogView = inflater.inflate(R.layout.dialog_game_over, null); // Gonfle le layout
        builder.setView(dialogView);
        builder.setCancelable(false); // Empêche de fermer sans choisir

        TextView messageTextView = dialogView.findViewById(R.id.dialogMessageGameOver);
        Button newGameButtonDialog = dialogView.findViewById(R.id.dialogNewGameButtonGameOver); // Renommé
        Button quitButton = dialogView.findViewById(R.id.dialogQuitButtonGameOver);

        // Affiche le message avec le score final
        if (game != null) {
            messageTextView.setText(getString(R.string.game_over_message, game.getCurrentScore()));
        } else {
            messageTextView.setText(getString(R.string.game_over_title)); // Message générique si game est null
        }

        final AlertDialog dialog = builder.create();

        // Listener pour "Nouvelle Partie"
        newGameButtonDialog.setOnClickListener(v -> {
            Log.d(TAG, "Dialogue Game Over: Nouvelle partie.");
            dialog.dismiss();
            startNewGame();
        });

        // Listener pour "Quitter"
        quitButton.setOnClickListener(v -> {
            Log.d(TAG, "Dialogue Game Over: Quitter.");
            dialog.dismiss();
            finish(); // Ferme l'activité actuelle (et l'application si c'est la seule activité)
        });

        dialog.show();
    }

    // --- Menu Principal ---

    /**
     * Affiche la boîte de dialogue du menu principal.
     * Utilise le layout personnalisé `dialog_main_menu.xml`.
     * Contient des boutons pour "Comment Jouer", "Paramètres", "À Propos", et "Retour".
     */
    private void showMenu() {
        Log.d(TAG, "Affichage dialogue du menu principal.");
        AlertDialog.Builder builder = new AlertDialog.Builder(this);
        LayoutInflater inflater = getLayoutInflater();
        View dialogView = inflater.inflate(R.layout.dialog_main_menu, null); // Gonfle le layout personnalisé
        builder.setView(dialogView);
        builder.setCancelable(true); // Permet de fermer en cliquant à côté

        // Récupère les boutons du layout personnalisé
        Button howToPlayButton = dialogView.findViewById(R.id.menuButtonHowToPlay);
        Button settingsButton = dialogView.findViewById(R.id.menuButtonSettings);
        Button aboutButton = dialogView.findViewById(R.id.menuButtonAbout);
        Button returnButton = dialogView.findViewById(R.id.menuButtonReturn);

        final AlertDialog dialog = builder.create();

        // Attache les listeners aux boutons du menu
        howToPlayButton.setOnClickListener(v -> {
            Log.d(TAG, "Menu: Clic sur Comment Jouer.");
            dialog.dismiss(); // Ferme le menu
            showHowToPlayDialog(); // Ouvre la dialogue "Comment Jouer"
        });

        settingsButton.setOnClickListener(v -> {
            Log.d(TAG, "Menu: Clic sur Paramètres.");
            dialog.dismiss(); // Ferme le menu
            showSettingsDialog(); // Ouvre la dialogue "Paramètres"
        });

        aboutButton.setOnClickListener(v -> {
            Log.d(TAG, "Menu: Clic sur À Propos.");
            dialog.dismiss(); // Ferme le menu
            showAboutDialog(); // Ouvre la dialogue "À Propos"
        });

        returnButton.setOnClickListener(v -> {
            Log.d(TAG, "Menu: Clic sur Retour.");
            dialog.dismiss(); // Ferme simplement le menu
        });

        dialog.show(); // Affiche la boîte de dialogue du menu
    }

    /**
     * Affiche une boîte de dialogue expliquant les règles du jeu 2048.
     * Utilise le layout personnalisé `dialog_how_to_play.xml`.
     */
    private void showHowToPlayDialog() {
        Log.d(TAG, "Affichage dialogue Comment Jouer.");
        AlertDialog.Builder builder = new AlertDialog.Builder(this);
        LayoutInflater inflater = getLayoutInflater();
        View dialogView = inflater.inflate(R.layout.dialog_how_to_play, null); // Gonfle le layout
        builder.setView(dialogView);
        builder.setCancelable(true); // Permet de fermer en cliquant à côté

        // Récupère le bouton OK DANS la vue gonflée
        Button okButton = dialogView.findViewById(R.id.dialogOkButtonHowToPlay);

        final AlertDialog dialog = builder.create();

        okButton.setOnClickListener(v -> {
            Log.d(TAG, "Dialogue Comment Jouer: OK cliqué.");
            dialog.dismiss(); // Ferme simplement
        });

        dialog.show();
    }

    /**
     * Affiche la boîte de dialogue des paramètres de l'application.
     * Utilise le layout personnalisé `dialog_settings.xml`.
     * Permet à l'utilisateur de gérer :
     * - Activation/Désactivation des effets sonores.
     * - Activation/Désactivation des notifications (avec demande de permission si nécessaire).
     * - Accès aux paramètres système de l'application (pour gérer les permissions manuellement).
     * - Partage des statistiques.
     * - Réinitialisation des statistiques (avec confirmation).
     * - Quitter l'application.
     */
    private void showSettingsDialog() {
        Log.d(TAG, "Affichage dialogue Paramètres.");
        AlertDialog.Builder builder = new AlertDialog.Builder(this);
        LayoutInflater inflater = getLayoutInflater();
        View dialogView = inflater.inflate(R.layout.dialog_settings, null); // Gonfle le layout
        builder.setView(dialogView).setCancelable(true); // Permet de fermer en cliquant à côté

        // Récupération des vues dans la dialogue
        SwitchMaterial switchSound = dialogView.findViewById(R.id.switchSound);
        SwitchMaterial switchNotifications = dialogView.findViewById(R.id.switchNotifications);
        Button permissionsButton = dialogView.findViewById(R.id.buttonManagePermissions);
        Button shareStatsButton = dialogView.findViewById(R.id.buttonShareStats);
        Button resetStatsButton = dialogView.findViewById(R.id.buttonResetStats);
        Button quitAppButton = dialogView.findViewById(R.id.buttonQuitApp);
        Button closeButton = dialogView.findViewById(R.id.buttonCloseSettings);
        // Les boutons de test ont été retirés car non ajoutés au layout

        final AlertDialog dialog = builder.create();

        // --- Configuration du Switch Son ---
        switchSound.setChecked(soundEnabled); // Initialise avec l'état actuel
        switchSound.setOnCheckedChangeListener((buttonView, isChecked) -> {
            Log.d(TAG, "Paramètres: Switch Son changé à " + isChecked);
            soundEnabled = isChecked; // Met à jour l'état interne
            saveSoundPreference(isChecked); // Sauvegarde la préférence
            Toast.makeText(this,
                    isChecked ? R.string.sound_enabled : R.string.sound_disabled,
                    Toast.LENGTH_SHORT).show();
            // Jouer un petit son de test ici ? Optionnel.
        });

        // --- Configuration du Switch Notifications ---
        switchNotifications.setChecked(notificationsEnabled); // Initialise avec l'état actuel
        switchNotifications.setOnCheckedChangeListener((buttonView, isChecked) -> {
            Log.d(TAG, "Paramètres: Switch Notifications changé à " + isChecked);
            if (isChecked) {
                // L'utilisateur VEUT activer les notifications
                Log.d(TAG, "Paramètres: Demande d'activation des notifications.");
                requestNotificationPermission(); // Demande la permission si nécessaire (gère aussi l'activation si déjà accordée)
            } else {
                // L'utilisateur désactive les notifications
                Log.d(TAG, "Paramètres: Désactivation des notifications.");
                notificationsEnabled = false;
                saveNotificationPreference(false);
                Toast.makeText(this, R.string.notifications_disabled, Toast.LENGTH_SHORT).show();
                // Arrêter le service de notification si l'utilisateur désactive
                stopNotificationService();
                // Annuler les éventuelles notifications planifiées (via WorkManager/AlarmManager si utilisé)
            }
            // Note: L'état 'notificationsEnabled' est mis à jour dans le callback de requestPermissionLauncher si la permission est accordée/refusée.
            // Le switch peut se désynchroniser brièvement si l'utilisateur refuse la permission. updateNotificationSwitchState est appelé dans le callback.
        });

        // --- Listeners pour les autres boutons ---
        // Bouton Gérer Permissions -> Ouvre les paramètres système de l'app
        permissionsButton.setOnClickListener(v -> {
            Log.d(TAG, "Paramètres: Clic sur Gérer Permissions.");
            openAppSettings();
            dialog.dismiss(); // Ferme les paramètres de l'app après avoir ouvert ceux du système
        });

        // Bouton Partager Stats -> Lance une Intent de partage
        shareStatsButton.setOnClickListener(v -> {
            Log.d(TAG, "Paramètres: Clic sur Partager Stats.");
            shareStats();
            dialog.dismiss();
        });

        // Bouton Réinitialiser Stats -> Affiche dialogue de confirmation
        resetStatsButton.setOnClickListener(v -> {
            Log.d(TAG, "Paramètres: Clic sur Réinitialiser Stats.");
            dialog.dismiss(); // Ferme les paramètres AVANT d'ouvrir la confirmation
            showResetStatsConfirmationDialog();
        });

        // Bouton Quitter l'App -> Ferme toutes les activités de l'application
        quitAppButton.setOnClickListener(v -> {
            Log.d(TAG, "Paramètres: Clic sur Quitter App.");
            dialog.dismiss();
            finishAffinity(); // Ferme l'application complètement
        });

        // Bouton Fermer -> Ferme simplement la dialogue des paramètres
        closeButton.setOnClickListener(v -> {
            Log.d(TAG, "Paramètres: Clic sur Fermer.");
            dialog.dismiss();
        });

        // // Emplacement pour ajouter les listeners des boutons de test si besoin
        // testNotifHS.setOnClickListener(v -> { if(gameStats!=null) showHighScoreNotification(gameStats.getOverallHighScore()); });
        // testNotifInactiv.setOnClickListener(v -> { showInactivityNotification(); });

        dialog.show();
    }

    /**
     * Met à jour l'état visuel de l'interrupteur de notifications.
     * Actuellement, cette méthode est un placeholder car mettre à jour une vue
     * dans une dialogue non affichée ou après sa fermeture est complexe.
     * L'approche la plus simple est que l'utilisateur doive rouvrir les paramètres
     * pour voir l'état mis à jour si la permission a été refusée.
     *
     * @param isEnabled L'état souhaité pour l'interrupteur (généralement false si permission refusée).
     */
    private void updateNotificationSwitchState(boolean isEnabled) {
        // Idéalement, si la dialogue des paramètres est encore ouverte (AlertDialog dialog),
        // on récupérerait le switch et on ferait dialogView.findViewById(R.id.switchNotifications).setChecked(isEnabled);
        // Mais gérer la référence à la dialogue/vue de manière fiable est complexe.
        Log.d(TAG, "updateNotificationSwitchState: État notif demandé: " + isEnabled + " (Mise à jour visuelle du switch non implémentée si dialogue fermée)");
        // Si l'utilisateur vient de refuser, on s'assure que notre état interne est correct.
        if (!isEnabled) {
            notificationsEnabled = false;
            saveNotificationPreference(false);
        }
    }

    /**
     * Sauvegarde la préférence utilisateur pour l'activation/désactivation des notifications
     * dans les SharedPreferences.
     *
     * @param enabled true pour activer, false pour désactiver.
     */
    private void saveNotificationPreference(boolean enabled) {
        if (preferences != null) {
            Log.d(TAG, "Sauvegarde préférence notifications: " + enabled);
            preferences.edit().putBoolean(NOTIFICATIONS_ENABLED_KEY, enabled).apply();
        } else {
            Log.e(TAG, "Impossible de sauvegarder préférence notifications, SharedPreferences est null.");
        }
    }

    /**
     * Charge la préférence utilisateur pour l'activation/désactivation des notifications
     * depuis les SharedPreferences. Par défaut à false si non définie.
     */
    private void loadNotificationPreference() {
        if (preferences != null) {
            // Défaut à false - l'utilisateur doit activement les activer.
            notificationsEnabled = preferences.getBoolean(NOTIFICATIONS_ENABLED_KEY, false);
            Log.d(TAG, "Chargement préférence notifications: " + notificationsEnabled);
        } else {
            notificationsEnabled = false; // Assure une valeur par défaut si prefs est null
            Log.e(TAG, "Impossible de charger préférence notifications, SharedPreferences est null. Défaut: false.");
        }
    }

    /**
     * Sauvegarde la préférence utilisateur pour l'activation/désactivation des effets sonores
     * dans les SharedPreferences.
     *
     * @param enabled true pour activer, false pour désactiver.
     */
    private void saveSoundPreference(boolean enabled) {
        if (preferences != null) {
            Log.d(TAG, "Sauvegarde préférence son: " + enabled);
            preferences.edit().putBoolean(SOUND_ENABLED_KEY, enabled).apply();
        } else {
            Log.e(TAG, "Impossible de sauvegarder préférence son, SharedPreferences est null.");
        }
    }

    /**
     * Charge la préférence utilisateur pour l'activation/désactivation des effets sonores
     * depuis les SharedPreferences. Par défaut à true si non définie.
     */
    private void loadSoundPreference() {
        if (preferences != null) {
            soundEnabled = preferences.getBoolean(SOUND_ENABLED_KEY, true); // Son activé par défaut
            Log.d(TAG, "Chargement préférence son: " + soundEnabled);
        } else {
            soundEnabled = true; // Assure une valeur par défaut si prefs est null
            Log.e(TAG, "Impossible de charger préférence son, SharedPreferences est null. Défaut: true.");
        }
    }

    /**
     * Ouvre l'écran des paramètres système spécifiques à cette application,
     * permettant à l'utilisateur de gérer manuellement les permissions accordées.
     * Utilise une Intent {@code Settings.ACTION_APPLICATION_DETAILS_SETTINGS}.
     */
    private void openAppSettings() {
        Log.d(TAG, "Ouverture des paramètres système de l'application.");
        Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
        // Crée l'URI pointant vers les détails de notre package
        Uri uri = Uri.fromParts("package", getPackageName(), null);
        intent.setData(uri);
        try {
            startActivity(intent); // Lance l'Intent
        } catch (ActivityNotFoundException e) {
            // Gère le cas (très improbable) où l'écran des paramètres ne peut être ouvert
            Log.e(TAG, "Impossible d'ouvrir les paramètres système de l'application.", e);
            Toast.makeText(this, "Impossible d'ouvrir les paramètres.", Toast.LENGTH_LONG).show();
        }
    }

    // --- Gestion des Permissions (Notifications) ---

    /**
     * Demande la permission {@code POST_NOTIFICATIONS} si l'application tourne
     * sur Android 13 (API 33) ou supérieur et si la permission n'a pas déjà été accordée.
     * Si la permission est déjà accordée ou si l'API est inférieure à 33, active
     * directement les notifications (met à jour l'état interne et sauvegarde la préférence).
     * Utilise le {@code requestPermissionLauncher} pour gérer la réponse asynchrone.
     */
    private void requestNotificationPermission() {
        // Vérifie si on est sur Android 13 (TIRAMISU) ou plus
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            Log.d(TAG, "Vérification permission POST_NOTIFICATIONS sur API 33+");
            // Vérifie si la permission n'est PAS déjà accordée
            if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
                // La permission n'est pas accordée, il faut la demander.
                Log.i(TAG, "Permission POST_NOTIFICATIONS non accordée. Demande en cours...");
                // Lance la demande de permission via le launcher enregistré
                requestPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS);
                // Le résultat sera géré dans le callback du requestPermissionLauncher
            } else {
                // La permission est DÉJÀ accordée.
                Log.i(TAG, "Permission POST_NOTIFICATIONS déjà accordée.");
                // On peut activer directement les notifications (si ce n'est pas déjà fait)
                if (!notificationsEnabled) {
                    notificationsEnabled = true;
                    saveNotificationPreference(true);
                    Toast.makeText(this, R.string.notifications_enabled, Toast.LENGTH_SHORT).show();
                    startNotificationService(); // Démarrer le service
                }
            }
        } else {
            // Sur les versions antérieures à Android 13, pas besoin de demander la permission runtime.
            Log.i(TAG, "API < 33, permission POST_NOTIFICATIONS non requise explicitement.");
            // On considère la permission comme accordée, on active les notifications.
            if (!notificationsEnabled) {
                notificationsEnabled = true;
                saveNotificationPreference(true);
                Toast.makeText(this, R.string.notifications_enabled, Toast.LENGTH_SHORT).show();
                startNotificationService(); // Démarrer le service
            }
        }
    }

    // --- Logique de Notification (Affichage principalement délégué à NotificationHelper) ---

    /**
     * Crée l'Intent qui sera lancé lorsque l'utilisateur clique sur une notification.
     * Dans ce cas, l'intent ouvre (ou ramène au premier plan) la {@code MainActivity}.
     * Utilise les flags appropriés pour la gestion de la pile d'activités et l'immutabilité.
     *
     * @return Le {@link PendingIntent} configuré pour être attaché à la notification.
     */
    private PendingIntent createNotificationTapIntent() {
        Intent intent = new Intent(this, MainActivity.class);
        // FLAG_ACTIVITY_NEW_TASK: Démarre l'activité dans une nouvelle tâche si nécessaire.
        // FLAG_ACTIVITY_CLEAR_TASK: Efface la tâche existante avant de démarrer l'activité (si NEW_TASK est aussi défini).
        // Résultat : ramène l'instance existante au premier plan ou en crée une nouvelle.
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);

        // FLAG_IMMUTABLE est requis pour Android 12+ (API 31) pour les PendingIntents utilisés par le système.
        // FLAG_UPDATE_CURRENT: Si un PendingIntent avec le même requestCode existe déjà, met à jour son Intent extra data.
        int flags = PendingIntent.FLAG_UPDATE_CURRENT;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { // FLAG_IMMUTABLE existe depuis API 23, mais requis système depuis 31
            flags |= PendingIntent.FLAG_IMMUTABLE;
        }
        return PendingIntent.getActivity(this, 0, intent, flags);
    }

    /**
     * Construit et affiche une notification simple en utilisant {@link NotificationHelper}.
     * Vérifie d'abord si les notifications sont activées et si la permission est accordée.
     *
     * @param context      Contexte (typiquement 'this' depuis l'activité).
     * @param title        Titre de la notification.
     * @param message      Corps du message de la notification.
     * @param notificationId ID unique pour cette notification (permet de la mettre à jour ou l'annuler).
     */
    private void showNotification(Context context, String title, String message, int notificationId) {
        // Vérification globale avant d'appeler le Helper
        if (!notificationsEnabled) {
            Log.w(TAG, "showNotification: Notifications désactivées, notification [" + notificationId + "] non envoyée.");
            return;
        }
        // Le Helper vérifiera aussi la permission pour API 33+
        Log.d(TAG, "showNotification: Tentative d'affichage de la notification ID " + notificationId);
        NotificationHelper.showNotification(context, title, message, notificationId);
    }

    /**
     * Démarre le {@link NotificationService} en arrière-plan s'il n'est pas déjà lancé.
     * Ce service est responsable de l'envoi des notifications périodiques (rappel HS, inactivité).
     * Ne fait rien si les notifications ne sont pas activées ou si la permission manque.
     */
    private void startNotificationService() {
        if (!notificationsEnabled) {
            Log.w(TAG, "startNotificationService: Notifications désactivées, service non démarré.");
            return;
        }
        // Sur API 33+, revérifier la permission avant de démarrer
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
                ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
            Log.w(TAG, "startNotificationService: Permission POST_NOTIFICATIONS manquante sur API 33+, service non démarré.");
            return;
        }

        Log.i(TAG, "Démarrage du NotificationService.");
        Intent serviceIntent = new Intent(this, NotificationService.class);
        try {
            // Utiliser startService. Si le service a besoin d'être foreground (API 26+),
            // il doit appeler startForeground() lui-même rapidement.
            startService(serviceIntent);
        } catch (IllegalStateException e) {
            // Peut arriver sur API 26+ si l'app est en arrière-plan et tente de démarrer un service
            // sans le passer en foreground rapidement. NotificationService devrait gérer startForeground si besoin.
            Log.e(TAG, "Impossible de démarrer NotificationService (peut-être en arrière-plan sur API 26+ ?)", e);
        } catch (SecurityException se) {
            Log.e(TAG, "Erreur de sécurité au démarrage de NotificationService.", se);
        }
    }

    /**
     * Arrête le {@link NotificationService} s'il est en cours d'exécution.
     */
    private void stopNotificationService() {
        Log.i(TAG, "Arrêt du NotificationService.");
        Intent serviceIntent = new Intent(this, NotificationService.class);
        stopService(serviceIntent);
    }

    /**
     * Affiche la notification d'accomplissement (ex: atteinte d'une tuile spécifique)
     * via le {@link NotificationHelper}.
     * Vérifie si les notifications sont activées avant d'envoyer.
     *
     * @param tileValue La valeur de la tuile atteinte (ex: 2048).
     */
    private void showAchievementNotification(int tileValue) {
        if (!notificationsEnabled) return; // Vérification globale
        Log.i(TAG, "Affichage notification d'accomplissement pour la tuile " + tileValue);
        String title = getString(R.string.notification_title_achievement);
        String message = getString(R.string.notification_text_achievement, tileValue);
        // Utilise ID 1 (par convention définie dans NotificationService ou ici)
        showNotification(this, title, message, 1);
    }

    /**
     * Affiche la notification de rappel du meilleur score (utilisé pour test/démo).
     * La planification réelle devrait être gérée par {@link NotificationService} ou WorkManager.
     *
     * @param highScore Le meilleur score actuel à afficher.
     */
    private void showHighScoreNotification(int highScore) {
        Log.d(TAG, "Affichage notification de test High Score: " + highScore);
        String title = getString(R.string.notification_title_highscore);
        String message = getString(R.string.notification_text_highscore, highScore);
        // Utilise ID 2 (par convention définie dans NotificationService)
        showNotification(this, title, message, 2);
    }

    /**
     * Affiche la notification de rappel d'inactivité (utilisé pour test/démo).
     * La planification et la logique réelle devraient être dans {@link NotificationService} ou WorkManager.
     */
    private void showInactivityNotification() {
        Log.d(TAG, "Affichage notification de test Inactivité.");
        String title = getString(R.string.notification_title_inactivity);
        String message = getString(R.string.notification_text_inactivity);
        // Utilise ID 3 (par convention définie dans NotificationService)
        showNotification(this, title, message, 3);
    }

    /**
     * Crée et lance une Intent {@code Intent.ACTION_SEND} pour permettre à l'utilisateur
     * de partager ses statistiques de jeu via d'autres applications (email, réseaux sociaux, etc.).
     * Formate un message texte contenant les statistiques clés.
     */
    private void shareStats() {
        if (gameStats == null) {
            Log.w(TAG, "shareStats: Impossible de partager, GameStats est null.");
            Toast.makeText(this, "Statistiques non disponibles.", Toast.LENGTH_SHORT).show();
            return;
        }
        Log.i(TAG, "Préparation du partage des statistiques.");

        // Construit le message texte à partager
        String shareBody = getString(R.string.share_stats_body,
                gameStats.getOverallHighScore(),
                gameStats.getHighestTile(),
                gameStats.getNumberOfTimesObjectiveReached(),
                gameStats.getTotalGamesStarted(), // Ou totalGamesPlayed ? Utilisons started pour cohérence avec win %
                GameStats.formatTime(gameStats.getTotalPlayTimeMs()),
                gameStats.getTotalMoves()
        );

        // Crée l'Intent de partage
        Intent shareIntent = new Intent(Intent.ACTION_SEND);
        shareIntent.setType("text/plain"); // Type MIME pour du texte simple
        shareIntent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.share_stats_subject)); // Sujet (pour email, etc.)
        shareIntent.putExtra(Intent.EXTRA_TEXT, shareBody); // Corps du message

        // Lance le sélecteur d'applications pour le partage
        try {
            startActivity(Intent.createChooser(shareIntent, getString(R.string.share_stats_title)));
        } catch (ActivityNotFoundException e) {
            // Gère le cas où aucune application ne peut gérer l'Intent de partage
            Log.e(TAG, "Aucune application de partage disponible.", e);
            Toast.makeText(this, "Aucune application de partage disponible.", Toast.LENGTH_SHORT).show();
        }
    }

    /**
     * Affiche une boîte de dialogue de confirmation avant de réinitialiser
     * toutes les statistiques du joueur. Prévient l'utilisateur que l'action est irréversible.
     */
    private void showResetStatsConfirmationDialog() {
        Log.w(TAG, "Affichage dialogue confirmation réinitialisation stats.");
        new AlertDialog.Builder(this)
                .setTitle(R.string.reset_stats_confirm_title)
                .setMessage(R.string.reset_stats_confirm_message)
                .setIcon(android.R.drawable.ic_dialog_alert) // Icône d'avertissement standard
                // Bouton Positif (Confirmer)
                .setPositiveButton(R.string.confirm, (dialog, which) -> {
                    Log.i(TAG, "Réinitialisation des statistiques confirmée.");
                    resetStatistics(); // Appelle la méthode de réinitialisation
                    dialog.dismiss();
                })
                // Bouton Négatif (Annuler)
                .setNegativeButton(R.string.cancel, (dialog, which) -> {
                    Log.d(TAG, "Réinitialisation des statistiques annulée.");
                    dialog.dismiss();
                })
                .setCancelable(false) // Empêche de fermer sans choisir
                .show();
    }

    /**
     * Réinitialise toutes les statistiques stockées via l'objet {@link GameStats}.
     * Met également à jour le meilleur score affiché dans l'interface si une partie est en cours.
     * Affiche une confirmation à l'utilisateur via un Toast.
     */
    private void resetStatistics() {
        Log.i(TAG, "resetStatistics: Réinitialisation des statistiques demandée.");
        if (gameStats != null) {
            gameStats.resetStats(); // Réinitialise les stats dans l'objet et sauvegarde via SharedPreferences

            // Met aussi à jour le highScore de l'objet Game courant (qui est maintenant 0)
            if(game != null){
                game.setHighestScore(gameStats.getOverallHighScore()); // Met à jour l'instance Game
                updateScores(); // Rafraîchit l'affichage du HS si visible
            }

            Toast.makeText(this, R.string.stats_reset_confirmation, Toast.LENGTH_SHORT).show();

            // Si les statistiques étaient visibles, on les rafraîchit pour montrer les zéros
            if (statisticsVisible && inflatedStatsView != null) {
                Log.d(TAG, "resetStatistics: Rafraîchissement du panneau de statistiques.");
                updateStatisticsTextViews();
            }
        } else {
            Log.e(TAG, "resetStatistics: GameStats est null, impossible de réinitialiser.");
        }
    }

    /**
     * Affiche la boîte de dialogue "À Propos".
     * Utilise le layout personnalisé `dialog_about.xml`.
     * Affiche des informations sur l'application (version, développeur) et
     * inclut un lien cliquable vers un site web.
     */
    private void showAboutDialog() {
        Log.d(TAG, "Affichage dialogue À Propos.");
        AlertDialog.Builder builder = new AlertDialog.Builder(this);
        LayoutInflater inflater = getLayoutInflater();
        View dialogView = inflater.inflate(R.layout.dialog_about, null); // Gonfle le layout
        builder.setView(dialogView);
        builder.setCancelable(true); // Permet de fermer en cliquant à côté

        // Récupère les vues du layout
        TextView websiteLinkTextView = dialogView.findViewById(R.id.websiteLinkTextView);
        Button okButton = dialogView.findViewById(R.id.dialogOkButtonAbout);

        final AlertDialog dialog = builder.create();

        // Rend le lien du site web cliquable pour ouvrir le navigateur
        websiteLinkTextView.setOnClickListener(v -> {
            String url = getString(R.string.about_website_url);
            Log.d(TAG, "Dialogue À Propos: Clic sur le lien du site web -> " + url);
            Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
            try {
                startActivity(browserIntent);
            } catch (ActivityNotFoundException e) {
                // Gère le cas où aucun navigateur n'est installé
                Log.e(TAG, "Aucun navigateur web trouvé pour ouvrir l'URL.", e);
                Toast.makeText(this, "Aucun navigateur web trouvé.", Toast.LENGTH_SHORT).show();
            }
        });

        // Bouton OK pour fermer la dialogue
        okButton.setOnClickListener(v -> {
            Log.d(TAG, "Dialogue À Propos: OK cliqué.");
            dialog.dismiss();
        });

        dialog.show();
    }


    // --- Gestion Stats UI ---

    /**
     * Affiche ou masque le panneau des statistiques.
     * Utilise un {@link ViewStub} pour gonfler le layout (`stats_layout.xml`) uniquement
     * lors de la première demande d'affichage (optimisation des performances).
     * Gère la visibilité du panneau et du bouton multijoueur.
     */
    private void toggleStatistics() {
        statisticsVisible = !statisticsVisible; // Inverse l'état de visibilité
        Log.d(TAG, "toggleStatistics: Visibilité changée à " + statisticsVisible);

        if (statisticsVisible) {
            // Si on veut afficher les stats
            if (inflatedStatsView == null) { // Gonfle le layout si ce n'est pas déjà fait
                Log.i(TAG, "toggleStatistics: Gonflage du ViewStub des statistiques.");
                try {
                    inflatedStatsView = statisticsViewStub.inflate(); // Gonfle le layout défini dans android:layout="@layout/stats_layout"
                    // Attache listener au bouton Retour DANS le layout des stats, une fois gonflé
                    Button backButton = inflatedStatsView.findViewById(R.id.backButton);
                    if (backButton != null) {
                        backButton.setOnClickListener(v -> toggleStatistics()); // Recliquer sur Back appelle à nouveau toggle
                    } else {
                        Log.e(TAG, "toggleStatistics: Bouton Retour (R.id.backButton) non trouvé dans stats_layout !");
                    }
                } catch (Exception e) {
                    Log.e(TAG, "Erreur lors du gonflage du ViewStub des statistiques", e);
                    statisticsVisible = false; // Annule le changement d'état si erreur
                    Toast.makeText(this, "Erreur lors de l'affichage des stats.", Toast.LENGTH_SHORT).show();
                    return;
                }
            }
            // Assure que la vue est visible et met à jour les données
            if (inflatedStatsView != null) {
                updateStatisticsTextViews(); // Remplit les champs avec les données actuelles
                inflatedStatsView.setVisibility(View.VISIBLE); // Affiche le panneau
            }
            multiplayerButton.setVisibility(View.GONE); // Masque le bouton multijoueur quand les stats sont visibles

        } else {
            // Si on veut masquer les stats
            if (inflatedStatsView != null) { // Masque le panneau s'il existe
                inflatedStatsView.setVisibility(View.GONE);
            }
            multiplayerButton.setVisibility(View.VISIBLE); // Réaffiche le bouton multijoueur
        }
    }

    /**
     * Remplit les {@link TextView} du panneau de statistiques (contenu dans `inflatedStatsView`)
     * avec les données actuelles provenant de l'objet {@link GameStats}.
     * Doit être appelé seulement après que `inflatedStatsView` a été initialisé (gonflé)
     * et seulement si `gameStats` n'est pas null.
     */
    @SuppressLint("StringFormatInvalid") // Pour R.string.average_time_per_game_label potentiellement mal utilisé
    private void updateStatisticsTextViews() {
        if (inflatedStatsView == null || gameStats == null) {
            Log.w(TAG, "updateStatisticsTextViews: Vue des stats non gonflée ou GameStats null.");
            return; // Ne rien faire si la vue n'existe pas ou les stats ne sont pas prêtes
        }
        Log.d(TAG, "updateStatisticsTextViews: Mise à jour des labels de statistiques.");

        // Récupération des TextViews DANS la vue gonflée
        // Utiliser findViewByID sur inflatedStatsView, pas sur l'activité !
        TextView highScoreStatsLabel = inflatedStatsView.findViewById(R.id.high_score_stats_label);
        TextView totalGamesPlayedLabel = inflatedStatsView.findViewById(R.id.total_games_played_label);
        TextView totalGamesStartedLabel = inflatedStatsView.findViewById(R.id.total_games_started_label);
        TextView winPercentageLabel = inflatedStatsView.findViewById(R.id.win_percentage_label);
        TextView totalPlayTimeLabel = inflatedStatsView.findViewById(R.id.total_play_time_label);
        TextView totalMovesLabel = inflatedStatsView.findViewById(R.id.total_moves_label);
        TextView currentMovesLabel = inflatedStatsView.findViewById(R.id.current_moves_label);
        TextView currentGameTimeLabel = inflatedStatsView.findViewById(R.id.current_game_time_label);
        TextView averageGameTimeLabel = inflatedStatsView.findViewById(R.id.average_game_time_label);
        TextView bestWinningTimeLabel = inflatedStatsView.findViewById(R.id.best_winning_time_label);
        TextView worstWinningTimeLabel = inflatedStatsView.findViewById(R.id.worst_winning_time_label);
        TextView totalMergesLabel = inflatedStatsView.findViewById(R.id.total_merges_label);
        TextView highestTileLabel = inflatedStatsView.findViewById(R.id.highest_tile_label);
        TextView objectiveReachedLabel = inflatedStatsView.findViewById(R.id.number_of_time_objective_reached_label);
        // TextView perfectGameLabel = inflatedStatsView.findViewById(R.id.perfect_game_label); // Supprimé
        TextView mergesThisGameLabel = inflatedStatsView.findViewById(R.id.merges_this_game);

        // Multiplayer stats TextViews
        TextView multiplayerGamesWonLabel = inflatedStatsView.findViewById(R.id.multiplayer_games_won_label);
        TextView multiplayerGamesPlayedLabel = inflatedStatsView.findViewById(R.id.multiplayer_games_played_label);
        TextView multiplayerWinRateLabel = inflatedStatsView.findViewById(R.id.multiplayer_win_rate_label);
        TextView multiplayerBestWinningStreakLabel = inflatedStatsView.findViewById(R.id.multiplayer_best_winning_streak_label);
        TextView multiplayerAverageScoreLabel = inflatedStatsView.findViewById(R.id.multiplayer_average_score_label);
        TextView averageTimePerGameMultiLabel = inflatedStatsView.findViewById(R.id.average_time_per_game_label); // *** ATTENTION: ID POTENTIELLEMENT DUPLIQUÉ DANS LE LAYOUT XML ***
        TextView totalMultiplayerLossesLabel = inflatedStatsView.findViewById(R.id.total_multiplayer_losses_label);
        TextView multiplayerHighScoreLabel = inflatedStatsView.findViewById(R.id.multiplayer_high_score_label);


        // --- Vérifier la nullité de chaque TextView avant de l'utiliser ---
        // (Alternativement, utiliser un View Binding ou Data Binding pour plus de sécurité)

        // Mise à jour des textes avec les getters de gameStats + formatage
        if (highScoreStatsLabel != null) highScoreStatsLabel.setText(getString(R.string.high_score_stats, gameStats.getOverallHighScore()));
        if (totalGamesPlayedLabel != null) totalGamesPlayedLabel.setText(getString(R.string.total_games_played, gameStats.getTotalGamesPlayed()));
        if (totalGamesStartedLabel != null) totalGamesStartedLabel.setText(getString(R.string.total_games_started, gameStats.getTotalGamesStarted()));
        if (totalMovesLabel != null) totalMovesLabel.setText(getString(R.string.total_moves, gameStats.getTotalMoves()));
        if (currentMovesLabel != null) currentMovesLabel.setText(getString(R.string.current_moves, gameStats.getCurrentMoves()));
        if (mergesThisGameLabel != null) mergesThisGameLabel.setText(getString(R.string.merges_this_game_label, gameStats.getMergesThisGame()));
        if (totalMergesLabel != null) totalMergesLabel.setText(getString(R.string.total_merges, gameStats.getTotalMerges()));
        if (highestTileLabel != null) highestTileLabel.setText(getString(R.string.highest_tile, gameStats.getHighestTile()));
        if (objectiveReachedLabel != null) objectiveReachedLabel.setText(getString(R.string.number_of_time_objective_reached, gameStats.getNumberOfTimesObjectiveReached()));
        // if (perfectGameLabel != null) perfectGameLabel.setText(getString(R.string.perfect_games, gameStats.getPerfectGames())); // Supprimé

        // Calculs Pourcentages (avec vérification division par zéro)
        if (winPercentageLabel != null) {
            String winPercentage = (gameStats.getTotalGamesStarted() > 0)
                    ? String.format(java.util.Locale.US, "%.1f%%", ((double) gameStats.getNumberOfTimesObjectiveReached() / gameStats.getTotalGamesStarted()) * 100)
                    : "N/A";
            winPercentageLabel.setText(getString(R.string.win_percentage, winPercentage));
        }
        if (multiplayerWinRateLabel != null) {
            String multiplayerWinRate = (gameStats.getMultiplayerGamesPlayed() > 0)
                    ? String.format(java.util.Locale.US, "%.1f%%", ((double) gameStats.getMultiplayerGamesWon() / gameStats.getMultiplayerGamesPlayed()) * 100)
                    : "N/A";
            multiplayerWinRateLabel.setText(getString(R.string.multiplayer_win_rate, multiplayerWinRate));
        }

        // Calculs et formatage des Temps
        if (totalPlayTimeLabel != null) totalPlayTimeLabel.setText(getString(R.string.total_play_time, GameStats.formatTime(gameStats.getTotalPlayTimeMs())));

        // Calcule le temps de la partie en cours seulement si elle est active
        if (currentGameTimeLabel != null) {
            long currentDurationMs = 0;
            if (game != null && currentGameState == GameFlowState.PLAYING && gameStats.getCurrentGameStartTimeMs() > 0) {
                currentDurationMs = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs();
            }
            currentGameTimeLabel.setText(getString(R.string.current_game_time, GameStats.formatTime(currentDurationMs)));
        }

        if (averageGameTimeLabel != null) averageGameTimeLabel.setText(getString(R.string.average_time_per_game, GameStats.formatTime(gameStats.getAverageGameTimeMs())));
        if (bestWinningTimeLabel != null) bestWinningTimeLabel.setText(getString(R.string.best_winning_time, (gameStats.getBestWinningTimeMs() != Long.MAX_VALUE) ? GameStats.formatTime(gameStats.getBestWinningTimeMs()) : "N/A"));
        if (worstWinningTimeLabel != null) worstWinningTimeLabel.setText(getString(R.string.worst_winning_time, (gameStats.getWorstWinningTimeMs() != 0) ? GameStats.formatTime(gameStats.getWorstWinningTimeMs()) : "N/A"));

        // MAJ Stats Multi
        if (multiplayerGamesWonLabel != null) multiplayerGamesWonLabel.setText(getString(R.string.multiplayer_games_won, gameStats.getMultiplayerGamesWon()));
        if (multiplayerGamesPlayedLabel != null) multiplayerGamesPlayedLabel.setText(getString(R.string.multiplayer_games_played, gameStats.getMultiplayerGamesPlayed()));
        if (multiplayerBestWinningStreakLabel != null) multiplayerBestWinningStreakLabel.setText(getString(R.string.multiplayer_best_winning_streak, gameStats.getMultiplayerBestWinningStreak()));
        if (multiplayerAverageScoreLabel != null) multiplayerAverageScoreLabel.setText(getString(R.string.multiplayer_average_score, gameStats.getMultiplayerAverageScore()));
        if (totalMultiplayerLossesLabel != null) totalMultiplayerLossesLabel.setText(getString(R.string.total_multiplayer_losses, gameStats.getTotalMultiplayerLosses()));
        if (multiplayerHighScoreLabel != null) multiplayerHighScoreLabel.setText(getString(R.string.multiplayer_high_score, gameStats.getMultiplayerHighestScore()));

        // *** ATTENTION : ID potentiellement dupliqué ***
        // L'ID R.id.average_time_per_game_label semble être utilisé pour averageGameTimeLabel ET averageTimePerGameMultiLabel.
        // Cela signifie qu'une seule des deux TextViews (probablement la dernière dans le XML) sera trouvée et mise à jour.
        // Vous DEVEZ donner des IDs uniques à ces deux TextViews dans stats_layout.xml et mettre à jour les findViewById ici.
        if (averageTimePerGameMultiLabel != null) {
            // Cette ligne mettra probablement à jour la même TextView que averageGameTimeLabel si l'ID est dupliqué !
            // Assurez-vous que R.id.average_time_per_game_label pointe vers la bonne TextView pour le multi.
            try {
                averageTimePerGameMultiLabel.setText(getString(R.string.average_time_per_game_label, GameStats.formatTime(gameStats.getMultiplayerAverageTimeMs())));
            } catch (Exception e) {
                // Le format de la string R.string.average_time_per_game_label attend peut-être un autre type d'argument
                Log.e(TAG, "Erreur de formatage pour average_time_per_game_label (multi). Vérifier la string resource.", e);
                // Afficher une valeur par défaut ou un message d'erreur
                averageTimePerGameMultiLabel.setText(R.string.average_time_per_game_label); // Affiche le label brut
            }
        } else {
            Log.w(TAG, "TextView pour R.id.average_time_per_game_label (multi) non trouvée.");
        }

    }


    // --- Placeholders Multi ---

    /** Affiche l'écran du mode multijoueur (lance MultiplayerActivity). */
    private void showMultiplayerScreen() {
        Log.d(TAG, "Affichage écran Multijoueur.");
        Intent intent = new Intent(this, MultiplayerActivity.class);
        // TODO: Passer des informations utiles à l'activité multi (ex: ID Joueur)
        // intent.putExtra("playerId", myPlayerId);
        try {
            startActivity(intent);
        } catch (ActivityNotFoundException e) {
            Log.e(TAG,"MultiplayerActivity non trouvée ! Vérifier AndroidManifest.xml", e);
            Toast.makeText(this, "Erreur: Fonctionnalité multijoueur non disponible.", Toast.LENGTH_SHORT).show();
        }
        // AlertDialog retiré
    }

    // --- Sauvegarde / Chargement ---

    /**
     * Sauvegarde l'état essentiel du jeu actuel (plateau + score courant via {@code game.toString()})
     * et le meilleur score global (depuis `game.getHighestScore()`) dans les SharedPreferences.
     * Utilise `apply()` pour une sauvegarde asynchrone.
     * Appelée typiquement dans `onPause`.
     */
    private void saveGame() {
        if (preferences == null) {
            Log.e(TAG, "saveGame: SharedPreferences non initialisé, sauvegarde annulée.");
            return;
        }
        SharedPreferences.Editor editor = preferences.edit();
        if (game != null) {
            Log.d(TAG, "saveGame: Sauvegarde de l'état du jeu et du high score.");
            editor.putString(GAME_STATE_KEY, game.toString()); // Sérialise l'objet Game
            // Le meilleur score est aussi géré par GameStats, mais on le sauve ici aussi
            // pour assurer la cohérence lors du chargement direct de l'objet Game.
            editor.putInt(HIGH_SCORE_KEY, game.getHighestScore());
        } else {
            Log.w(TAG, "saveGame: Instance 'game' est null, suppression de l'état sauvegardé.");
            // Optionnel: nettoyer la clé si pas de jeu à sauvegarder
            editor.remove(GAME_STATE_KEY);
        }
        editor.apply(); // Sauvegarde asynchrone
    }

    /**
     * Charge l'état du jeu depuis les SharedPreferences.
     * 1. Tente de lire la chaîne sérialisée de l'état du jeu (`GAME_STATE_KEY`).
     * 2. Charge le meilleur score global (`HIGH_SCORE_KEY`) depuis les préférences (cette valeur
     * a aussi été chargée par `gameStats`, on s'assure de la cohérence).
     * 3. Tente de désérialiser la chaîne d'état en un objet `Game`.
     * 4. Si la désérialisation réussit :
     * a. Assigne l'objet chargé à la variable `game`.
     * b. Applique le meilleur score chargé à l'objet `game`.
     * c. Détermine l'état `currentGameState` (PLAYING, WON_DIALOG_SHOWN, GAME_OVER) basé sur le jeu chargé.
     * 5. Si la désérialisation échoue ou si aucune sauvegarde n'existe, `game` reste `null` (ou devient `null`),
     * ce qui déclenchera la création d'une nouvelle partie dans `initializeGameAndStats`.
     */
    private void loadGame() {
        if (preferences == null) {
            Log.e(TAG, "loadGame: SharedPreferences non initialisé, chargement annulé.");
            game = null;
            return;
        }
        Log.d(TAG, "loadGame: Tentative de chargement de l'état du jeu.");
        String gameStateString = preferences.getString(GAME_STATE_KEY, null);

        // Charge le meilleur score depuis les préférences directes.
        int loadedHighScore = preferences.getInt(HIGH_SCORE_KEY, 0);
        Log.d(TAG, "loadGame: High score chargé depuis les prefs directes: " + loadedHighScore);

        // Assure que GameStats (qui a aussi chargé les prefs) est la source de vérité pour le HS
        if (gameStats == null) {
            Log.w(TAG, "loadGame: GameStats est null lors du chargement! Tentative d'init.");
            gameStats = new GameStats(this); // Devrait déjà être initialisé, mais sécurité
        }
        // Prend le max entre le HS chargé directement et celui chargé par GameStats
        int authoritativeHighScore = Math.max(loadedHighScore, gameStats.getOverallHighScore());
        // S'assure que GameStats a bien la valeur la plus élevée
        if (authoritativeHighScore > gameStats.getOverallHighScore()) {
            gameStats.setHighestScore(authoritativeHighScore);
            // Pas besoin de saveStats() ici, sera fait dans onPause.
        }
        Log.d(TAG, "loadGame: High score définitif après synchro avec GameStats: " + authoritativeHighScore);


        Game loadedGame = null;
        if (gameStateString != null) {
            Log.d(TAG, "loadGame: État de jeu trouvé dans les prefs, tentative de désérialisation.");
            loadedGame = Game.deserialize(gameStateString);
        } else {
            Log.d(TAG, "loadGame: Aucun état de jeu trouvé dans les prefs.");
        }

        // Si un jeu a été correctement désérialisé
        if (loadedGame != null) {
            Log.i(TAG, "loadGame: Désérialisation réussie.");
            game = loadedGame;
            game.setHighestScore(authoritativeHighScore); // Applique le HS synchronisé

            // Détermine l'état de jeu basé sur le plateau chargé
            if (game.isGameOver()) {
                Log.d(TAG, "loadGame: État chargé -> GAME_OVER");
                currentGameState = GameFlowState.GAME_OVER;
            } else if (game.isGameWon()) {
                // Si on charge une partie déjà gagnée, on considère qu'on a déjà vu la dialogue
                Log.d(TAG, "loadGame: État chargé -> WON_DIALOG_SHOWN (partie déjà gagnée)");
                currentGameState = GameFlowState.WON_DIALOG_SHOWN;
            } else {
                Log.d(TAG, "loadGame: État chargé -> PLAYING");
                currentGameState = GameFlowState.PLAYING;
                // Le timer sera (re)démarré dans onResume si l'état est PLAYING
            }
        } else {
            // Pas de sauvegarde valide ou erreur de désérialisation
            Log.w(TAG, "loadGame: Échec de la désérialisation ou pas de sauvegarde. 'game' mis à null.");
            game = null; // Force à null pour déclencher startNewGame() dans initializeGameAndStats
            currentGameState = GameFlowState.PLAYING; // Nouvel état par défaut
        }
    }

    /**
     * Sauvegarde le timestamp actuel dans les SharedPreferences.
     * Utilisé par {@link NotificationService} pour déterminer l'inactivité.
     * Appelée dans `onPause`.
     */
    private void saveLastPlayedTime() {
        if (preferences != null) {
            long now = System.currentTimeMillis();
            Log.d(TAG, "Enregistrement du dernier temps joué: " + now);
            preferences.edit().putLong(LAST_PLAYED_TIME_KEY, now).apply();
        } else {
            Log.e(TAG, "Impossible d'enregistrer le dernier temps joué, SharedPreferences est null.");
        }
    }

}