/*
 * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
 */

package com.tiedup.remake.rig.tick;

import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

import com.mojang.logging.LogUtils;

import net.minecraft.client.Minecraft;
import net.minecraft.world.entity.player.Player;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;

import org.slf4j.Logger;

import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.rig.TiedUpAnimationRegistry;
import com.tiedup.remake.rig.TiedUpRigRegistry;
import com.tiedup.remake.rig.anim.Animator;
import com.tiedup.remake.rig.anim.LivingMotions;
import com.tiedup.remake.rig.anim.client.ClientAnimator;
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
import com.tiedup.remake.rig.anim.types.StaticAnimation;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
import com.tiedup.remake.rig.patch.TiedUpCapabilities;

/**
 * Phase 2.7 — tick handler RIG côté client. Pour chaque player visible :
 *
 * <ol>
 *   <li>Récupère le {@link LivingEntityPatch} via
 *       {@link TiedUpCapabilities#getEntityPatch(net.minecraft.world.entity.Entity, Class)}</li>
 *   <li>Ticke l'{@link Animator} (avance les layers EF, gère les transitions,
 *       etc.)</li>
 *   <li>Si aucune animation "réelle" n'est active (= on joue encore
 *       l'{@code EMPTY_ANIMATION} du fallback initial), déclenche une
 *       transition vers {@link TiedUpAnimationRegistry#CONTEXT_STAND_IDLE}
 *       (0.2s) — preuve vivante que le pipeline tourne Phase 2.7.</li>
 * </ol>
 *
 * <h2>Choix de design</h2>
 * <ul>
 *   <li><b>Phase END</b> : on tick après la logique vanilla de la frame pour
 *       que l'animator voit le dernier {@code yBodyRot}, position, vélocité
 *       écrits par MC. Pattern EF standard.</li>
 *   <li><b>Itération {@code level.players()}</b> : uniquement les joueurs
 *       dans la dimension courante — les autres ont leurs patches suspendus
 *       de toutes façons. Évite d'itérer les entities tiers non-patchées
 *       (Phase 5+ quand NPCs auront RIG).</li>
 *   <li><b>Error handling per-entity</b> : try/catch autour de chaque patch
 *       tick. Un patch qui throw ne cascade pas sur les autres. L'erreur
 *       est logguée <b>une seule fois par UUID</b> pour éviter de spammer
 *       la console.</li>
 *   <li><b>Registry pas ready → noop</b> : si
 *       {@link TiedUpAnimationRegistry#CONTEXT_STAND_IDLE} est null (setup
 *       pas encore exécuté, ou fallback d'échec), on ne crash pas — on
 *       skippe juste le trigger idle. L'animator tourne quand même
 *       (EMPTY_ANIMATION en boucle).</li>
 * </ul>
 *
 * <h2>Futur Phase 3+</h2>
 * <p>Ce handler est minimal — Phase 3 ajoutera :</p>
 * <ul>
 *   <li>Détection motion (WALK, SIT, KNEEL, etc.) via {@code updateMotion}
 *       et binding dynamic des anims correspondantes</li>
 *   <li>Resolve context bondage (arms_bound / legs_bound / etc.) via
 *       {@code RigContextResolver}</li>
 *   <li>Tick des NPCs TiedUp quand leurs patches seront enregistrés</li>
 * </ul>
 *
 * <p>Cf. {@code docs/plans/rig/MIGRATION.md} Phase 3 — Animation consumers.</p>
 */
@Mod.EventBusSubscriber(
    modid = TiedUpMod.MOD_ID,
    bus = Mod.EventBusSubscriber.Bus.FORGE,
    value = Dist.CLIENT
)
public final class RigAnimationTickHandler {

    private static final Logger LOGGER = LogUtils.getLogger();

    /** Set des UUIDs ayant déjà loggé une erreur tick — évite spam console. */
    private static final Set<UUID> LOGGED_ERRORS = ConcurrentHashMap.newKeySet();

    /** Cap de taille — reset si dépassé (évite leak en session infinie). */
    private static final int MAX_LOGGED_UUIDS = 1024;

    private RigAnimationTickHandler() {}

    @SubscribeEvent
    public static void onClientTick(TickEvent.ClientTickEvent event) {
        if (event.phase != TickEvent.Phase.END) {
            return;
        }

        Minecraft mc = Minecraft.getInstance();

        // Loading screen, splash, disconnect, F3+T reload in progress — level null.
        // Early-return silencieux (évite les logs parasites).
        if (mc.level == null) {
            return;
        }

        // Pause menu / options screen — on skip le tick anim pour matcher
        // le comportement vanilla MC (tick world paused).
        if (mc.isPaused()) {
            return;
        }

        // Cap de taille du set loggé — reset préventif.
        if (LOGGED_ERRORS.size() > MAX_LOGGED_UUIDS) {
            LOGGED_ERRORS.clear();
        }

        // On itère directement level.players() — pas de copie défensive :
        // le coût d'allocation par tick client (~60 fois/sec) n'est pas
        // justifié pour une liste typiquement petite (~10 joueurs visibles
        // max) alors qu'un CME join/leave pendant une frame est déjà attrapé
        // par le try/catch per-entity dans tickPlayer(). Tradeoff perf vs
        // correctness : correctness déjà assurée par le catch, le CME est
        // une anomalie transitoire qui sera resync à la frame suivante.
        try {
            for (Player player : mc.level.players()) {
                tickPlayer(player);
            }
        } catch (java.util.ConcurrentModificationException cme) {
            // Un join/leave MP ou un unload de dimension pendant l'itération.
            // On skip le reste du tick — la frame suivante repartira propre.
            // Pas de log : anomalie transitoire attendue (<1/session typique).
        }
    }

    /**
     * Ticke le patch RIG d'un joueur. Noop si :
     * <ul>
     *   <li>Pas de capability attachée (ex : le type d'entity n'est pas dans
     *       {@code EntityPatchProvider.CAPABILITIES})</li>
     *   <li>L'{@code Animator} est null (entitypatch pas encore onConstructed)</li>
     * </ul>
     *
     * <p>Toute exception thrown par le patch / animator est caught et logguée
     * une seule fois par UUID — le player reste rendu par le fallback vanilla
     * du pipeline render engine (cf. {@code TiedUpRenderEngine.onRenderLiving}).</p>
     */
    private static void tickPlayer(Player player) {
        LivingEntityPatch<?> patch =
            TiedUpCapabilities.getEntityPatch(player, LivingEntityPatch.class);

        if (patch == null) {
            return;
        }

        Animator animator = patch.getAnimator();
        if (animator == null) {
            return;
        }

        UUID uuid = player.getUUID();

        try {
            // 1. Tick de l'animator — avance les layers + gère les transitions
            //    EF natifs. C'est ce que le MixinLivingEntity.tick() ferait en
            //    Phase 3+ pattern push EF (cf. MIGRATION.md §2.2.1). Ici on
            //    tick côté client uniquement pour Phase 2.7.
            animator.tick();

            // 2. Trigger transition vers CONTEXT_STAND_IDLE si rien de mieux
            //    n'est actif. Idempotent : ne re-déclenche pas si l'anim
            //    courante est déjà CONTEXT_STAND_IDLE.
            maybePlayIdle(patch, animator);

        } catch (Throwable t) {
            if (LOGGED_ERRORS.add(uuid)) {
                LOGGER.error(
                    "RigAnimationTickHandler: tick animator failed for player {} ({}) — "
                    + "subsequent errors suppressed for this UUID.",
                    player.getGameProfile().getName(), uuid, t
                );
            }
            // Pas de rethrow — le joueur continue d'être rendu en vanilla (ou
            // stale pose). Le pipeline render dispose de son propre fallback.
        }
    }

    /**
     * Déclenche la transition vers {@code CONTEXT_STAND_IDLE} si :
     * <ul>
     *   <li>Le registry est ready (placeholder asset loadé)</li>
     *   <li>Le patch est dans {@code LivingMotions.IDLE} (pas en action
     *       particulière — walk, sit, etc. Phase 3+)</li>
     *   <li>L'animation actuellement jouée sur le base layer n'est pas déjà
     *       CONTEXT_STAND_IDLE (évite le re-trigger à chaque tick)</li>
     * </ul>
     *
     * <p>Transition 0.2s (4 ticks @ 20tps) pour un fondu doux avec
     * l'EMPTY_ANIMATION de départ. Ça reste court — si Phase 3+ veut un fondu
     * plus long on passera la valeur en param.</p>
     */
    private static void maybePlayIdle(LivingEntityPatch<?> patch, Animator animator) {
        if (!TiedUpAnimationRegistry.isReady()) {
            return;
        }

        if (patch.getCurrentLivingMotion() != LivingMotions.IDLE) {
            return;
        }

        if (!(animator instanceof ClientAnimator clientAnimator)) {
            // Server-side Animator — rien à rendre. Phase 3+ si on veut sync
            // motion serveur, c'est là qu'on le fera.
            return;
        }

        // Check si la CONTEXT_STAND_IDLE est déjà l'anim courante du base layer.
        // getPlayerFor(null) retourne base layer player (non-null en EF).
        AssetAccessor<? extends DynamicAnimation> currentAnim =
            clientAnimator.baseLayer.animationPlayer.getAnimation();

        StaticAnimation target = TiedUpAnimationRegistry.CONTEXT_STAND_IDLE;

        if (currentAnim != null && currentAnim.get() != null) {
            // Compare directement les instances — les StaticAnimation sont
            // singletons (registry constructor pattern). equals() fallback
            // sur id et les accessors sont null pour EMPTY, donc ID-based
            // comparison pas fiable ici. Instance check suffit.
            if (currentAnim.get() == target) {
                return;
            }
        }

        // Self-heal : si le bind IDLE d'un patch (PlayerPatch.addAnimations)
        // a été construit AVANT que TiedUpAnimationRegistry.initStaticAnimations()
        // ait tourné, le bind pointe encore sur EMPTY_ANIMATION au lieu de
        // CONTEXT_STAND_IDLE. On rebind ici — idempotent : les addLivingAnimation
        // suivantes écrasent simplement la précédente entry dans la map.
        //
        // Ce cas se produit si :
        //   - Le patch construit la première fois sur le login (pré-setup)
        //   - Un test runClient démarre avant FMLCommonSetupEvent async enqueueWork
        //   - L'asset CONTEXT_STAND_IDLE est en fallback EMPTY après échec JSON
        //     (registry throw swallowed → CONTEXT_STAND_IDLE reste null cf. le
        //     isReady() guard au-dessus → on n'entre même pas dans ce bloc).
        //
        // On utilise getLivingAnimation(motion, defaultGetter) avec null comme
        // default pour distinguer "pas de bind" d'un bind explicite vers
        // EMPTY_ANIMATION. Dans les deux cas on rebind.
        AssetAccessor<? extends StaticAnimation> currentIdleBind =
            clientAnimator.getLivingAnimation(LivingMotions.IDLE, null);
        if (currentIdleBind == null || currentIdleBind == TiedUpRigRegistry.EMPTY_ANIMATION) {
            clientAnimator.addLivingAnimation(LivingMotions.IDLE, target.getAccessor());
        }

        clientAnimator.playAnimation(target.getAccessor(), 0.2F);
    }

    /**
     * Reset des erreurs loggées. Appelé :
     * <ul>
     *   <li>Par les tests unitaires (isolation d'état entre cas)</li>
     *   <li>Par {@code TiedUpRenderEngine.ModBusEvents#onAddLayers} sur chaque
     *       F3+T reload / resource-pack swap. Un UUID ayant crashé pré-reload
     *       reste sinon marqué et masquerait tout nouveau succès / crash
     *       post-reload. Borne aussi la taille du set en session longue.</li>
     * </ul>
     *
     * <p>Thread-safety : {@code LOGGED_ERRORS} est un
     * {@link ConcurrentHashMap#newKeySet()} — aucun besoin de synchronized
     * même si appelé depuis un thread loader pendant qu'un tick client est
     * en flight.</p>
     */
    public static void resetLoggedErrors() {
        LOGGED_ERRORS.clear();
    }
}
