/*
 * This file is part of ComputerCraft - http://www.computercraft.info
 * Copyright Daniel Ratcliffe, 2011-2021. Do not distribute without permission.
 * Send enquiries to dratcliffe@gmail.com
 */
package dan200.computercraft.shared.turtle.core;

import com.google.common.base.Objects;
import com.mojang.authlib.GameProfile;
import dan200.computercraft.ComputerCraft;
import dan200.computercraft.api.lua.ILuaCallback;
import dan200.computercraft.api.lua.MethodResult;
import dan200.computercraft.api.peripheral.IPeripheral;
import dan200.computercraft.api.turtle.*;
import dan200.computercraft.core.computer.ComputerSide;
import dan200.computercraft.shared.TurtleUpgrades;
import dan200.computercraft.shared.computer.blocks.ComputerProxy;
import dan200.computercraft.shared.computer.core.ComputerFamily;
import dan200.computercraft.shared.computer.core.ServerComputer;
import dan200.computercraft.shared.turtle.blocks.TileTurtle;
import dan200.computercraft.shared.util.Colour;
import dan200.computercraft.shared.util.Holiday;
import dan200.computercraft.shared.util.HolidayUtil;
import dan200.computercraft.shared.util.InventoryDelegate;
import net.minecraft.block.Block;
import net.minecraft.block.BlockState;
import net.minecraft.entity.Entity;
import net.minecraft.entity.MoverType;
import net.minecraft.fluid.IFluidState;
import net.minecraft.inventory.IInventory;
import net.minecraft.item.DyeColor;
import net.minecraft.nbt.CompoundNBT;
import net.minecraft.particles.ParticleTypes;
import net.minecraft.tags.FluidTags;
import net.minecraft.tileentity.TileEntity;
import net.minecraft.util.Direction;
import net.minecraft.util.EntityPredicates;
import net.minecraft.util.ResourceLocation;
import net.minecraft.util.math.AxisAlignedBB;
import net.minecraft.util.math.BlockPos;
import net.minecraft.util.math.Vec3d;
import net.minecraft.world.World;
import net.minecraftforge.common.util.Constants;
import net.minecraftforge.items.IItemHandlerModifiable;
import net.minecraftforge.items.wrapper.InvWrapper;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.*;
import java.util.concurrent.TimeUnit;

import static dan200.computercraft.shared.common.IColouredItem.NBT_COLOUR;
import static dan200.computercraft.shared.util.WaterloggableHelpers.WATERLOGGED;

public class TurtleBrain implements ITurtleAccess
{
    public static final String NBT_RIGHT_UPGRADE = "RightUpgrade";
    public static final String NBT_RIGHT_UPGRADE_DATA = "RightUpgradeNbt";
    public static final String NBT_LEFT_UPGRADE = "LeftUpgrade";
    public static final String NBT_LEFT_UPGRADE_DATA = "LeftUpgradeNbt";
    public static final String NBT_FUEL = "Fuel";
    public static final String NBT_OVERLAY = "Overlay";

    private static final String NBT_SLOT = "Slot";

    private static final int ANIM_DURATION = 8;

    private TileTurtle owner;
    private ComputerProxy proxy;
    private GameProfile owningPlayer;

    private final IInventory inventory = (InventoryDelegate) () -> owner;
    private final IItemHandlerModifiable inventoryWrapper = new InvWrapper( inventory );

    private final Queue<TurtleCommandQueueEntry> commandQueue = new ArrayDeque<>();
    private int commandsIssued = 0;

    private final Map<TurtleSide, ITurtleUpgrade> upgrades = new EnumMap<>( TurtleSide.class );
    private final Map<TurtleSide, IPeripheral> peripherals = new EnumMap<>( TurtleSide.class );
    private final Map<TurtleSide, CompoundNBT> upgradeNBTData = new EnumMap<>( TurtleSide.class );

    private int selectedSlot = 0;
    private int fuelLevel = 0;
    private int colourHex = -1;
    private ResourceLocation overlay = null;

    private TurtleAnimation animation = TurtleAnimation.NONE;
    private int animationProgress = 0;
    private int lastAnimationProgress = 0;

    TurtlePlayer cachedPlayer;

    public TurtleBrain( TileTurtle turtle )
    {
        owner = turtle;
    }

    public void setOwner( TileTurtle owner )
    {
        this.owner = owner;
    }

    public TileTurtle getOwner()
    {
        return owner;
    }

    public ComputerProxy getProxy()
    {
        if( proxy == null ) proxy = new ComputerProxy( () -> owner );
        return proxy;
    }

    public ComputerFamily getFamily()
    {
        return owner.getFamily();
    }

    public void setupComputer( ServerComputer computer )
    {
        updatePeripherals( computer );
    }

    public void update()
    {
        World world = getWorld();
        if( !world.isClientSide )
        {
            // Advance movement
            updateCommands();

            // The block may have been broken while the command was executing (for instance, if a block explodes
            // when being mined). If so, abort.
            if( owner.isRemoved() ) return;
        }

        // Advance animation
        updateAnimation();

        // Advance upgrades
        if( !upgrades.isEmpty() )
        {
            for( Map.Entry<TurtleSide, ITurtleUpgrade> entry : upgrades.entrySet() )
            {
                entry.getValue().update( this, entry.getKey() );
            }
        }
    }

    /**
     * Read common data for saving and client synchronisation.
     *
     * @param nbt The tag to read from
     */
    private void readCommon( CompoundNBT nbt )
    {
        // Read fields
        colourHex = nbt.contains( NBT_COLOUR ) ? nbt.getInt( NBT_COLOUR ) : -1;
        fuelLevel = nbt.contains( NBT_FUEL ) ? nbt.getInt( NBT_FUEL ) : 0;
        overlay = nbt.contains( NBT_OVERLAY ) ? new ResourceLocation( nbt.getString( NBT_OVERLAY ) ) : null;

        // Read upgrades
        setUpgradeDirect( TurtleSide.LEFT, nbt.contains( NBT_LEFT_UPGRADE ) ? TurtleUpgrades.get( nbt.getString( NBT_LEFT_UPGRADE ) ) : null );
        setUpgradeDirect( TurtleSide.RIGHT, nbt.contains( NBT_RIGHT_UPGRADE ) ? TurtleUpgrades.get( nbt.getString( NBT_RIGHT_UPGRADE ) ) : null );

        // NBT
        upgradeNBTData.clear();
        if( nbt.contains( NBT_LEFT_UPGRADE_DATA ) )
        {
            upgradeNBTData.put( TurtleSide.LEFT, nbt.getCompound( NBT_LEFT_UPGRADE_DATA ).copy() );
        }
        if( nbt.contains( NBT_RIGHT_UPGRADE_DATA ) )
        {
            upgradeNBTData.put( TurtleSide.RIGHT, nbt.getCompound( NBT_RIGHT_UPGRADE_DATA ).copy() );
        }
    }

    private void writeCommon( CompoundNBT nbt )
    {
        nbt.putInt( NBT_FUEL, fuelLevel );
        if( colourHex != -1 ) nbt.putInt( NBT_COLOUR, colourHex );
        if( overlay != null ) nbt.putString( NBT_OVERLAY, overlay.toString() );

        // Write upgrades
        String leftUpgradeId = getUpgradeId( getUpgrade( TurtleSide.LEFT ) );
        if( leftUpgradeId != null ) nbt.putString( NBT_LEFT_UPGRADE, leftUpgradeId );
        String rightUpgradeId = getUpgradeId( getUpgrade( TurtleSide.RIGHT ) );
        if( rightUpgradeId != null ) nbt.putString( NBT_RIGHT_UPGRADE, rightUpgradeId );

        // Write upgrade NBT
        if( upgradeNBTData.containsKey( TurtleSide.LEFT ) )
        {
            nbt.put( NBT_LEFT_UPGRADE_DATA, getUpgradeNBTData( TurtleSide.LEFT ).copy() );
        }
        if( upgradeNBTData.containsKey( TurtleSide.RIGHT ) )
        {
            nbt.put( NBT_RIGHT_UPGRADE_DATA, getUpgradeNBTData( TurtleSide.RIGHT ).copy() );
        }
    }

    public void readFromNBT( CompoundNBT nbt )
    {
        readCommon( nbt );

        // Read state
        selectedSlot = nbt.getInt( NBT_SLOT );

        // Read owner
        if( nbt.contains( "Owner", Constants.NBT.TAG_COMPOUND ) )
        {
            CompoundNBT owner = nbt.getCompound( "Owner" );
            owningPlayer = new GameProfile(
                new UUID( owner.getLong( "UpperId" ), owner.getLong( "LowerId" ) ),
                owner.getString( "Name" )
            );
        }
        else
        {
            owningPlayer = null;
        }
    }

    public CompoundNBT writeToNBT( CompoundNBT nbt )
    {
        writeCommon( nbt );

        // Write state
        nbt.putInt( NBT_SLOT, selectedSlot );

        // Write owner
        if( owningPlayer != null )
        {
            CompoundNBT owner = new CompoundNBT();
            nbt.put( "Owner", owner );

            owner.putLong( "UpperId", owningPlayer.getId().getMostSignificantBits() );
            owner.putLong( "LowerId", owningPlayer.getId().getLeastSignificantBits() );
            owner.putString( "Name", owningPlayer.getName() );
        }

        return nbt;
    }

    private static String getUpgradeId( ITurtleUpgrade upgrade )
    {
        return upgrade != null ? upgrade.getUpgradeID().toString() : null;
    }

    public void readDescription( CompoundNBT nbt )
    {
        readCommon( nbt );

        // Animation
        TurtleAnimation anim = TurtleAnimation.values()[nbt.getInt( "Animation" )];
        if( anim != animation &&
            anim != TurtleAnimation.WAIT &&
            anim != TurtleAnimation.SHORT_WAIT &&
            anim != TurtleAnimation.NONE )
        {
            animation = anim;
            animationProgress = 0;
            lastAnimationProgress = 0;
        }
    }

    public void writeDescription( CompoundNBT nbt )
    {
        writeCommon( nbt );
        nbt.putInt( "Animation", animation.ordinal() );
    }

    @Nonnull
    @Override
    public World getWorld()
    {
        return owner.getLevel();
    }

    @Nonnull
    @Override
    public BlockPos getPosition()
    {
        return owner.getBlockPos();
    }

    @Override
    public boolean teleportTo( @Nonnull World world, @Nonnull BlockPos pos )
    {
        if( world.isClientSide || getWorld().isClientSide )
        {
            throw new UnsupportedOperationException( "Cannot teleport on the client" );
        }

        // Cache info about the old turtle (so we don't access this after we delete ourselves)
        World oldWorld = getWorld();
        TileTurtle oldOwner = owner;
        BlockPos oldPos = owner.getBlockPos();
        BlockState oldBlock = owner.getBlockState();

        if( oldWorld == world && oldPos.equals( pos ) )
        {
            // Teleporting to the current position is a no-op
            return true;
        }

        // Ensure the chunk is loaded
        if( !world.isAreaLoaded( pos, 0 ) ) return false;

        // Ensure we're inside the world border
        if( !world.getWorldBorder().isWithinBounds( pos ) ) return false;

        IFluidState existingFluid = world.getBlockState( pos ).getFluidState();
        BlockState newState = oldBlock
            // We only mark this as waterlogged when travelling into a source block. This prevents us from spreading
            // fluid by creating a new source when moving into a block, causing the next block to be almost full and
            // then moving into that.
            .setValue( WATERLOGGED, existingFluid.is( FluidTags.WATER ) && existingFluid.isSource() );

        oldOwner.notifyMoveStart();

        try
        {
            // Create a new turtle
            if( world.setBlock( pos, newState, 0 ) )
            {
                Block block = world.getBlockState( pos ).getBlock();
                if( block == oldBlock.getBlock() )
                {
                    TileEntity newTile = world.getBlockEntity( pos );
                    if( newTile instanceof TileTurtle )
                    {
                        // Copy the old turtle state into the new turtle
                        TileTurtle newTurtle = (TileTurtle) newTile;
                        newTurtle.setLevelAndPosition( world, pos );
                        newTurtle.transferStateFrom( oldOwner );
                        newTurtle.createServerComputer().setWorld( world );
                        newTurtle.createServerComputer().setPosition( pos );

                        // Remove the old turtle
                        oldWorld.removeBlock( oldPos, false );

                        // Make sure everybody knows about it
                        newTurtle.updateBlock();
                        newTurtle.updateInput();
                        newTurtle.updateOutput();
                        return true;
                    }
                }

                // Something went wrong, remove the newly created turtle
                world.removeBlock( pos, false );
            }
        }
        finally
        {
            // whatever happens, unblock old turtle in case it's still in world
            oldOwner.notifyMoveEnd();
        }

        return false;
    }

    @Nonnull
    @Override
    public Vec3d getVisualPosition( float f )
    {
        Vec3d offset = getRenderOffset( f );
        BlockPos pos = owner.getBlockPos();
        return new Vec3d(
            pos.getX() + 0.5 + offset.x,
            pos.getY() + 0.5 + offset.y,
            pos.getZ() + 0.5 + offset.z
        );
    }

    @Override
    public float getVisualYaw( float f )
    {
        float yaw = getDirection().toYRot();
        switch( animation )
        {
            case TURN_LEFT:
            {
                yaw += 90.0f * (1.0f - getAnimationFraction( f ));
                if( yaw >= 360.0f )
                {
                    yaw -= 360.0f;
                }
                break;
            }
            case TURN_RIGHT:
            {
                yaw += -90.0f * (1.0f - getAnimationFraction( f ));
                if( yaw < 0.0f )
                {
                    yaw += 360.0f;
                }
                break;
            }
        }
        return yaw;
    }

    @Nonnull
    @Override
    public Direction getDirection()
    {
        return owner.getDirection();
    }

    @Override
    public void setDirection( @Nonnull Direction dir )
    {
        owner.setDirection( dir );
    }

    @Override
    public int getSelectedSlot()
    {
        return selectedSlot;
    }

    @Override
    public void setSelectedSlot( int slot )
    {
        if( getWorld().isClientSide ) throw new UnsupportedOperationException( "Cannot set the slot on the client" );

        if( slot >= 0 && slot < owner.getContainerSize() )
        {
            selectedSlot = slot;
            owner.onTileEntityChange();
        }
    }

    @Nonnull
    @Override
    public IInventory getInventory()
    {
        return inventory;
    }

    @Nonnull
    @Override
    public IItemHandlerModifiable getItemHandler()
    {
        return inventoryWrapper;
    }

    @Override
    public boolean isFuelNeeded()
    {
        return ComputerCraft.turtlesNeedFuel;
    }

    @Override
    public int getFuelLevel()
    {
        return Math.min( fuelLevel, getFuelLimit() );
    }

    @Override
    public void setFuelLevel( int level )
    {
        fuelLevel = Math.min( level, getFuelLimit() );
        owner.onTileEntityChange();
    }

    @Override
    public int getFuelLimit()
    {
        if( owner.getFamily() == ComputerFamily.ADVANCED )
        {
            return ComputerCraft.advancedTurtleFuelLimit;
        }
        else
        {
            return ComputerCraft.turtleFuelLimit;
        }
    }

    @Override
    public boolean consumeFuel( int fuel )
    {
        if( getWorld().isClientSide ) throw new UnsupportedOperationException( "Cannot consume fuel on the client" );

        if( !isFuelNeeded() ) return true;

        int consumption = Math.max( fuel, 0 );
        if( getFuelLevel() >= consumption )
        {
            setFuelLevel( getFuelLevel() - consumption );
            return true;
        }
        return false;
    }

    @Override
    public void addFuel( int fuel )
    {
        if( getWorld().isClientSide ) throw new UnsupportedOperationException( "Cannot add fuel on the client" );

        int addition = Math.max( fuel, 0 );
        setFuelLevel( getFuelLevel() + addition );
    }

    @Nonnull
    @Override
    public MethodResult executeCommand( @Nonnull ITurtleCommand command )
    {
        if( getWorld().isClientSide ) throw new UnsupportedOperationException( "Cannot run commands on the client" );
        if( commandQueue.size() > 16 ) return MethodResult.of( false, "Too many ongoing turtle commands" );

        commandQueue.offer( new TurtleCommandQueueEntry( ++commandsIssued, command ) );
        int commandID = commandsIssued;
        return new CommandCallback( commandID ).pull;
    }

    @Override
    public void playAnimation( @Nonnull TurtleAnimation animation )
    {
        if( getWorld().isClientSide ) throw new UnsupportedOperationException( "Cannot play animations on the client" );

        this.animation = animation;
        if( this.animation == TurtleAnimation.SHORT_WAIT )
        {
            animationProgress = ANIM_DURATION / 2;
            lastAnimationProgress = ANIM_DURATION / 2;
        }
        else
        {
            animationProgress = 0;
            lastAnimationProgress = 0;
        }
        owner.updateBlock();
    }

    public ResourceLocation getOverlay()
    {
        return overlay;
    }

    public void setOverlay( ResourceLocation overlay )
    {
        if( !Objects.equal( this.overlay, overlay ) )
        {
            this.overlay = overlay;
            owner.updateBlock();
        }
    }

    public DyeColor getDyeColour()
    {
        if( colourHex == -1 ) return null;
        Colour colour = Colour.fromHex( colourHex );
        return colour == null ? null : DyeColor.byId( 15 - colour.ordinal() );
    }

    public void setDyeColour( DyeColor dyeColour )
    {
        int newColour = -1;
        if( dyeColour != null )
        {
            newColour = Colour.values()[15 - dyeColour.getId()].getHex();
        }
        if( colourHex != newColour )
        {
            colourHex = newColour;
            owner.updateBlock();
        }
    }

    @Override
    public void setColour( int colour )
    {
        if( colour >= 0 && colour <= 0xFFFFFF )
        {
            if( colourHex != colour )
            {
                colourHex = colour;
                owner.updateBlock();
            }
        }
        else if( colourHex != -1 )
        {
            colourHex = -1;
            owner.updateBlock();
        }
    }

    @Override
    public int getColour()
    {
        return colourHex;
    }

    public void setOwningPlayer( GameProfile profile )
    {
        owningPlayer = profile;
    }

    @Nullable
    @Override
    public GameProfile getOwningPlayer()
    {
        return owningPlayer;
    }

    @Override
    public ITurtleUpgrade getUpgrade( @Nonnull TurtleSide side )
    {
        return upgrades.get( side );
    }

    @Override
    public void setUpgrade( @Nonnull TurtleSide side, ITurtleUpgrade upgrade )
    {
        if( !setUpgradeDirect( side, upgrade ) ) return;

        // This is a separate function to avoid updating the block when reading the NBT. We don't need to do this as
        // either the block is newly placed (and so won't have changed) or is being updated with /data, which calls
        // updateBlock for us.
        if( owner.getLevel() != null )
        {
            owner.updateBlock();
            owner.updateInput();
        }
    }

    private boolean setUpgradeDirect( @Nonnull TurtleSide side, ITurtleUpgrade upgrade )
    {
        // Remove old upgrade
        if( upgrades.containsKey( side ) )
        {
            if( upgrades.get( side ) == upgrade ) return false;
            upgrades.remove( side );
        }
        else
        {
            if( upgrade == null ) return false;
        }

        upgradeNBTData.remove( side );

        // Set new upgrade
        if( upgrade != null ) upgrades.put( side, upgrade );

        // Notify clients and create peripherals
        if( owner.getLevel() != null )
        {
            updatePeripherals( owner.createServerComputer() );
        }

        return true;
    }

    @Override
    public IPeripheral getPeripheral( @Nonnull TurtleSide side )
    {
        return peripherals.get( side );
    }

    @Nonnull
    @Override
    public CompoundNBT getUpgradeNBTData( TurtleSide side )
    {
        CompoundNBT nbt = upgradeNBTData.get( side );
        if( nbt == null ) upgradeNBTData.put( side, nbt = new CompoundNBT() );
        return nbt;
    }

    @Override
    public void updateUpgradeNBTData( @Nonnull TurtleSide side )
    {
        owner.updateBlock();
    }

    public Vec3d getRenderOffset( float f )
    {
        switch( animation )
        {
            case MOVE_FORWARD:
            case MOVE_BACK:
            case MOVE_UP:
            case MOVE_DOWN:
            {
                // Get direction
                Direction dir;
                switch( animation )
                {
                    case MOVE_FORWARD:
                    default:
                        dir = getDirection();
                        break;
                    case MOVE_BACK:
                        dir = getDirection().getOpposite();
                        break;
                    case MOVE_UP:
                        dir = Direction.UP;
                        break;
                    case MOVE_DOWN:
                        dir = Direction.DOWN;
                        break;
                }

                double distance = -1.0 + getAnimationFraction( f );
                return new Vec3d(
                    distance * dir.getStepX(),
                    distance * dir.getStepY(),
                    distance * dir.getStepZ()
                );
            }
            default:
            {
                return Vec3d.ZERO;
            }
        }
    }

    public float getToolRenderAngle( TurtleSide side, float f )
    {
        return (side == TurtleSide.LEFT && animation == TurtleAnimation.SWING_LEFT_TOOL) ||
            (side == TurtleSide.RIGHT && animation == TurtleAnimation.SWING_RIGHT_TOOL)
            ? 45.0f * (float) Math.sin( getAnimationFraction( f ) * Math.PI )
            : 0.0f;
    }

    private static ComputerSide toDirection( TurtleSide side )
    {
        switch( side )
        {
            case LEFT:
                return ComputerSide.LEFT;
            case RIGHT:
            default:
                return ComputerSide.RIGHT;
        }
    }

    private void updatePeripherals( ServerComputer serverComputer )
    {
        if( serverComputer == null ) return;

        // Update peripherals
        for( TurtleSide side : TurtleSide.values() )
        {
            ITurtleUpgrade upgrade = getUpgrade( side );
            IPeripheral peripheral = null;
            if( upgrade != null && upgrade.getType().isPeripheral() )
            {
                peripheral = upgrade.createPeripheral( this, side );
            }

            IPeripheral existing = peripherals.get( side );
            if( existing == peripheral || (existing != null && peripheral != null && existing.equals( peripheral )) )
            {
                // If the peripheral is the same, just use that.
                peripheral = existing;
            }
            else
            {
                // Otherwise update our map
                peripherals.put( side, peripheral );
            }

            // Always update the computer: it may not be the same computer as before!
            serverComputer.setPeripheral( toDirection( side ), peripheral );
        }
    }

    private void updateCommands()
    {
        if( animation != TurtleAnimation.NONE || commandQueue.isEmpty() ) return;

        // If we've got a computer, ensure that we're allowed to perform work.
        ServerComputer computer = owner.getServerComputer();
        if( computer != null && !computer.getComputer().getMainThreadMonitor().canWork() ) return;

        // Pull a new command
        TurtleCommandQueueEntry nextCommand = commandQueue.poll();
        if( nextCommand == null ) return;

        // Execute the command
        long start = System.nanoTime();
        TurtleCommandResult result = nextCommand.command.execute( this );
        long end = System.nanoTime();

        // Dispatch the callback
        if( computer == null ) return;
        computer.getComputer().getMainThreadMonitor().trackWork( end - start, TimeUnit.NANOSECONDS );
        int callbackID = nextCommand.callbackID;
        if( callbackID < 0 ) return;

        if( result != null && result.isSuccess() )
        {
            Object[] results = result.getResults();
            if( results != null )
            {
                Object[] arguments = new Object[results.length + 2];
                arguments[0] = callbackID;
                arguments[1] = true;
                System.arraycopy( results, 0, arguments, 2, results.length );
                computer.queueEvent( "turtle_response", arguments );
            }
            else
            {
                computer.queueEvent( "turtle_response", new Object[] {
                    callbackID, true,
                } );
            }
        }
        else
        {
            computer.queueEvent( "turtle_response", new Object[] {
                callbackID, false, result != null ? result.getErrorMessage() : null,
            } );
        }
    }

    private void updateAnimation()
    {
        if( animation != TurtleAnimation.NONE )
        {
            World world = getWorld();

            if( ComputerCraft.turtlesCanPush )
            {
                // Advance entity pushing
                if( animation == TurtleAnimation.MOVE_FORWARD ||
                    animation == TurtleAnimation.MOVE_BACK ||
                    animation == TurtleAnimation.MOVE_UP ||
                    animation == TurtleAnimation.MOVE_DOWN )
                {
                    BlockPos pos = getPosition();
                    Direction moveDir;
                    switch( animation )
                    {
                        case MOVE_FORWARD:
                        default:
                            moveDir = getDirection();
                            break;
                        case MOVE_BACK:
                            moveDir = getDirection().getOpposite();
                            break;
                        case MOVE_UP:
                            moveDir = Direction.UP;
                            break;
                        case MOVE_DOWN:
                            moveDir = Direction.DOWN;
                            break;
                    }

                    double minX = pos.getX();
                    double minY = pos.getY();
                    double minZ = pos.getZ();
                    double maxX = minX + 1.0;
                    double maxY = minY + 1.0;
                    double maxZ = minZ + 1.0;

                    float pushFrac = 1.0f - (float) (animationProgress + 1) / ANIM_DURATION;
                    float push = Math.max( pushFrac + 0.0125f, 0.0f );
                    if( moveDir.getStepX() < 0 )
                    {
                        minX += moveDir.getStepX() * push;
                    }
                    else
                    {
                        maxX -= moveDir.getStepX() * push;
                    }

                    if( moveDir.getStepY() < 0 )
                    {
                        minY += moveDir.getStepY() * push;
                    }
                    else
                    {
                        maxY -= moveDir.getStepY() * push;
                    }

                    if( moveDir.getStepZ() < 0 )
                    {
                        minZ += moveDir.getStepZ() * push;
                    }
                    else
                    {
                        maxZ -= moveDir.getStepZ() * push;
                    }

                    AxisAlignedBB aabb = new AxisAlignedBB( minX, minY, minZ, maxX, maxY, maxZ );
                    List<Entity> list = world.getEntitiesOfClass( Entity.class, aabb, EntityPredicates.NO_SPECTATORS );
                    if( !list.isEmpty() )
                    {
                        double pushStep = 1.0f / ANIM_DURATION;
                        double pushStepX = moveDir.getStepX() * pushStep;
                        double pushStepY = moveDir.getStepY() * pushStep;
                        double pushStepZ = moveDir.getStepZ() * pushStep;
                        for( Entity entity : list )
                        {
                            entity.move( MoverType.PISTON, new Vec3d( pushStepX, pushStepY, pushStepZ ) );
                        }
                    }
                }
            }

            // Advance valentines day easter egg
            if( world.isClientSide && animation == TurtleAnimation.MOVE_FORWARD && animationProgress == 4 )
            {
                // Spawn love pfx if valentines day
                Holiday currentHoliday = HolidayUtil.getCurrentHoliday();
                if( currentHoliday == Holiday.VALENTINES )
                {
                    Vec3d position = getVisualPosition( 1.0f );
                    if( position != null )
                    {
                        double x = position.x + world.random.nextGaussian() * 0.1;
                        double y = position.y + 0.5 + world.random.nextGaussian() * 0.1;
                        double z = position.z + world.random.nextGaussian() * 0.1;
                        world.addParticle(
                            ParticleTypes.HEART, x, y, z,
                            world.random.nextGaussian() * 0.02,
                            world.random.nextGaussian() * 0.02,
                            world.random.nextGaussian() * 0.02
                        );
                    }
                }
            }

            // Wait for anim completion
            lastAnimationProgress = animationProgress;
            if( ++animationProgress >= ANIM_DURATION )
            {
                animation = TurtleAnimation.NONE;
                animationProgress = 0;
                lastAnimationProgress = 0;
            }
        }
    }

    private float getAnimationFraction( float f )
    {
        float next = (float) animationProgress / ANIM_DURATION;
        float previous = (float) lastAnimationProgress / ANIM_DURATION;
        return previous + (next - previous) * f;
    }

    private static final class CommandCallback implements ILuaCallback
    {
        final MethodResult pull = MethodResult.pullEvent( "turtle_response", this );
        private final int command;

        CommandCallback( int command )
        {
            this.command = command;
        }

        @Nonnull
        @Override
        public MethodResult resume( Object[] response )
        {
            if( response.length < 3 || !(response[1] instanceof Number) || !(response[2] instanceof Boolean) )
            {
                return pull;
            }

            if( ((Number) response[1]).intValue() != command ) return pull;

            return MethodResult.of( Arrays.copyOfRange( response, 2, response.length ) );
        }
    }
}
