package com.replaymod.replay;

import com.github.steveice10.packetlib.io.NetOutput;
import com.github.steveice10.packetlib.tcp.io.ByteBufNetOutput;
import com.google.common.base.Preconditions;
import com.google.common.io.Files;
import com.replaymod.core.ReplayMod;
import com.replaymod.core.mixin.MinecraftAccessor;
import com.replaymod.core.mixin.TimerAccessor;
import com.replaymod.core.utils.Restrictions;
import com.replaymod.replay.camera.CameraEntity;
import com.replaymod.replaystudio.io.ReplayInputStream;
import com.replaymod.replaystudio.replay.ReplayFile;
import de.johni0702.minecraft.gui.utils.EventRegistrations;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelDuplexHandler;
import io.netty.channel.ChannelHandler.Sharable;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelPromise;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.network.OtherClientPlayerEntity;
import net.minecraft.client.gui.screen.DownloadingTerrainScreen;
import net.minecraft.client.gui.screen.NoticeScreen;
import net.minecraft.client.world.ClientWorld;
import net.minecraft.entity.Entity;
import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.network.NetworkState;
import net.minecraft.network.Packet;
import net.minecraft.util.PacketByteBuf;
import net.minecraft.network.packet.s2c.play.ChatMessageS2CPacket;
import net.minecraft.network.packet.s2c.play.CustomPayloadS2CPacket;
import net.minecraft.network.packet.s2c.play.DisconnectS2CPacket;
import net.minecraft.network.packet.s2c.play.EntitiesDestroyS2CPacket;
import net.minecraft.network.packet.s2c.play.EntitySpawnS2CPacket;
import net.minecraft.network.packet.s2c.play.ExperienceBarUpdateS2CPacket;
import net.minecraft.network.packet.s2c.play.ExperienceOrbSpawnS2CPacket;
import net.minecraft.network.packet.s2c.play.GameJoinS2CPacket;
import net.minecraft.network.packet.s2c.play.GameStateChangeS2CPacket;
import net.minecraft.network.packet.s2c.play.CloseContainerS2CPacket;
import net.minecraft.network.packet.s2c.play.OpenHorseContainerS2CPacket;
import net.minecraft.network.packet.s2c.play.ContainerSlotUpdateS2CPacket;
import net.minecraft.network.packet.s2c.play.ContainerPropertyUpdateS2CPacket;
import net.minecraft.network.packet.s2c.play.HealthUpdateS2CPacket;
import net.minecraft.network.packet.s2c.login.LoginSuccessS2CPacket;
import net.minecraft.network.packet.s2c.play.MobSpawnS2CPacket;
import net.minecraft.network.packet.s2c.play.PaintingSpawnS2CPacket;
import net.minecraft.network.packet.s2c.play.ParticleS2CPacket;
import net.minecraft.network.packet.s2c.play.PlayerAbilitiesS2CPacket;
import net.minecraft.network.packet.s2c.play.PlayerPositionLookS2CPacket;
import net.minecraft.network.packet.s2c.play.PlayerRespawnS2CPacket;
import net.minecraft.network.packet.s2c.play.PlayerSpawnS2CPacket;
import net.minecraft.network.packet.s2c.play.SignEditorOpenS2CPacket;
import net.minecraft.network.packet.s2c.play.StatisticsS2CPacket;
import net.minecraft.text.Text;
import net.minecraft.util.math.MathHelper;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;

//#if FABRIC>=1
import de.johni0702.minecraft.gui.versions.callbacks.PreTickCallback;
//#else
//$$ import net.minecraftforge.eventbus.api.SubscribeEvent;
//$$ import net.minecraftforge.event.TickEvent;
//#endif

//#if MC>=11600
//#else
import net.minecraft.network.packet.s2c.play.EntitySpawnGlobalS2CPacket;
//#endif

//#if MC>=11400
import com.replaymod.core.versions.MCVer;
import net.minecraft.network.packet.s2c.play.ChunkDataS2CPacket;
import net.minecraft.network.packet.s2c.play.PlayerActionResponseS2CPacket;
import net.minecraft.network.packet.s2c.play.OpenContainerS2CPacket;
import net.minecraft.network.packet.s2c.play.OpenWrittenBookS2CPacket;
import net.minecraft.entity.EntityType;
import net.minecraft.text.TranslatableText;
import net.minecraft.world.chunk.ChunkManager;
import net.minecraft.world.chunk.WorldChunk;
import net.minecraft.world.chunk.light.LightingProvider;
//#else
//$$ import net.minecraft.client.resources.I18n;
//$$ import net.minecraft.world.EnumDifficulty;
//$$ import net.minecraft.world.World;
//$$ import net.minecraft.world.WorldType;
//$$ import net.minecraft.world.chunk.Chunk;
//$$ import net.minecraft.world.chunk.IChunkProvider;
//$$ import java.util.Iterator;
//#endif

//#if MC>=11400
import net.minecraft.util.Identifier;
//#if MC<11400
//$$ import net.minecraft.world.dimension.DimensionType;
//#endif
//#endif

//#if MC>=11200
import com.replaymod.core.utils.WrappedTimer;
import net.minecraft.network.packet.s2c.play.AdvancementUpdateS2CPacket;
import net.minecraft.network.packet.s2c.play.SelectAdvancementTabS2CPacket;
import net.minecraft.network.packet.s2c.play.SynchronizeRecipesS2CPacket;
//#endif
//#if MC>=11002
import net.minecraft.world.GameMode;
//#else
//$$ import net.minecraft.world.WorldSettings.GameType;
//#endif

//#if MC>=10904
import net.minecraft.network.packet.s2c.play.UnloadChunkS2CPacket;
//#else
//$$ import net.minecraft.network.play.server.S21PacketChunkData;
//#endif

//#if MC>=10800
import net.minecraft.network.packet.s2c.play.ResourcePackSendS2CPacket;
import net.minecraft.network.packet.s2c.play.SetCameraEntityS2CPacket;
import net.minecraft.network.packet.s2c.play.TitleS2CPacket;
import net.minecraft.network.NetworkSide;
//#else
//$$ import org.apache.commons.io.Charsets;
//#endif

import java.io.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;

import static com.replaymod.core.versions.MCVer.*;
import static com.replaymod.replaystudio.util.Utils.readInt;

/**
 * Sends replay packets to netty channels.
 * Even though {@link Sharable}, this should never be added to multiple pipes at once, it may however be re-added when
 * the replay restart from the beginning.
 */
@Sharable
public class FullReplaySender extends ChannelDuplexHandler implements ReplaySender {
    /**
     * These packets are ignored completely during replay.
     */
    private static final List<Class> BAD_PACKETS = Arrays.<Class>asList(
            //#if MC>=11404
            PlayerActionResponseS2CPacket.class,
            //#endif
            //#if MC>=11400
            OpenWrittenBookS2CPacket.class,
            OpenContainerS2CPacket.class,
            //#endif
            //#if MC>=11200
            SynchronizeRecipesS2CPacket.class,
            AdvancementUpdateS2CPacket.class,
            SelectAdvancementTabS2CPacket.class,
            //#endif
            //#if MC>=10800
            SetCameraEntityS2CPacket.class,
            TitleS2CPacket.class,
            //#endif
            HealthUpdateS2CPacket.class,
            OpenHorseContainerS2CPacket.class,
            CloseContainerS2CPacket.class,
            ContainerSlotUpdateS2CPacket.class,
            ContainerPropertyUpdateS2CPacket.class,
            SignEditorOpenS2CPacket.class,
            StatisticsS2CPacket.class,
            ExperienceBarUpdateS2CPacket.class,
            PlayerAbilitiesS2CPacket.class
    );

    private static int TP_DISTANCE_LIMIT = 128;

    /**
     * The replay handler responsible for the current replay.
     */
    private final ReplayHandler replayHandler;

    /**
     * Whether to work in async mode.
     *
     * When in async mode, a separate thread send packets and waits according to their delays.
     * This is default in normal playback mode.
     *
     * When in sync mode, no packets will be sent until {@link #sendPacketsTill(int)} is called.
     * This is used during path playback and video rendering.
     */
    protected boolean asyncMode;

    /**
     * Timestamp of the last packet sent in milliseconds since the start.
     */
    protected int lastTimeStamp;

    /**
     * @see #currentTimeStamp()
     */
    protected int currentTimeStamp;

    /**
     * The replay file.
     */
    protected ReplayFile replayFile;

    /**
     * The channel handler context used to send packets to minecraft.
     */
    protected ChannelHandlerContext ctx;

    /**
     * The replay input stream from which new packets are read.
     * When accessing this stream make sure to synchronize on {@code this} as it's used from multiple threads.
     */
    protected ReplayInputStream replayIn;

    /**
     * The next packet that should be sent.
     * This is required as some actions such as jumping to a specified timestamp have to peek at the next packet.
     */
    protected PacketData nextPacket;

    /**
     * Whether we're currently reading packets from the login phase.
     */
    private boolean loginPhase;

    /**
     * Whether we need to restart the current replay. E.g. when jumping backwards in time
     */
    protected boolean startFromBeginning = true;

    /**
     * Whether to terminate the replay. This only has an effect on the async mode and is {@code true} during sync mode.
     */
    protected boolean terminate;

    /**
     * The speed of the replay. 1 is normal, 2 is twice as fast, 0.5 is half speed and 0 is frozen
     */
    protected double replaySpeed = 1f;

    /**
     * Whether the world has been loaded and the dirt-screen should go away.
     */
    protected boolean hasWorldLoaded;

    /**
     * The minecraft instance.
     */
    protected MinecraftClient mc = getMinecraft();

    /**
     * The total length of this replay in milliseconds.
     */
    protected final int replayLength;

    /**
     * Our actual entity id that the server gave to us.
     */
    protected int actualID = -1;

    /**
     * Whether to allow (process) the next player movement packet.
     */
    protected boolean allowMovement;

    /**
     * Directory to which resource packs are extracted.
     */
    private final File tempResourcePackFolder = Files.createTempDir();

    private final EventHandler events = new EventHandler();

    /**
     * Create a new replay sender.
     * @param file The replay file
     * @param asyncMode {@code true} for async mode, {@code false} otherwise
     * @see #asyncMode
     */
    public FullReplaySender(ReplayHandler replayHandler, ReplayFile file, boolean asyncMode) throws IOException {
        this.replayHandler = replayHandler;
        this.replayFile = file;
        this.asyncMode = asyncMode;
        this.replayLength = file.getMetaData().getDuration();

        events.register();

        if (asyncMode) {
            new Thread(asyncSender, "replaymod-async-sender").start();
        }
    }

    /**
     * Set whether this replay sender operates in async mode.
     * When in async mode, it will send packets timed from a separate thread.
     * When not in async mode, it will send packets when {@link #sendPacketsTill(int)} is called.
     * @param asyncMode {@code true} to enable async mode
     */
    @Override
    public void setAsyncMode(boolean asyncMode) {
        if (this.asyncMode == asyncMode) return;
        this.asyncMode = asyncMode;
        if (asyncMode) {
            this.terminate = false;
            new Thread(asyncSender, "replaymod-async-sender").start();
        } else {
            this.terminate = true;
        }
    }

    @Override
    public boolean isAsyncMode() {
        return asyncMode;
    }

    /**
     * Set whether this replay sender  to operate in sync mode.
     * When in sync mode, it will send packets when {@link #sendPacketsTill(int)} is called.
     * This call will block until the async worker thread has stopped.
     */
    @Override
    public void setSyncModeAndWait() {
        if (!this.asyncMode) return;
        this.asyncMode = false;
        this.terminate = true;
        synchronized (this) {
            // This will wait for the worker thread to leave the synchronized code part
        }
    }

    /**
     * Return a fake system tile in milliseconds value that respects slowdown/speedup/pause and works in both,
     * sync and async mode.
     * Note: For sync mode this returns the last value passed to {@link #sendPacketsTill(int)}.
     * @return The timestamp in milliseconds since the start of the replay
     */
    @Override
    public int currentTimeStamp() {
        if (asyncMode) {
            int timePassed = (int) (System.currentTimeMillis() - lastPacketSent);
            return lastTimeStamp + (int) (timePassed * getReplaySpeed());
        } else {
            return lastTimeStamp;
        }
    }

    /**
     * Terminate this replay sender.
     */
    public void terminateReplay() {
        if (terminate) {
            return;
        }
        terminate = true;
        events.unregister();
        try {
            channelInactive(ctx);
            ctx.channel().pipeline().close();
            FileUtils.deleteDirectory(tempResourcePackFolder);
        } catch(Exception e) {
            e.printStackTrace();
        }
    }

    //#if MC>=10800
    private
    //#else
    //$$ public // All event handlers need to be public in 1.7.10
    //#endif
    class EventHandler extends EventRegistrations {
        //#if FABRIC>=1
        { on(PreTickCallback.EVENT, this::onWorldTick); }
        private void onWorldTick() {
        //#else
        //$$ @SubscribeEvent
        //$$ public void onWorldTick(TickEvent.ClientTickEvent event) {
        //$$     // Unfortunately the WorldTickEvent doesn't seem to be emitted on the CLIENT side
        //$$     if (event.phase != TickEvent.Phase.START) return;
        //#endif

            // Spawning a player into an empty chunk (which we might do with the recording player)
            // prevents it from being moved by teleport packets (it essentially gets stuck) because
            // Entity#addedToChunk is not set and it is therefore not updated every tick.
            // To counteract this, we need to manually update it's position if it hasn't been added
            // to any chunk yet.
            if (mc.world != null) {
                for (PlayerEntity playerEntity : playerEntities(mc.world)) {
                    if (!playerEntity.updateNeeded && playerEntity instanceof OtherClientPlayerEntity) {
                        playerEntity.tickMovement();
                    }
                }
            }
        }
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg)
            throws Exception {
        // When in async mode and the replay sender shut down, then don't send packets
        if(terminate && asyncMode) {
            return;
        }

        // When a packet is sent directly, perform no filtering
        if(msg instanceof Packet) {
            super.channelRead(ctx, msg);
        }

        if (msg instanceof byte[]) {
            try {
                Packet p = deserializePacket((byte[]) msg);

                if (p != null) {
                    p = processPacket(p);
                    if (p != null) {
                        super.channelRead(ctx, p);
                    }

                    // If we do not give minecraft time to tick, there will be dead entity artifacts left in the world
                    // Therefore we have to remove all loaded, dead entities manually if we are in sync mode.
                    // We do this after every SpawnX packet and after the destroy entities packet.
                    if (!asyncMode && mc.world != null) {
                        if (p instanceof PlayerSpawnS2CPacket
                                || p instanceof EntitySpawnS2CPacket
                                || p instanceof MobSpawnS2CPacket
                                //#if MC<11600
                                || p instanceof EntitySpawnGlobalS2CPacket
                                //#endif
                                || p instanceof PaintingSpawnS2CPacket
                                || p instanceof ExperienceOrbSpawnS2CPacket
                                || p instanceof EntitiesDestroyS2CPacket) {
                            ClientWorld world = mc.world;
                            //#if MC>=11400
                            // Note: Not sure if it's still required but there's this really handy method anyway
                            world.finishRemovingEntities();
                            //#else
                            //$$ Iterator<Entity> iter = loadedEntityList(world).iterator();
                            //$$ while (iter.hasNext()) {
                            //$$     Entity entity = iter.next();
                            //$$     if (entity.isDead) {
                            //$$         int chunkX = entity.chunkCoordX;
                            //$$         int chunkY = entity.chunkCoordZ;
                            //$$
                                    //#if MC>=11400
                                    //$$ if (entity.addedToChunk && world.getChunkProvider().provideChunk(chunkX, chunkY, false, false) != null) {
                                    //#else
                                    //#if MC>=10904
                                    //$$ if (entity.addedToChunk && world.getChunkProvider().getLoadedChunk(chunkX, chunkY) != null) {
                                    //#else
                                    //$$ if (entity.addedToChunk && world.getChunkProvider().chunkExists(chunkX, chunkY)) {
                                    //#endif
                                    //#endif
                            //$$             world.getChunkFromChunkCoords(chunkX, chunkY).removeEntity(entity);
                            //$$         }
                            //$$
                            //$$         iter.remove();
                            //$$         world.onEntityRemoved(entity);
                            //$$     }
                            //$$
                            //$$ }
                            //#endif
                        }
                    }

                    //#if MC>=11400
                    if (p instanceof ChunkDataS2CPacket) {
                        Runnable doLightUpdates = () -> {
                            if (mc.world != null) {
                                LightingProvider provider = mc.world.getChunkManager().getLightingProvider();
                                while (provider.hasUpdates()) {
                                    provider.doLightUpdates(Integer.MAX_VALUE, true, true);
                                }
                            }
                        };
                        if (MCVer.isOnMainThread()) {
                            doLightUpdates.run();
                        } else {
                            MCVer.scheduleOnMainThread(doLightUpdates);
                        }
                    }
                    //#endif
                }
            } catch (Exception e) {
                // We'd rather not have a failure parsing one packet screw up the whole replay process
                e.printStackTrace();
            }
        }

    }

    private Packet deserializePacket(byte[] bytes) throws IOException, IllegalAccessException, InstantiationException {
        ByteBuf bb = Unpooled.wrappedBuffer(bytes);
        PacketByteBuf pb = new PacketByteBuf(bb);

        int i = pb.readVarInt();

        NetworkState state = loginPhase ? NetworkState.LOGIN : NetworkState.PLAY;
        //#if MC>=10800
        Packet p = state.getPacketHandler(NetworkSide.CLIENTBOUND, i);
        //#else
        //$$ Packet p = Packet.generatePacket(state.func_150755_b(), i);
        //#endif
        p.read(pb);

        return p;
    }

    /**
     * Process a packet and return the result.
     * @param p The packet to process
     * @return The processed packet or {@code null} if no packet shall be sent
     */
    protected Packet processPacket(Packet p) throws Exception {
        if (p instanceof LoginSuccessS2CPacket) {
            loginPhase = false;
            return p;
        }

        if (p instanceof CustomPayloadS2CPacket) {
            CustomPayloadS2CPacket packet = (CustomPayloadS2CPacket) p;
            if (Restrictions.PLUGIN_CHANNEL.equals(packet.getChannel())) {
                final String unknown = replayHandler.getRestrictions().handle(packet);
                if (unknown == null) {
                    return null;
                } else {
                    // Failed to parse options, make sure that under no circumstances further packets are parsed
                    terminateReplay();
                    // Then end replay and show error GUI
                    ReplayMod.instance.runLater(() -> {
                        try {
                            replayHandler.endReplay();
                        } catch (IOException e) {
                            e.printStackTrace();
                        }
                        mc.openScreen(new NoticeScreen(
                                //#if MC>=11400
                                () -> mc.openScreen(null),
                                new TranslatableText("replaymod.error.unknownrestriction1"),
                                new TranslatableText("replaymod.error.unknownrestriction2", unknown)
                                //#else
                                //$$ I18n.format("replaymod.error.unknownrestriction1"),
                                //$$ I18n.format("replaymod.error.unknownrestriction2", unknown)
                                //#endif
                        ));
                    });
                }
            }
        }
        if (p instanceof DisconnectS2CPacket) {
            Text reason = ((DisconnectS2CPacket) p).getReason();
            String message = reason.getString();
            if ("Please update to view this replay.".equals(message)) {
                // This version of the mod supports replay restrictions so we are allowed
                // to remove this packet.
                return null;
            }
        }

        if(BAD_PACKETS.contains(p.getClass())) return null;

        if (p instanceof CustomPayloadS2CPacket) {
            CustomPayloadS2CPacket packet = (CustomPayloadS2CPacket) p;
            //#if MC>=11400
            Identifier channelName = packet.getChannel();
            //#else
            //$$ String channelName = packet.getChannelName();
            //#endif
            // On 1.14+ there's a dedicated OpenWrittenBookS2CPacket now
            //#if MC<11400
            //#if MC>=11400
            //$$ if (SPacketCustomPayload.BOOK_OPEN.equals(channelName)) {
            //#else
            //$$ if ("MC|BOpen".equals(channelName)) {
            //#endif
            //$$     return null;
            //$$ }
            //#endif
        //#if MC>=10800
        }

        if(p instanceof ResourcePackSendS2CPacket) {
            ResourcePackSendS2CPacket packet = (ResourcePackSendS2CPacket) p;
            String url = packet.getURL();
            if (url.startsWith("replay://")) {
        //#else
        //$$     String url;
        //$$     if ("MC|RPack".equals(channelName) &&
        //$$             (url = new String(packet.func_149168_d(), Charsets.UTF_8)).startsWith("replay://")) {
        //#endif
                int id = Integer.parseInt(url.substring("replay://".length()));
                Map<Integer, String> index = replayFile.getResourcePackIndex();
                if (index != null) {
                    String hash = index.get(id);
                    if (hash != null) {
                        File file = new File(tempResourcePackFolder, hash + ".zip");
                        if (!file.exists()) {
                            IOUtils.copy(replayFile.getResourcePack(hash).get(), new FileOutputStream(file));
                        }
                        setServerResourcePack(file);
                    }
                }
                return null;
            }
        }

        if(p instanceof GameJoinS2CPacket) {
            GameJoinS2CPacket packet = (GameJoinS2CPacket) p;
            int entId = packet.getEntityId();
            allowMovement = true;
            actualID = entId;
            entId = -1789435; // Camera entity id should be negative which is an invalid id and can't be used by servers
            //#if MC>=11400
            p = new GameJoinS2CPacket(
                    entId,
                    GameMode.SPECTATOR,
                    //#if MC>=11600
                    //$$ GameMode.SPECTATOR,
                    //#endif
                    //#if MC>=11500
                    packet.getSeed(),
                    //#endif
                    false,
                    //#if MC>=11600
                    //$$ packet.method_29443(),
                    //$$ (net.minecraft.util.registry.RegistryTracker.Modifiable) packet.getDimension(),
                    //$$ packet.method_29444(),
                    //$$ packet.getDimensionId(),
                    //#else
                    packet.getDimension(),
                    //#endif
                    0, // max players (has no getter -> never actually used)
                    //#if MC<11600
                    packet.getGeneratorType(),
                    //#endif
                    packet.getChunkLoadDistance(),
                    packet.hasReducedDebugInfo()
                    //#if MC>=11500
                    , packet.showsDeathScreen()
                    //#endif
                    //#if MC>=11600
                    //$$ , packet.isDebugWorld()
                    //$$ , packet.isFlatWorld()
                    //#endif
            );
            //#else
            //#if MC>=10800
            //#if MC>=11400
            //$$ DimensionType dimension = packet.func_212642_e();
            //#else
            //$$ int dimension = packet.getDimension();
            //#endif
            //$$ EnumDifficulty difficulty = packet.getDifficulty();
            //#if MC>=11400
            //$$ int maxPlayers = 0; // literally never used by vanilla (i.e. no accessor)
            //#else
            //$$ int maxPlayers = packet.getMaxPlayers();
            //#endif
            //$$ WorldType worldType = packet.getWorldType();
            //$$
            //#if MC>=10904
            //$$ p = new SPacketJoinGame(entId, GameType.SPECTATOR, false, dimension,
            //$$         difficulty, maxPlayers, worldType, false);
            //#else
            //$$ p = new S01PacketJoinGame(entId, GameType.SPECTATOR, false, dimension,
            //$$         difficulty, maxPlayers, worldType, false);
            //#endif
            //#else
            //$$ int dimension = packet.func_149194_f();
            //$$ EnumDifficulty difficulty = packet.func_149192_g();
            //$$ int maxPlayers = packet.func_149193_h();
            //$$ WorldType worldType = packet.func_149196_i();
            //$$
            //$$ p = new S01PacketJoinGame(entId, GameType.ADVENTURE, false, dimension,
            //$$         difficulty, maxPlayers, worldType);
            //#endif
            //#endif
        }

        if(p instanceof PlayerRespawnS2CPacket) {
            PlayerRespawnS2CPacket respawn = (PlayerRespawnS2CPacket) p;
            //#if MC>=11400
            p = new PlayerRespawnS2CPacket(
                    //#if MC>=11600
                    //$$ respawn.method_29445(),
                    //#endif
                    respawn.getDimension(),
                    //#if MC>=11500
                    respawn.getSha256Seed(),
                    //#endif
                    //#if MC>=11600
                    //$$ GameMode.SPECTATOR,
                    //$$ GameMode.SPECTATOR,
                    //$$ respawn.isDebugWorld(),
                    //$$ respawn.isFlatWorld(),
                    //$$ respawn.isWritingErrorSkippable()
                    //#else
                    respawn.getGeneratorType(),
                    GameMode.SPECTATOR
                    //#endif
            );
            //#else
            //#if MC>=10809
            //$$ p = new SPacketRespawn(respawn.getDimensionID(),
            //$$         respawn.getDifficulty(), respawn.getWorldType(), GameType.SPECTATOR);
            //#else
            //$$ p = new S07PacketRespawn(respawn.func_149082_c(),
            //$$         respawn.func_149081_d(), respawn.func_149080_f(),
                    //#if MC>=10800
                    //$$ GameType.SPECTATOR);
                    //#else
                    //$$ GameType.ADVENTURE);
                    //#endif
            //#endif
            //#endif

            allowMovement = true;
        }

        if(p instanceof PlayerPositionLookS2CPacket) {
            final PlayerPositionLookS2CPacket ppl = (PlayerPositionLookS2CPacket) p;
            if(!hasWorldLoaded) hasWorldLoaded = true;

            if (mc.currentScreen instanceof DownloadingTerrainScreen) {
                // Close the world loading screen manually in case we swallow the packet
                mc.openScreen(null);
            }

            if(replayHandler.shouldSuppressCameraMovements()) return null;

            CameraEntity cent = replayHandler.getCameraEntity();

            //#if MC>=10800
            //#if MC>=11400
            for (PlayerPositionLookS2CPacket.Flag relative : ppl.getFlags()) {
                if (relative == PlayerPositionLookS2CPacket.Flag.X
                        || relative == PlayerPositionLookS2CPacket.Flag.Y
                        || relative == PlayerPositionLookS2CPacket.Flag.Z) {
            //#else
            //#if MC>=10904
            //$$ for (SPacketPlayerPosLook.EnumFlags relative : ppl.getFlags()) {
            //#else
            //$$ for (Object relative : ppl.func_179834_f()) {
            //#endif
            //$$     if (relative == SPacketPlayerPosLook.EnumFlags.X
            //$$             || relative == SPacketPlayerPosLook.EnumFlags.Y
            //$$             || relative == SPacketPlayerPosLook.EnumFlags.Z) {
            //#endif
                    return null; // At least one of the coordinates is relative, so we don't care
                }
            }
            //#endif

            if(cent != null) {
                if(!allowMovement && !((Math.abs(Entity_getX(cent) - ppl.getX()) > TP_DISTANCE_LIMIT) ||
                        (Math.abs(Entity_getZ(cent) - ppl.getZ()) > TP_DISTANCE_LIMIT))) {
                    return null;
                } else {
                    allowMovement = false;
                }
            }

            new Runnable() {
                @Override
                @SuppressWarnings("unchecked")
                public void run() {
                    if (mc.world == null || !isOnMainThread()) {
                        ReplayMod.instance.runLater(this);
                        return;
                    }

                    CameraEntity cent = replayHandler.getCameraEntity();
                    cent.setCameraPosition(ppl.getX(), ppl.getY(), ppl.getZ());
                }
            }.run();
        }

        if(p instanceof GameStateChangeS2CPacket) {
            GameStateChangeS2CPacket pg = (GameStateChangeS2CPacket)p;
            // only allow the following packets:
            // 1 - End raining
            // 2 - Begin raining
            //
            // The following values are to control sky color (e.g. if thunderstorm)
            // 7 - Fade value
            // 8 - Fade time
            if (!Arrays.asList(
                    //#if MC>=11600
                    //$$ GameStateChangeS2CPacket.RAIN_STARTED,
                    //$$ GameStateChangeS2CPacket.RAIN_STOPPED,
                    //$$ GameStateChangeS2CPacket.RAIN_GRADIENT_CHANGED,
                    //$$ GameStateChangeS2CPacket.THUNDER_GRADIENT_CHANGED
                    //#else
                    1,
                    2,
                    7,
                    8
                    //#endif
            ).contains(pg.getReason())) {
                return null;
            }
        }

        if (p instanceof ChatMessageS2CPacket) {
            if (!ReplayModReplay.instance.getCore().getSettingsRegistry().get(Setting.SHOW_CHAT)) {
                return null;
            }
        }

        return asyncMode ? processPacketAsync(p) : processPacketSync(p);
    }

    @Override
    @SuppressWarnings("unchecked")
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        this.ctx = ctx;
        super.channelActive(ctx);
    }

    @Override
    public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
        // The embedded channel's event loop will consider every thread to be in it and as such provides no
        // guarantees that only one thread is using the pipeline at any one time.
        // For reading the replay sender (either sync or async) is the only thread ever writing.
        // For writing it may very well happen that multiple threads want to use the pipline at the same time.
        // It's unclear whether the EmbeddedChannel is supposed to be thread-safe (the behavior of the event loop
        // does suggest that). However it seems like it either isn't (likely) or there is a race condition.
        // See: https://www.replaymod.com/forum/thread/1752#post8045 (https://paste.replaymod.com/lotacatuwo)
        // To work around this issue, we just outright drop all write/flush requests (they aren't needed anyway).
        // This still leaves channel handlers upstream with the threading issue but they all seem to cope well with it.
        promise.setSuccess();
    }

    @Override
    public void flush(ChannelHandlerContext ctx) throws Exception {
        // See write method above
    }

    /**
     * Returns the speed of the replay. 1 being normal speed, 0.5 half and 2 twice as fast.
     * If 0 is returned, the replay is paused.
     * @return speed multiplier
     */
    @Override
    public double getReplaySpeed() {
        if(!paused()) return replaySpeed;
        else return 0;
    }

    /**
     * Set the speed of the replay. 1 being normal speed, 0.5 half and 2 twice as fast.
     * The speed may not be set to 0 nor to negative values.
     * @param d Speed multiplier
     */
    @Override
    public void setReplaySpeed(final double d) {
        if(d != 0) this.replaySpeed = d;
        TimerAccessor timer = (TimerAccessor) ((MinecraftAccessor) mc).getTimer();
        //#if MC>=11200
        timer.setTickLength(WrappedTimer.DEFAULT_MS_PER_TICK / (float) d);
        //#else
        //$$ timer.setTimerSpeed((float) d);
        //#endif
    }

    /////////////////////////////////////////////////////////
    //       Asynchronous packet processing                //
    /////////////////////////////////////////////////////////

    /**
     * The real time at which the last packet was sent in milliseconds.
     */
    private long lastPacketSent;

    /**
     * There is no waiting performed until a packet with at least this timestamp is reached (but not yet sent).
     * If this is -1, then timing is normal.
     */
    private long desiredTimeStamp = -1;

    /**
     * Runnable which performs timed dispatching of packets from the input stream.
     */
    private Runnable asyncSender = new Runnable() {
        public void run() {
            try {
                while (ctx == null && !terminate) {
                    Thread.sleep(10);
                }
                REPLAY_LOOP:
                while (!terminate) {
                    synchronized (FullReplaySender.this) {
                        if (replayIn == null) {
                            replayIn = replayFile.getPacketData(getPacketTypeRegistry(true));
                        }
                        // Packet loop
                        while (true) {
                            try {
                                // When playback is paused and the world has loaded (we don't want any dirt-screens) we sleep
                                while (paused() && hasWorldLoaded) {
                                    // Unless we are going to terminate, restart or jump
                                    if (terminate || startFromBeginning || desiredTimeStamp != -1) {
                                        break;
                                    }
                                    Thread.sleep(10);
                                }

                                if (terminate) {
                                    break REPLAY_LOOP;
                                }

                                if (startFromBeginning) {
                                    // In case we need to restart from the beginning
                                    // break out of the loop sending all packets which will
                                    // cause the replay to be restarted by the outer loop
                                    break;
                                }

                                // Read the next packet if we don't already have one
                                if (nextPacket == null) {
                                    nextPacket = new PacketData(replayIn, loginPhase);
                                }

                                int nextTimeStamp = nextPacket.timestamp;

                                // If we aren't jumping and the world has already been loaded (no dirt-screens) then wait
                                // the required amount to get proper packet timing
                                if (!isHurrying() && hasWorldLoaded) {
                                    // How much time should have passed
                                    int timeWait = (int) Math.round((nextTimeStamp - lastTimeStamp) / replaySpeed);
                                    // How much time did pass
                                    long timeDiff = System.currentTimeMillis() - lastPacketSent;
                                    // How much time we need to wait to make up for the difference
                                    long timeToSleep = Math.max(0, timeWait - timeDiff);

                                    Thread.sleep(timeToSleep);
                                    lastPacketSent = System.currentTimeMillis();
                                }

                                // Process packet
                                channelRead(ctx, nextPacket.bytes);
                                nextPacket = null;

                                lastTimeStamp = nextTimeStamp;

                                // In case we finished jumping
                                // We need to check that we aren't planing to restart so we don't accidentally run this
                                // code before we actually restarted
                                if (isHurrying() && lastTimeStamp > desiredTimeStamp && !startFromBeginning) {
                                    desiredTimeStamp = -1;

                                    replayHandler.moveCameraToTargetPosition();

                                    // Pause after jumping
                                    setReplaySpeed(0);
                                }
                            } catch (EOFException eof) {
                                // Reached end of file
                                // Pause the replay which will cause it to freeze before getting restarted
                                setReplaySpeed(0);
                                // Then wait until the user tells us to continue
                                while (paused() && hasWorldLoaded && desiredTimeStamp == -1 && !terminate) {
                                    Thread.sleep(10);
                                }

                                if (terminate) {
                                    break REPLAY_LOOP;
                                }
                                break;
                            } catch (IOException e) {
                                e.printStackTrace();
                            }
                        }

                        // Restart the replay.
                        hasWorldLoaded = false;
                        lastTimeStamp = 0;
                        loginPhase = true;
                        startFromBeginning = false;
                        nextPacket = null;
                        lastPacketSent = System.currentTimeMillis();
                        if (replayIn != null) {
                            replayIn.close();
                            replayIn = null;
                        }
                        ReplayMod.instance.runSync(replayHandler::restartedReplay);
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    };

    /**
     * Return whether this replay sender is currently rushing. When rushing, all packets are sent without waiting until
     * a specified timestamp is passed.
     * @return {@code true} if currently rushing, {@code false} otherwise
     */
    public boolean isHurrying() {
        return desiredTimeStamp != -1;
    }

    /**
     * Cancels the hurrying.
     */
    public void stopHurrying() {
        desiredTimeStamp = -1;
    }

    /**
     * Return the timestamp to which this replay sender is currently rushing. All packets with an lower or equal
     * timestamp will be sent out without any sleeping.
     * @return The timestamp in milliseconds since the start of the replay
     */
    public long getDesiredTimestamp() {
        return desiredTimeStamp;
    }

    /**
     * Jumps to the specified timestamp when in async mode by rushing all packets until one with a timestamp greater
     * than the specified timestamp is found.
     * If the timestamp has already passed, this causes the replay to restart and then rush all packets.
     * @param millis Timestamp in milliseconds since the start of the replay
     */
    @Override
    public void jumpToTime(int millis) {
        Preconditions.checkState(asyncMode, "Can only jump in async mode. Use sendPacketsTill(int) instead.");
        if(millis < lastTimeStamp && !isHurrying()) {
            startFromBeginning = true;
        }

        desiredTimeStamp = millis;
    }

    protected Packet processPacketAsync(Packet p) {
        //If hurrying, ignore some packets, except for short durations
        if(desiredTimeStamp - lastTimeStamp > 1000) {
            if(p instanceof ParticleS2CPacket) return null;

            if(p instanceof EntitySpawnS2CPacket) {
                EntitySpawnS2CPacket pso = (EntitySpawnS2CPacket)p;
                //#if MC>=11400
                if (pso.getEntityTypeId() == EntityType.FIREWORK_ROCKET) return null;
                //#else
                //$$ int type = pso.getType();
                //$$ if(type == 76) { // Firework rocket
                //$$     return null;
                //$$ }
                //#endif
            }
        }
        return p;
    }

    /////////////////////////////////////////////////////////
    //        Synchronous packet processing                //
    /////////////////////////////////////////////////////////

    /**
     * Sends all packets until the specified timestamp is reached (inclusive).
     * If the timestamp is smaller than the last packet sent, the replay is restarted from the beginning.
     * @param timestamp The timestamp in milliseconds since the beginning of this replay
     */
    @Override
    public void sendPacketsTill(int timestamp) {
        Preconditions.checkState(!asyncMode, "This method cannot be used in async mode. Use jumpToTime(int) instead.");
        try {
            while (ctx == null && !terminate) { // Make sure channel is ready
                Thread.sleep(10);
            }

            synchronized (this) {
                if (timestamp == lastTimeStamp) { // Do nothing if we're already there
                    return;
                }
                if (timestamp < lastTimeStamp) { // Restart the replay if we need to go backwards in time
                    hasWorldLoaded = false;
                    lastTimeStamp = 0;
                    if (replayIn != null) {
                        replayIn.close();
                        replayIn = null;
                    }
                    loginPhase = true;
                    startFromBeginning = false;
                    nextPacket = null;
                    replayHandler.restartedReplay();
                }

                if (replayIn == null) {
                    replayIn = replayFile.getPacketData(getPacketTypeRegistry(true));
                }

                while (true) { // Send packets
                    try {
                        PacketData pd;
                        if (nextPacket != null) {
                            // If there is still a packet left from before, use it first
                            pd = nextPacket;
                            nextPacket = null;
                        } else {
                            // Otherwise read one from the input stream
                            pd = new PacketData(replayIn, loginPhase);
                        }

                        int nextTimeStamp = pd.timestamp;
                        if (nextTimeStamp > timestamp) {
                            // We are done sending all packets
                            nextPacket = pd;
                            break;
                        }

                        // Process packet
                        channelRead(ctx, pd.bytes);
                    } catch (EOFException eof) {
                        // Shit! We hit the end before finishing our job! What shall we do now?
                        // well, let's just pretend we're done...
                        replayIn = null;
                        break;
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }

                // This might be required if we change to async mode anytime soon
                lastPacketSent = System.currentTimeMillis();
                lastTimeStamp = timestamp;
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    protected Packet processPacketSync(Packet p) {
        //#if MC>=10904
        if (p instanceof UnloadChunkS2CPacket) {
            UnloadChunkS2CPacket packet = (UnloadChunkS2CPacket) p;
            int x = packet.getX();
            int z = packet.getZ();
        //#else
        //$$ if (p instanceof S21PacketChunkData && ((S21PacketChunkData) p).getExtractedSize() == 0) {
        //$$     S21PacketChunkData packet = (S21PacketChunkData) p;
        //$$     int x = packet.getChunkX();
        //$$     int z = packet.getChunkZ();
        //#endif
            // If the chunk is getting unloaded, we will have to forcefully update the position of all entities
            // within. Otherwise, if there wasn't a game tick recently, there may be entities that have moved
            // out of the chunk by now but are still registered in it. If we do not update those, they will get
            // unloaded even though they shouldn't.
            // Note: This is only half of the truth. Entities may be removed by chunk-unloading, see else-case below.
            // To make things worse, it seems like players were never supposed to be unloaded this way because
            // they will remain glitched in the World#playerEntities list.
            // 1.14+: The update issue remains but only for non-players and the unloading list bug appears to have been
            //        fixed (chunk unloading no longer removes the entities).
            // Get the chunk that will be unloaded
            //#if MC>=11400
            ClientWorld world = mc.world;
            ChunkManager chunkProvider = world.getChunkManager();
            WorldChunk chunk = chunkProvider.getWorldChunk(x, z
                    //#if MC<11500
                    //$$ , false
                    //#endif
            );
            if (chunk != null) {
            //#else
            //$$ World world = mc.world;
            //$$ IChunkProvider chunkProvider = world.getChunkProvider();
            //$$ Chunk chunk = chunkProvider.provideChunk(x, z);
            //$$ if (!chunk.isEmpty()) {
            //#endif
                List<Entity> entitiesInChunk = new ArrayList<>();
                // Gather all entities in that chunk
                for (Collection<Entity> entityList : getEntityLists(chunk)) {
                    entitiesInChunk.addAll(entityList);
                }
                for (Entity entity : entitiesInChunk) {
                    // Skip interpolation of position updates coming from server
                    // (See: newX in EntityLivingBase or otherPlayerMPX in EntityOtherPlayerMP)
                    // Needs to be called at least 4 times thanks to
                    // EntityOtherPlayerMP#otherPlayerMPPosRotationIncrements (max vanilla value is 3)
                    for (int i = 0; i < 4; i++) {
                        //#if MC>=11400
                        entity.tick();
                        //#else
                        //$$ entity.onUpdate();
                        //#endif
                    }

                    // Check whether the entity has left the chunk
                    //#if MC>=11404
                    int chunkX = MathHelper.floor(Entity_getX(entity) / 16);
                    int chunkY = MathHelper.floor(Entity_getY(entity) / 16);
                    int chunkZ = MathHelper.floor(Entity_getZ(entity) / 16);
                    if (entity.chunkX != chunkX || entity.chunkY != chunkY || entity.chunkZ != chunkZ) {
                        if (entity.updateNeeded) {
                            // Entity has left the chunk
                            chunk.remove(entity, entity.chunkY);
                        }
                        WorldChunk newChunk = chunkProvider.getWorldChunk(chunkX, chunkZ
                                //#if MC<11500
                                //$$ , false
                                //#endif
                        );
                        if (newChunk != null) {
                            newChunk.addEntity(entity);
                        } else {
                            // Entity has left all loaded chunks
                            entity.updateNeeded = false;
                        }
                    }
                    //#else
                    //$$ int chunkX = MathHelper.floor(entity.posX / 16);
                    //$$ int chunkZ = MathHelper.floor(entity.posZ / 16);
                    //$$ if (entity.chunkCoordX != chunkX || entity.chunkCoordZ != chunkZ) {
                    //$$     // Entity has left the chunk
                    //$$     chunk.removeEntityAtIndex(entity, entity.chunkCoordY);
                        //#if MC>=10904
                        //$$ Chunk newChunk = chunkProvider.getLoadedChunk(chunkX, chunkZ);
                        //#else
                        //$$ Chunk newChunk = chunkProvider.chunkExists(chunkX, chunkZ)
                        //$$         ? chunkProvider.provideChunk(chunkX, chunkZ) : null;
                        //#endif
                    //$$     if (newChunk != null) {
                    //$$         newChunk.addEntity(entity);
                    //$$     } else {
                    //$$         // Entity has left all loaded chunks
                    //$$         entity.addedToChunk = false;
                    //$$     }
                    //$$ } else {
                    //$$     // When entities remain in a chunk that's to be unloaded, they'll only be added to a unload
                    //$$     // queue and remain loaded as before until the next tick (which during jumping is way off).
                    //$$     // So, if they are re-spawned with the same entity id, MC actually cleans up the old entity and
                    //$$     // then adds the new one but leaves the unload queue as is.
                    //$$     // Finally, on the next tick the legitimate entity will be unloaded because it's part of the
                    //$$     // unload queue (entities .equals based purely on their id). However, the old entity object
                    //$$     // is used to determine the chunk the entity is removed from and in this case that'll allow the
                    //$$     // legitimate entity to remain registered in a loaded chunk, causing them to still be rendered.
                    //$$     //
                    //$$     // The usual removal-due-to-chunk-unload process will, without touching the entityList, call
                    //$$     // onEntityRemoved. In that method WorldClient checks to see whether the entity is still in the
                    //$$     // entityList (which it is) and then adds it to the entitySpawnQueue.
                    //$$     // As the final result the entity will remain loaded.
                    //$$     // To get the same result without ticking, we just remove the entity from the to-be-unloaded
                    //$$     // chunk but keep it loaded otherwise. They won't be rendered because they're not part of any
                    //$$     // chunk and will be removed properly if the server decides to re-spawn the entity.
                    //$$     chunk.removeEntityAtIndex(entity, entity.chunkCoordY);
                    //$$     entity.addedToChunk = false;
                    //$$ }
                    //#endif
                }
            }
        }
        return p; // During synchronous playback everything is sent normally
    }

    private static final class PacketData {
        private static final com.github.steveice10.netty.buffer.ByteBuf byteBuf = com.github.steveice10.netty.buffer.Unpooled.buffer();
        private static final NetOutput netOutput = new ByteBufNetOutput(byteBuf);

        private final int timestamp;
        private final byte[] bytes;

        PacketData(ReplayInputStream in, boolean loginPhase) throws IOException {
            if (ReplayMod.isMinimalMode()) {
                // Minimal mode, we can only read our exact protocol version and cannot use ReplayStudio
                timestamp = readInt(in);
                int length = readInt(in);
                if (timestamp == -1 || length == -1) {
                    throw new EOFException();
                }
                bytes = new byte[length];
                IOUtils.readFully(in, bytes);
            } else {
                com.replaymod.replaystudio.PacketData data = in.readPacket();
                if (data == null) {
                    throw new EOFException();
                }
                timestamp = (int) data.getTime();
                com.replaymod.replaystudio.protocol.Packet packet = data.getPacket();
                // We need to re-encode ReplayStudio packets, so we can later decode them as NMS packets
                // The main reason we aren't reading them as NMS packets is that we want ReplayStudio to be able
                // to apply ViaVersion (and potentially other magic) to it.
                synchronized (byteBuf) {
                    byteBuf.markReaderIndex(); // Mark the current reader and writer index (should be at start)
                    byteBuf.markWriterIndex();

                    netOutput.writeVarInt(packet.getId());
                    int idSize = byteBuf.readableBytes();
                    int contentSize = packet.getBuf().readableBytes();
                    bytes = new byte[idSize + contentSize]; // Create bytes array of sufficient size
                    byteBuf.readBytes(bytes, 0, idSize);
                    packet.getBuf().readBytes(bytes, idSize, contentSize);

                    byteBuf.resetReaderIndex(); // Reset reader & writer index for next use
                    byteBuf.resetWriterIndex();
                }
                packet.getBuf().release();
            }
        }
    }
}
