package com.replaymod.simplepathing;

import com.google.common.base.Preconditions;
import com.google.common.collect.Iterables;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import com.replaymod.pathing.properties.CameraProperties;
import com.replaymod.pathing.properties.SpectatorProperty;
import com.replaymod.pathing.properties.TimestampProperty;
import com.replaymod.replaystudio.pathing.PathingRegistry;
import com.replaymod.replaystudio.pathing.change.AddKeyframe;
import com.replaymod.replaystudio.pathing.change.Change;
import com.replaymod.replaystudio.pathing.change.CombinedChange;
import com.replaymod.replaystudio.pathing.change.SetInterpolator;
import com.replaymod.replaystudio.pathing.change.UpdateKeyframeProperties;
import com.replaymod.replaystudio.pathing.impl.TimelineImpl;
import com.replaymod.replaystudio.pathing.interpolation.CatmullRomSplineInterpolator;
import com.replaymod.replaystudio.pathing.interpolation.CubicSplineInterpolator;
import com.replaymod.replaystudio.pathing.interpolation.Interpolator;
import com.replaymod.replaystudio.pathing.interpolation.LinearInterpolator;
import com.replaymod.replaystudio.pathing.path.Keyframe;
import com.replaymod.replaystudio.pathing.path.Path;
import com.replaymod.replaystudio.pathing.path.PathSegment;
import com.replaymod.replaystudio.pathing.path.Timeline;
import com.replaymod.replaystudio.pathing.property.Property;
import com.replaymod.replaystudio.util.EntityPositionTracker;
import com.replaymod.replaystudio.util.Location;
import com.replaymod.simplepathing.properties.ExplicitInterpolationProperty;
import lombok.Getter;
import net.minecraft.crash.CrashReport;
import net.minecraft.crash.CrashReportCategory;
import net.minecraft.util.ReportedException;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.Validate;
import org.apache.commons.lang3.tuple.Triple;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

import static com.replaymod.core.versions.MCVer.*;
import static com.replaymod.replaystudio.pathing.change.RemoveKeyframe.create;
import static com.replaymod.simplepathing.ReplayModSimplePathing.LOGGER;

/**
 * Simplified timeline abstraction used in the SimplePathing module.
 */
public class SPTimeline implements PathingRegistry {
    public enum SPPath {
        TIME,
        POSITION,
    }

    @Getter
    private final Timeline timeline;
    @Getter
    private final Path timePath;
    @Getter
    private final Path positionPath;

    @Getter
    private EntityPositionTracker entityTracker;
    private InterpolatorType defaultInterpolatorType;

    public SPTimeline() {
        this(createInitialTimeline());
    }

    public SPTimeline(Timeline timeline) {
        this.timeline = timeline;
        this.timePath = timeline.getPaths().get(SPPath.TIME.ordinal());
        this.positionPath = timeline.getPaths().get(SPPath.POSITION.ordinal());
    }

    public Path getPath(SPPath path) {
        switch (path) {
            case TIME:
                return getTimePath();
            case POSITION:
                return getPositionPath();
        }
        throw new IllegalArgumentException("Unknown path " + path);
    }

    public Keyframe getKeyframe(SPPath path, long keyframe) {
        return getPath(path).getKeyframe(keyframe);
    }

    public void setEntityTracker(EntityPositionTracker entityTracker) {
        Preconditions.checkState(this.entityTracker == null, "Entity tracker already set");
        this.entityTracker = entityTracker;
    }

    public void setDefaultInterpolatorType(InterpolatorType defaultInterpolatorType) {
        Validate.isTrue(defaultInterpolatorType != InterpolatorType.DEFAULT, "Must not be DEFAULT");
        this.defaultInterpolatorType = Validate.notNull(defaultInterpolatorType);

        if (entityTracker != null) {
            timeline.pushChange(updateInterpolators());
        }
    }

    public Change setDefaultInterpolator(Interpolator interpolator) {
        Preconditions.checkState(defaultInterpolatorType != null, "Default interpolator type not set.");
        Validate.isInstanceOf(defaultInterpolatorType.getInterpolatorClass(), interpolator);

        registerPositionInterpolatorProperties(interpolator);

        Change change = CombinedChange.create(
                positionPath.getSegments().stream()
                        // Ignore explicitly set segments
                        .filter(s -> !s.getStartKeyframe().getValue(ExplicitInterpolationProperty.PROPERTY).isPresent())
                        // Ignore spectator segments
                        .filter(s -> !isSpectatorSegment(s))
                        // Update interpolator for every remaining segment
                        // This will create a fragmented interpolator which is split by the updateInterpolators call
                        .map(s -> SetInterpolator.create(s, interpolator)).toArray(Change[]::new)
        );
        change.apply(timeline);
        return CombinedChange.createFromApplied(change, updateInterpolators());
    }

    public boolean isTimeKeyframe(long time) {
        return timePath.getKeyframe(time) != null;
    }

    public boolean isPositionKeyframe(long time) {
        return positionPath.getKeyframe(time) != null;
    }

    public boolean isSpectatorKeyframe(long time) {
        Keyframe keyframe = positionPath.getKeyframe(time);
        return keyframe != null && keyframe.getValue(SpectatorProperty.PROPERTY).isPresent();
    }

    public void addPositionKeyframe(long time, double posX, double posY, double posZ,
                                    float yaw, float pitch, float roll, int spectated) {
        LOGGER.debug("Adding position keyframe at {} pos {}/{}/{} rot {}/{}/{} entId {}",
                time, posX, posY, posZ, yaw, pitch, roll, spectated);

        Path path = positionPath;

        Preconditions.checkState(positionPath.getKeyframe(time) == null, "Keyframe already exists");

        Change change = AddKeyframe.create(path, time);
        change.apply(timeline);
        Keyframe keyframe = path.getKeyframe(time);

        UpdateKeyframeProperties.Builder builder = UpdateKeyframeProperties.create(path, keyframe);
        builder.setValue(CameraProperties.POSITION, Triple.of(posX, posY, posZ));
        builder.setValue(CameraProperties.ROTATION, Triple.of(yaw, pitch, roll));
        if (spectated != -1) {
            builder.setValue(SpectatorProperty.PROPERTY, spectated);
        }
        UpdateKeyframeProperties updateChange = builder.done();
        updateChange.apply(timeline);
        change = CombinedChange.createFromApplied(change, updateChange);

        // If this new keyframe formed the first segment of the path
        if (path.getSegments().size() == 1) {
            // then create an initial interpolator of default type
            PathSegment segment = path.getSegments().iterator().next();
            Interpolator interpolator = createDefaultInterpolator();
            SetInterpolator setInterpolator = SetInterpolator.create(segment, interpolator);
            setInterpolator.apply(timeline);
            change = CombinedChange.createFromApplied(change, setInterpolator);
        }

        // Update interpolators for spectator keyframes
        // while this is overkill, it is far simpler than updating differently for every possible case
        change = CombinedChange.createFromApplied(change, updateInterpolators());

        Change specPosUpdate = updateSpectatorPositions();
        specPosUpdate.apply(timeline);
        change = CombinedChange.createFromApplied(change, specPosUpdate);

        timeline.pushChange(change);
    }

    public Change updatePositionKeyframe(long time, double posX, double posY, double posZ,
                                    float yaw, float pitch, float roll) {
        LOGGER.debug("Updating position keyframe at {} to pos {}/{}/{} rot {}/{}/{}",
                time, posX, posY, posZ, yaw, pitch, roll);

        Keyframe keyframe = positionPath.getKeyframe(time);

        Preconditions.checkState(keyframe != null, "Keyframe does not exists");
        Preconditions.checkState(!keyframe.getValue(SpectatorProperty.PROPERTY).isPresent(), "Cannot update spectator keyframe");

        Change change = UpdateKeyframeProperties.create(positionPath, keyframe)
                .setValue(CameraProperties.POSITION, Triple.of(posX, posY, posZ))
                .setValue(CameraProperties.ROTATION, Triple.of(yaw, pitch, roll))
                .done();
        change.apply(timeline);
        return change;
    }

    public void removePositionKeyframe(long time) {
        LOGGER.debug("Removing position keyframe at {}", time);

        Path path = positionPath;
        Keyframe keyframe = path.getKeyframe(time);

        Preconditions.checkState(keyframe != null, "No keyframe at that time");

        Change change = create(path, keyframe);
        change.apply(timeline);

        // Update interpolators for spectator keyframes
        // while this is overkill, it is far simpler than updating differently for every possible case
        change = CombinedChange.createFromApplied(change, updateInterpolators());

        Change specPosUpdate = updateSpectatorPositions();
        specPosUpdate.apply(timeline);
        change = CombinedChange.createFromApplied(change, specPosUpdate);

        timeline.pushChange(change);
    }

    public void addTimeKeyframe(long time, int replayTime) {
        LOGGER.debug("Adding time keyframe at {} time {}", time, replayTime);

        Path path = timePath;

        Preconditions.checkState(path.getKeyframe(time) == null, "Keyframe already exists");

        Change change = AddKeyframe.create(path, time);
        change.apply(timeline);
        Keyframe keyframe = path.getKeyframe(time);

        UpdateKeyframeProperties updateChange = UpdateKeyframeProperties.create(path, keyframe)
                .setValue(TimestampProperty.PROPERTY, replayTime)
                .done();
        updateChange.apply(timeline);
        change = CombinedChange.createFromApplied(change, updateChange);

        // If this new keyframe formed the first segment of the path
        if (path.getSegments().size() == 1) {
            // then create an initial interpolator
            PathSegment segment = path.getSegments().iterator().next();
            Interpolator interpolator = new LinearInterpolator();
            interpolator.registerProperty(TimestampProperty.PROPERTY);
            SetInterpolator setInterpolator = SetInterpolator.create(segment, interpolator);
            setInterpolator.apply(timeline);
            change = CombinedChange.createFromApplied(change, setInterpolator);
        }

        Change specPosUpdate = updateSpectatorPositions();
        specPosUpdate.apply(timeline);
        change = CombinedChange.createFromApplied(change, specPosUpdate);

        timeline.pushChange(change);
    }

    public Change updateTimeKeyframe(long time, int replayTime) {
        LOGGER.debug("Updating time keyframe at {} to time {}", time, replayTime);

        Keyframe keyframe = timePath.getKeyframe(time);

        Preconditions.checkState(keyframe != null, "Keyframe does not exists");

        Change change = UpdateKeyframeProperties.create(timePath, keyframe)
                .setValue(TimestampProperty.PROPERTY, replayTime)
                .done();
        change.apply(timeline);
        return change;
    }

    public void removeTimeKeyframe(long time) {
        LOGGER.debug("Removing time keyframe at {}", time);

        Path path = timePath;
        Keyframe keyframe = path.getKeyframe(time);

        Preconditions.checkState(keyframe != null, "No keyframe at that time");

        Change change = create(path, keyframe);
        change.apply(timeline);

        Change specPosUpdate = updateSpectatorPositions();
        specPosUpdate.apply(timeline);
        change = CombinedChange.createFromApplied(change, specPosUpdate);

        timeline.pushChange(change);
    }

    public Change setInterpolatorToDefault(long time) {
        LOGGER.debug("Setting interpolator of position keyframe at {} to the default", time);

        Keyframe keyframe = positionPath.getKeyframe(time);

        Preconditions.checkState(keyframe != null, "Keyframe does not exists");

        Change change = UpdateKeyframeProperties.create(positionPath, keyframe)
                .removeProperty(ExplicitInterpolationProperty.PROPERTY)
                .done();
        change.apply(timeline);
        return CombinedChange.createFromApplied(change, updateInterpolators());
    }

    public Change setInterpolator(long time, Interpolator interpolator) {
        LOGGER.debug("Setting interpolator of position keyframe at {} to {}", time, interpolator);

        Keyframe keyframe = positionPath.getKeyframe(time);
        Preconditions.checkState(keyframe != null, "Keyframe does not exists");
        PathSegment segment = positionPath.getSegments().stream().filter(s -> s.getStartKeyframe() == keyframe)
                .findFirst().orElseThrow(() -> new IllegalStateException("Keyframe has no following segment."));

        registerPositionInterpolatorProperties(interpolator);

        Change change = CombinedChange.create(
                UpdateKeyframeProperties.create(positionPath, keyframe)
                        .setValue(ExplicitInterpolationProperty.PROPERTY, ObjectUtils.NULL)
                        .done(),
                SetInterpolator.create(segment, interpolator)
        );
        change.apply(timeline);
        return CombinedChange.createFromApplied(change, updateInterpolators());
    }

    public Change moveKeyframe(SPPath spPath, long oldTime, long newTime) {
        LOGGER.debug("Moving keyframe on {} from {} to {}", spPath, oldTime, newTime);
        Path path = getPath(spPath);
        Keyframe keyframe = path.getKeyframe(oldTime);

        Preconditions.checkState(keyframe != null, "No keyframe at specified time");

        // Interpolator of the first segment might be required later if it is the only one
        Optional<Interpolator> firstInterpolator =
                path.getSegments().stream().findFirst().map(PathSegment::getInterpolator);

        // The interpolator that will be lost once we remove the old keyframe and has to be restored afterwards
        Optional<Interpolator> lostInterpolator = path.getSegments().stream().filter(s -> {
            // If this is the last keyframe,
            if (Iterables.getLast(path.getKeyframes()) == keyframe) {
                return s.getEndKeyframe() == keyframe; // the previous interpolator will be lost
            } else { // otherwise
                return s.getStartKeyframe() == keyframe; // the following interpolator will be lost
            }
        }).findFirst().map(PathSegment::getInterpolator);

        // First remove the old keyframe
        Change removeChange = create(path, keyframe);
        removeChange.apply(timeline);

        // and add a new one at the correct time
        Change addChange = AddKeyframe.create(path, newTime);
        addChange.apply(timeline);

        // Then copy over all properties
        UpdateKeyframeProperties.Builder builder = UpdateKeyframeProperties.create(path, path.getKeyframe(newTime));
        for (Property property : keyframe.getProperties()) {
            copyProperty(property, keyframe, builder);
        }
        Change propertyChange = builder.done();
        propertyChange.apply(timeline);


        Change restoreInterpolatorChange;
        Keyframe newKf = path.getKeyframe(newTime);
        if (Iterables.getLast(path.getKeyframes()) != newKf) { // Unless this is the last keyframe
            // the interpolator of the following segment has been lost and needs to be restored
            restoreInterpolatorChange = lostInterpolator.<Change>flatMap(interpolator ->
                    path.getSegments().stream().filter(s -> s.getStartKeyframe() == newKf).findFirst().map(segment ->
                            SetInterpolator.create(segment, interpolator)
                    )
            ).orElseGet(CombinedChange::create);
        } else { // If it is the last keyframe however,
            // the interpolator of the previous segment has been lost and needs to be restored
            restoreInterpolatorChange = path.getSegments().stream().filter(s -> s.getEndKeyframe() == newKf)
                    .findFirst().flatMap(segment -> lostInterpolator.map(interpolator -> {
                        // additionally, if the interpolation of this keyframe was set to explicit, that property
                        // has to be transferred to the start keyframe of the new segment
                        if (newKf.getValue(ExplicitInterpolationProperty.PROPERTY).isPresent()) {
                            return CombinedChange.create(
                                    SetInterpolator.create(segment, interpolator),
                                    UpdateKeyframeProperties.create(path, segment.getStartKeyframe())
                                            .setValue(ExplicitInterpolationProperty.PROPERTY, ObjectUtils.NULL).done()
                            );
                        } else {
                            return SetInterpolator.create(segment, interpolator);
                        }
            })).orElseGet(CombinedChange::create);
        }
        restoreInterpolatorChange.apply(timeline);

        // Finally update the interpolators
        Change interpolatorUpdateChange;
        if (spPath == SPPath.POSITION) {
            // Position / Spectator keyframes need special handling
            interpolatorUpdateChange = updateInterpolators();
        } else {
            // Time keyframes only need updating when only one segment of them exists
            if (path.getSegments().size() == 1) {
                assert firstInterpolator.isPresent() : "One segment should have existed before as well";
                interpolatorUpdateChange = SetInterpolator.create(path.getSegments().iterator().next(), firstInterpolator.get());
            } else {
                interpolatorUpdateChange = CombinedChange.create(); // Noop change
            }
            interpolatorUpdateChange.apply(timeline);
        }

        // and update spectator positions
        Change spectatorChange = updateSpectatorPositions();
        spectatorChange.apply(timeline);

        return CombinedChange.createFromApplied(removeChange, addChange, propertyChange,
                restoreInterpolatorChange, interpolatorUpdateChange, spectatorChange);
    }

    // Helper method because generics cannot be defined on blocks
    private <T> void copyProperty(Property<T> property, Keyframe from, UpdateKeyframeProperties.Builder to) {
        from.getValue(property).ifPresent(value -> to.setValue(property, value));
    }

    private Change updateInterpolators() {
        Collection<PathSegment> pathSegments = positionPath.getSegments();
        // Contains updated pathsegment-interpolator mappings, may be changed multiple times over the course of this method
        Map<PathSegment, Interpolator> updates = new HashMap<>();

        // First, setup spectator interpolators
        Interpolator interpolator = null;
        // Iterate through all segments
        for (PathSegment segment : pathSegments) {
            if (isSpectatorSegment(segment)) {
                // If the last segment was a spectator segment, then use its interpolator for this segment as well
                if (interpolator == null) {
                    // otherwise create a new interpolator
                    interpolator = new LinearInterpolator();
                    interpolator.registerProperty(SpectatorProperty.PROPERTY);
                }
                // Now that we have an interpolator, set it for the current segment
                updates.put(segment, interpolator);
            } else {
                // Not a spectator segment, make sure we don't re-use the last interpolator (it would not be continuous)
                interpolator = null;
            }
        }

        // Then, if required, replace old default interpolators with new ones
        pathSegments.stream()
                // Ignore explicitly set segments
                .filter(s -> !s.getStartKeyframe().getValue(ExplicitInterpolationProperty.PROPERTY).isPresent())
                // Ignore spectator segments
                .filter(s -> !isSpectatorSegment(s))
                // Ignore already correct segments, should ignore all if default hasn't changed
                .filter(s -> !s.getInterpolator().getClass().equals(defaultInterpolatorType.getInterpolatorClass()))
                // Finally, set the interpolators
                // This will create one interpolator per segment, they will be merged later
                .forEach(segment -> updates.put(segment, createDefaultInterpolator()));

        // All interpolators should now be set appropriately but may still be fragmented.
        // Cleaning that up is a two step process

        // Firstly, all interpolators that are not continuous need to be split up
        Interpolator lastInterpolator = null;
        Set<Interpolator> used = Collections.newSetFromMap(new IdentityHashMap<>());
        for (PathSegment segment : pathSegments) {
            if (isSpectatorSegment(segment)) {
                lastInterpolator = null;
                continue; // Chain broken by spectator segment
            }

            Interpolator currentInterpolator = updates.getOrDefault(segment, segment.getInterpolator());
            if (lastInterpolator == currentInterpolator) {
                continue; // All fine, interpolator is still continuous
            }

            // New interpolator, make sure it hasn't been used before
            if (!used.add(interpolator)) {
                // It has been used before, we need to clone it and use the clone instead
                // This will create a new interpolator for each segment which will be merged later
                currentInterpolator = cloneInterpolator(currentInterpolator);
                updates.put(segment, currentInterpolator);
            }
            lastInterpolator = currentInterpolator;
        }

        // Secondly, all neighbouring interpolators that are equal need to be merged
        lastInterpolator = null;
        String lastInterpolatorSerialized = null;
        for (PathSegment segment : pathSegments) {
            if (isSpectatorSegment(segment)) {
                lastInterpolator = null;
                lastInterpolatorSerialized = null;
                continue; // Spectator segments are continuous by construction
            }
            Interpolator currentInterpolator = updates.getOrDefault(segment, segment.getInterpolator());
            String serialized = serializeInterpolator(currentInterpolator);
            if (lastInterpolator != currentInterpolator && serialized.equals(lastInterpolatorSerialized)) {
                // This interpolator is equal to the last one but not the same, needs merging
                updates.put(segment, lastInterpolator);
                continue;
            }
            // New interpolator, not related to the previous one, merged by definition
            lastInterpolator = currentInterpolator;
            lastInterpolatorSerialized = serialized;
        }

        Change change = CombinedChange.create(updates.entrySet().stream()
                .map(e -> SetInterpolator.create(e.getKey(), e.getValue())).toArray(Change[]::new));
        change.apply(timeline);
        return change;
    }

    private boolean isSpectatorSegment(PathSegment segment) {
        return segment.getStartKeyframe().getValue(SpectatorProperty.PROPERTY).isPresent()
                && segment.getEndKeyframe().getValue(SpectatorProperty.PROPERTY).isPresent();
    }

    private Change updateSpectatorPositions() {
        List<Change> changes = new ArrayList<>();
        timePath.updateAll();
        for (Keyframe keyframe : positionPath.getKeyframes()) {
            Optional<Integer> spectator = keyframe.getValue(SpectatorProperty.PROPERTY);
            if (spectator.isPresent()) {
                Optional<Integer> time = timePath.getValue(TimestampProperty.PROPERTY, keyframe.getTime());
                if (!time.isPresent()) {
                    continue; // No time keyframes set at this video time, cannot determine replay time
                }
                Location expected = entityTracker.getEntityPositionAtTimestamp(spectator.get(), time.get());
                if (expected == null) {
                    continue; // We don't have any data on this entity for some reason
                }
                Triple<Double, Double, Double> pos = keyframe.getValue(CameraProperties.POSITION).orElse(Triple.of(0D, 0D, 0D));
                Triple<Float, Float, Float> rot = keyframe.getValue(CameraProperties.ROTATION).orElse(Triple.of(0F, 0F, 0F));
                Location actual = new Location(pos.getLeft(), pos.getMiddle(), pos.getRight(), rot.getLeft(), rot.getRight());
                if (!expected.equals(actual)) {
                    changes.add(UpdateKeyframeProperties.create(positionPath, keyframe)
                            .setValue(CameraProperties.POSITION, Triple.of(expected.getX(), expected.getY(), expected.getZ()))
                            .setValue(CameraProperties.ROTATION, Triple.of(expected.getYaw(), expected.getPitch(), 0f)).done()
                    );
                }
            }
        }
        return CombinedChange.create(changes.toArray(new Change[changes.size()]));
    }

    private Interpolator createDefaultInterpolator() {
        return registerPositionInterpolatorProperties(defaultInterpolatorType.newInstance());
    }

    private Interpolator registerPositionInterpolatorProperties(Interpolator interpolator) {
        interpolator.registerProperty(CameraProperties.POSITION);
        interpolator.registerProperty(CameraProperties.ROTATION);
        return interpolator;
    }

    @Override
    public Timeline createTimeline() {
        return createTimelineStatic();
    }

    private static Timeline createInitialTimeline() {
        Timeline timeline = createTimelineStatic();
        timeline.createPath();
        timeline.createPath();
        return timeline;
    }

    private static Timeline createTimelineStatic() {
        Timeline timeline = new TimelineImpl();

        timeline.registerProperty(TimestampProperty.PROPERTY);
        timeline.registerProperty(CameraProperties.POSITION);
        timeline.registerProperty(CameraProperties.ROTATION);
        timeline.registerProperty(SpectatorProperty.PROPERTY);
        timeline.registerProperty(ExplicitInterpolationProperty.PROPERTY);

        return timeline;
    }

    @Override
    public void serializeInterpolator(JsonWriter writer, Interpolator interpolator) throws IOException {
        if (interpolator instanceof LinearInterpolator) {
            writer.value("linear");
        } else if (interpolator instanceof CubicSplineInterpolator) {
            writer.value("cubic-spline");
        } else if (interpolator instanceof CatmullRomSplineInterpolator) {
            writer.beginObject();
            writer.name("type").value("catmull-rom-spline");
            writer.name("alpha").value(((CatmullRomSplineInterpolator) interpolator).getAlpha());
            writer.endObject();
        } else {
            throw new IOException("Unknown interpolator type: " + interpolator);
        }
    }

    @Override
    public Interpolator deserializeInterpolator(JsonReader reader) throws IOException {
        String type;
        JsonObject args;
        switch (reader.peek()) {
            case STRING:
                type = reader.nextString();
                args = null;
                break;
            case BEGIN_OBJECT:
                args = new JsonParser().parse(reader).getAsJsonObject();
                type = args.get("type").getAsString();
                break;
            default:
                throw new IOException("Unexpected token: " + reader.peek());
        }
        switch (type) {
            case "linear":
                return new LinearInterpolator();
            case "cubic-spline":
                return new CubicSplineInterpolator();
            case "catmull-rom-spline":
                if (args == null || !args.has("alpha")) {
                    throw new IOException("Missing alpha value for catmull-rom-spline.");
                }
                return new CatmullRomSplineInterpolator(args.get("alpha").getAsDouble());
            default:
                throw new IOException("Unknown interpolation type: " + type);

        }
    }

    /**
     * Clones an interpolator by de- and re-serializing it.
     * @param interpolator The interpolator to clone
     * @return The cloned interpolator
     */
    private Interpolator cloneInterpolator(Interpolator interpolator) {
        Interpolator cloned = deserializeInterpolator(serializeInterpolator(interpolator));
        interpolator.getKeyframeProperties().forEach(cloned::registerProperty);
        return cloned;
    }

    /**
     * Serializes the specific interpolator to String.
     * Does <b>not</b> serialize the registered keyframe properties.
     * @param interpolator The interpolator to serialize.
     * @return The serialized interpolator
     */
    private String serializeInterpolator(Interpolator interpolator) {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();

        JsonWriter jsonWriter = new JsonWriter(new PrintWriter(baos));
        try {
            jsonWriter.beginArray();
            serializeInterpolator(jsonWriter, interpolator);
            jsonWriter.endArray();
            jsonWriter.flush();
        } catch (IOException e) {
            CrashReport crash = CrashReport.makeCrashReport(e, "Serializing interpolator");
            CrashReportCategory category = crash.makeCategory("Serializing interpolator");
            addDetail(category, "Interpolator", interpolator::toString);
            throw new ReportedException(crash);
        }

        return baos.toString();
    }

    private Interpolator deserializeInterpolator(String json) {
        JsonReader jsonReader = new JsonReader(new StringReader(json));
        try {
            jsonReader.beginArray();
            return deserializeInterpolator(jsonReader);
        } catch (IOException e) {
            CrashReport crash = CrashReport.makeCrashReport(e, "De-serializing interpolator");
            CrashReportCategory category = crash.makeCategory("De-serializing interpolator");
            category.addCrashSection("Interpolator", json);
            throw new ReportedException(crash);
        }
    }
}
