<?php
/**
 * Classe de gestion de la memoire apprenants
 *
 * CRUD pour la table slidemind_learner_memory.
 * Persiste des variables entre modules/courses, liees au wp_user_id.
 *
 * @package SlideMind\Core
 * @since 2.1.0
 */

namespace SlideMind\Core;

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

class LearnerMemory {

    /**
     * Scopes valides pour les variables
     */
    private const VALID_SCOPES = ['global', 'course', 'module'];

    /**
     * Groupe de cache pour la resolution email -> user_id
     */
    private const CACHE_GROUP = 'slidemind_user_resolve';

    /**
     * Noms de colonnes reserves (interdits comme noms de variables)
     */
    private const RESERVED_NAMES = [
        'id',
        'wp_user_id',
        'session_id',
        'scope',
        'scope_id',
        'variable_name',
        'variable_value',
        'created_at',
        'updated_at',
    ];

    /**
     * Taille maximale d'une valeur serialisee (64 KB)
     */
    private const MAX_VALUE_SIZE = 65536;

    /**
     * Profondeur maximale pour json_encode
     */
    private const MAX_JSON_DEPTH = 10;

    /**
     * Nombre maximal d'elements recursifs dans un tableau/objet
     */
    private const MAX_ARRAY_ELEMENTS = 1000;

    /**
     * Retourne le nom complet de la table
     */
    private function get_table_name(): string {
        global $wpdb;
        return $wpdb->prefix . 'slidemind_learner_memory';
    }

    /**
     * Verifie si un scope est valide
     */
    private function is_valid_scope(string $scope): bool {
        return in_array($scope, self::VALID_SCOPES, true);
    }

    /**
     * Normalise le scope_id : retourne 0 si scope global
     */
    private function normalize_scope_id(string $scope, ?int $scope_id): int {
        if ($scope === 'global') {
            return 0;
        }
        return max(0, (int) $scope_id);
    }

    /**
     * Serialise une valeur pour stockage en base
     *
     * @param mixed $value Valeur a serialiser
     * @return string|false Valeur serialisee ou false si invalide
     */
    private function serialize_value(mixed $value): string|false {
        if (is_null($value)) {
            return '';
        }

        if (is_bool($value)) {
            return $value ? '1' : '0';
        }

        if (is_int($value) || is_float($value)) {
            return (string) $value;
        }

        if (is_string($value)) {
            if (strlen($value) > self::MAX_VALUE_SIZE) {
                return false;
            }
            return $value;
        }

        if (is_array($value) || is_object($value)) {
            if ($this->count_elements_recursive($value) > self::MAX_ARRAY_ELEMENTS) {
                return false;
            }

            $json = wp_json_encode($value, 0, self::MAX_JSON_DEPTH);

            if ($json === false) {
                return false;
            }

            if (strlen($json) > self::MAX_VALUE_SIZE) {
                return false;
            }

            return $json;
        }

        return false;
    }

    /**
     * Compte recursivement le nombre d'elements dans un tableau/objet
     */
    private function count_elements_recursive(mixed $data, int $depth = 0): int {
        if ($depth > self::MAX_JSON_DEPTH) {
            return self::MAX_ARRAY_ELEMENTS + 1;
        }

        if (!is_array($data) && !is_object($data)) {
            return 1;
        }

        $count = 0;
        foreach ((array) $data as $item) {
            $count += $this->count_elements_recursive($item, $depth + 1);
            if ($count > self::MAX_ARRAY_ELEMENTS) {
                return $count;
            }
        }

        return $count;
    }

    /**
     * Valide un nom de variable
     *
     * @param string $name Nom a valider
     * @return bool True si valide
     */
    private function validate_variable_name(string $name): bool {
        // Lowercase uniquement, commence par lettre, alphanumeriques/underscores, max 255 chars
        if (!preg_match('/^[a-z][a-z0-9_]{0,254}$/', $name)) {
            return false;
        }

        // Rejet des noms reserves (colonnes de la table)
        if (in_array($name, self::RESERVED_NAMES, true)) {
            return false;
        }

        // sanitize_key puis verification que le resultat est identique
        $sanitized = sanitize_key($name);
        if ($sanitized !== $name) {
            return false;
        }

        return true;
    }

    /**
     * Valide un session_id (UUID v4)
     *
     * @param string $session_id
     * @return bool
     */
    public static function is_valid_session_id(string $session_id): bool {
        return (bool) preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $session_id);
    }

    // =========================================================================
    // Methodes publiques
    // =========================================================================

    /**
     * Resout un email en wp_user_id avec cache objet
     *
     * @param string $email Email de l'apprenant
     * @return int|false ID utilisateur ou false si introuvable
     */
    public function resolve_user_id(string $email): int|false {
        $email = sanitize_email($email);

        if (!is_email($email)) {
            return false;
        }

        $cache_key = 'email_' . md5($email);
        $found = false;
        $cached = wp_cache_get($cache_key, self::CACHE_GROUP, false, $found);

        if ($found) {
            return $cached;
        }

        $user = get_user_by('email', $email);

        if ($user) {
            wp_cache_set($cache_key, (int) $user->ID, self::CACHE_GROUP, 3600);
            return (int) $user->ID;
        }

        // Cache negatif 60 secondes
        wp_cache_set($cache_key, false, self::CACHE_GROUP, 60);
        return false;
    }

    /**
     * Invalide le cache de resolution email -> user_id
     *
     * Appelee par les hooks user_register et profile_update
     *
     * @param int $user_id ID utilisateur WordPress
     */
    public function invalidate_user_cache(int $user_id): void {
        $user = get_userdata($user_id);

        if ($user && !empty($user->user_email)) {
            $cache_key = 'email_' . md5($user->user_email);
            wp_cache_delete($cache_key, self::CACHE_GROUP);
        }
    }

    /**
     * Enregistre une ou plusieurs variables
     *
     * @param int    $user_id    ID utilisateur WordPress (0 pour anonyme)
     * @param string $scope      Portee (global, course, module)
     * @param ?int   $scope_id   ID du scope (0 si global)
     * @param array  $variables  Tableau associatif nom => valeur
     * @param string $session_id UUID v4 pour sessions anonymes (vide si authentifie)
     * @return int Nombre de variables sauvegardees
     */
    public function set(int $user_id, string $scope, ?int $scope_id, array $variables, string $session_id = ''): int {
        global $wpdb;

        if ($user_id <= 0 && empty($session_id)) {
            return 0;
        }

        if (!$this->is_valid_scope($scope)) {
            return 0;
        }

        if (empty($variables)) {
            return 0;
        }

        if (!empty($session_id) && !self::is_valid_session_id($session_id)) {
            return 0;
        }

        // Authenticated users always use empty session_id
        if ($user_id > 0) {
            $session_id = '';
        }

        $scope_id = $this->normalize_scope_id($scope, $scope_id);
        $table = $this->get_table_name();
        $saved = 0;

        foreach ($variables as $name => $value) {
            if (!$this->validate_variable_name((string) $name)) {
                continue;
            }

            $serialized = $this->serialize_value($value);
            if ($serialized === false) {
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Intentional debug logging
                error_log(sprintf(
                    '[SM LearnerMemory] Valeur rejetee pour la variable "%s" : type %s',
                    (string) $name,
                    gettype($value)
                ));
                continue;
            }

            // phpcs:disable WordPress.DB.DirectDatabaseQuery
            $result = $wpdb->query(
                $wpdb->prepare(
                    "INSERT INTO %i (wp_user_id, session_id, scope, scope_id, variable_name, variable_value, created_at, updated_at)
                    VALUES (%d, %s, %s, %d, %s, %s, NOW(), NOW())
                    ON DUPLICATE KEY UPDATE variable_value = %s, updated_at = NOW()",
                    $table,
                    $user_id,
                    $session_id,
                    $scope,
                    $scope_id,
                    (string) $name,
                    $serialized,
                    $serialized
                )
            );
            // phpcs:enable WordPress.DB.DirectDatabaseQuery

            if ($result !== false) {
                $saved++;
            }
        }

        return $saved;
    }

    /**
     * Recupere des variables
     *
     * @param int    $user_id    ID utilisateur WordPress (0 pour anonyme)
     * @param string $scope      Portee
     * @param ?int   $scope_id   ID du scope
     * @param ?array $keys       Cles specifiques (null = toutes)
     * @param string $session_id UUID v4 pour sessions anonymes
     * @return array Tableau associatif nom => valeur
     */
    public function get(int $user_id, string $scope, ?int $scope_id, ?array $keys = null, string $session_id = ''): array {
        global $wpdb;

        if (($user_id <= 0 && empty($session_id)) || !$this->is_valid_scope($scope)) {
            return [];
        }

        // Authenticated users always use empty session_id
        if ($user_id > 0) {
            $session_id = '';
        }

        $scope_id = $this->normalize_scope_id($scope, $scope_id);
        $table = $this->get_table_name();

        if ($keys !== null && !empty($keys)) {
            // Filtre par cles specifiques
            $placeholders = implode(',', array_fill(0, count($keys), '%s'));
            $params = array_merge([$table, $user_id, $session_id, $scope, $scope_id], array_values($keys));

            // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $results = $wpdb->get_results(
                $wpdb->prepare(
                    "SELECT variable_name, variable_value FROM %i
                    WHERE wp_user_id = %d AND session_id = %s AND scope = %s AND scope_id = %d
                    AND variable_name IN ($placeholders)",
                    $params
                ),
                ARRAY_A
            );
            // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        } else {
            // Toutes les variables du scope
            // phpcs:disable WordPress.DB.DirectDatabaseQuery
            $results = $wpdb->get_results(
                $wpdb->prepare(
                    "SELECT variable_name, variable_value FROM %i
                    WHERE wp_user_id = %d AND session_id = %s AND scope = %s AND scope_id = %d",
                    $table,
                    $user_id,
                    $session_id,
                    $scope,
                    $scope_id
                ),
                ARRAY_A
            );
            // phpcs:enable WordPress.DB.DirectDatabaseQuery
        }

        if (empty($results)) {
            return [];
        }

        $output = [];
        foreach ($results as $row) {
            $output[$row['variable_name']] = $row['variable_value'];
        }

        return $output;
    }

    /**
     * Recupere une variable avec cascade : module -> course -> global
     *
     * Les valeurs vides ('') sont ignorees (la cascade continue)
     *
     * @param int     $user_id       ID utilisateur (0 pour anonyme)
     * @param ?int    $module_id     ID du module (null si pas de contexte module)
     * @param ?int    $course_id     ID du course (null si pas de contexte course)
     * @param string  $key           Nom de la variable
     * @param string  $session_id    UUID v4 pour sessions anonymes
     * @return ?string Valeur ou null si introuvable
     */
    public function get_with_cascade(int $user_id, ?int $module_id, ?int $course_id, string $key, string $session_id = ''): ?string {
        // Niveau module
        if ($module_id !== null && $module_id > 0) {
            $result = $this->get($user_id, 'module', $module_id, [$key], $session_id);
            if (!empty($result) && isset($result[$key]) && $result[$key] !== '') {
                return $result[$key];
            }
        }

        // Niveau course
        if ($course_id !== null && $course_id > 0) {
            $result = $this->get($user_id, 'course', $course_id, [$key], $session_id);
            if (!empty($result) && isset($result[$key]) && $result[$key] !== '') {
                return $result[$key];
            }
        }

        // Niveau global
        $result = $this->get($user_id, 'global', null, [$key], $session_id);
        if (!empty($result) && isset($result[$key]) && $result[$key] !== '') {
            return $result[$key];
        }

        return null;
    }

    /**
     * Version batch optimisee de get_with_cascade
     *
     * Resout les cles non trouvees niveau par niveau (max 3 requetes SQL)
     *
     * @param int    $user_id       ID utilisateur (0 pour anonyme)
     * @param ?int   $module_id     ID du module
     * @param ?int   $course_id     ID du course
     * @param array  $keys          Noms des variables
     * @param string $session_id    UUID v4 pour sessions anonymes
     * @return array Tableau associatif nom => valeur (cles non trouvees absentes)
     */
    public function get_multiple_with_cascade(int $user_id, ?int $module_id, ?int $course_id, array $keys, string $session_id = ''): array {
        if (empty($keys)) {
            return [];
        }

        $resolved = [];
        $remaining = $keys;

        // Niveau module
        if ($module_id !== null && $module_id > 0 && !empty($remaining)) {
            $result = $this->get($user_id, 'module', $module_id, $remaining, $session_id);
            foreach ($result as $name => $value) {
                if ($value !== '') {
                    $resolved[$name] = $value;
                }
            }
            $remaining = array_diff($remaining, array_keys($resolved));
        }

        // Niveau course
        if ($course_id !== null && $course_id > 0 && !empty($remaining)) {
            $result = $this->get($user_id, 'course', $course_id, array_values($remaining), $session_id);
            foreach ($result as $name => $value) {
                if ($value !== '') {
                    $resolved[$name] = $value;
                }
            }
            $remaining = array_diff($remaining, array_keys($resolved));
        }

        // Niveau global
        if (!empty($remaining)) {
            $result = $this->get($user_id, 'global', null, array_values($remaining), $session_id);
            foreach ($result as $name => $value) {
                if ($value !== '') {
                    $resolved[$name] = $value;
                }
            }
        }

        return $resolved;
    }

    /**
     * Supprime des variables specifiques
     *
     * @param int    $user_id    ID utilisateur (0 pour anonyme)
     * @param string $scope      Portee
     * @param ?int   $scope_id   ID du scope
     * @param array  $keys       Noms des variables a supprimer
     * @param string $session_id UUID v4 pour sessions anonymes
     * @return bool True si au moins une variable supprimee
     */
    public function delete(int $user_id, string $scope, ?int $scope_id, array $keys, string $session_id = ''): bool {
        global $wpdb;

        if (($user_id <= 0 && empty($session_id)) || !$this->is_valid_scope($scope) || empty($keys)) {
            return false;
        }

        // Authenticated users always use empty session_id
        if ($user_id > 0) {
            $session_id = '';
        }

        $scope_id = $this->normalize_scope_id($scope, $scope_id);
        $table = $this->get_table_name();

        $placeholders = implode(',', array_fill(0, count($keys), '%s'));
        $params = array_merge([$table, $user_id, $session_id, $scope, $scope_id], array_values($keys));

        // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $deleted = $wpdb->query(
            $wpdb->prepare(
                "DELETE FROM %i
                WHERE wp_user_id = %d AND session_id = %s AND scope = %s AND scope_id = %d
                AND variable_name IN ($placeholders)",
                $params
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        return $deleted > 0;
    }

    /**
     * Purge toutes les donnees d'un apprenant
     *
     * @param int $user_id ID utilisateur
     * @return int Nombre de lignes supprimees
     */
    public function purge_user(int $user_id): int {
        global $wpdb;

        if ($user_id <= 0) {
            return 0;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery
        $deleted = $wpdb->delete(
            $this->get_table_name(),
            ['wp_user_id' => $user_id],
            ['%d']
        );

        return $deleted !== false ? (int) $deleted : 0;
    }

    /**
     * Purge les donnees d'un scope specifique
     *
     * @param int    $user_id  ID utilisateur
     * @param string $scope    Portee
     * @param ?int   $scope_id ID du scope
     * @return int Nombre de lignes supprimees
     */
    public function purge_scope(int $user_id, string $scope, ?int $scope_id): int {
        global $wpdb;

        if ($user_id <= 0 || !$this->is_valid_scope($scope)) {
            return 0;
        }

        $scope_id = $this->normalize_scope_id($scope, $scope_id);

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery
        $deleted = $wpdb->delete(
            $this->get_table_name(),
            [
                'wp_user_id' => $user_id,
                'scope'      => $scope,
                'scope_id'   => $scope_id,
            ],
            ['%d', '%s', '%d']
        );

        return $deleted !== false ? (int) $deleted : 0;
    }

    /**
     * Purge toutes les donnees d'une session anonyme
     *
     * @param string $session_id UUID v4 de la session
     * @return int Nombre de lignes supprimees
     */
    public function purge_session(string $session_id): int {
        global $wpdb;

        if (empty($session_id) || !self::is_valid_session_id($session_id)) {
            return 0;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery
        $deleted = $wpdb->delete(
            $this->get_table_name(),
            ['wp_user_id' => 0, 'session_id' => $session_id],
            ['%d', '%s']
        );

        return $deleted !== false ? (int) $deleted : 0;
    }

    /**
     * Purge les sessions anonymes expirees
     *
     * @param int $hours Nombre d'heures apres lesquelles une session expire (defaut 24)
     * @return int Nombre de lignes supprimees
     */
    public function purge_expired_sessions(int $hours = 24): int {
        global $wpdb;

        $table = $this->get_table_name();

        // phpcs:disable WordPress.DB.DirectDatabaseQuery
        $deleted = $wpdb->query(
            $wpdb->prepare(
                "DELETE FROM %i WHERE wp_user_id = 0 AND session_id != '' AND updated_at < DATE_SUB(NOW(), INTERVAL %d HOUR)",
                $table,
                $hours
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery

        return $deleted !== false ? (int) $deleted : 0;
    }

    /**
     * Recupere les noms de variables distincts stockes en memoire
     *
     * Utilise par l'autocomplete admin pour lister les variables {{memory.*}} disponibles.
     *
     * @param int $limit Nombre maximum de resultats
     * @return array Liste plate de noms de variables
     */
    public function get_distinct_variable_names(int $limit = 50): array {
        global $wpdb;

        // phpcs:disable WordPress.DB.DirectDatabaseQuery
        $results = $wpdb->get_col($wpdb->prepare(
            "SELECT DISTINCT variable_name FROM %i ORDER BY variable_name ASC LIMIT %d",
            $this->get_table_name(),
            $limit
        ));
        // phpcs:enable WordPress.DB.DirectDatabaseQuery

        return $results ?: [];
    }
}
