// Copyright (C) 2019-2022 Intel Corporation
//
// SPDX-License-Identifier: MIT

(() => {
    const ObjectState = require('./object-state');
    const { checkObjectType } = require('./common');
    const {
        colors, Source, ObjectShape, ObjectType, AttributeType, HistoryActions,
    } = require('./enums');

    const { DataError, ArgumentError, ScriptingError } = require('./exceptions');

    const { Label } = require('./labels');

    const defaultGroupColor = '#E0E0E0';

    // Called with the Annotation context
    function objectStateFactory(frame, data) {
        const objectState = new ObjectState(data);

        // eslint-disable-next-line no-underscore-dangle
        objectState.__internal = {
            save: this.save.bind(this, frame, objectState),
            delete: this.delete.bind(this),
            context: this,
        };

        return objectState;
    }

    function checkNumberOfPoints(shapeType, points) {
        if (shapeType === ObjectShape.RECTANGLE) {
            if (points.length / 2 !== 2) {
                throw new DataError(`Rectangle must have 2 points, but got ${points.length / 2}`);
            }
        } else if (shapeType === ObjectShape.POLYGON) {
            if (points.length / 2 < 3) {
                throw new DataError(`Polygon must have at least 3 points, but got ${points.length / 2}`);
            }
        } else if (shapeType === ObjectShape.POLYLINE) {
            if (points.length / 2 < 2) {
                throw new DataError(`Polyline must have at least 2 points, but got ${points.length / 2}`);
            }
        } else if (shapeType === ObjectShape.POINTS) {
            if (points.length / 2 < 1) {
                throw new DataError(`Points must have at least 1 points, but got ${points.length / 2}`);
            }
        } else if (shapeType === ObjectShape.CUBOID) {
            if (points.length / 2 !== 8) {
                throw new DataError(`Cuboid must have 8 points, but got ${points.length / 2}`);
            }
        } else if (shapeType === ObjectShape.ELLIPSE) {
            if (points.length / 2 !== 2) {
                throw new DataError(`Ellipse must have 1 point, rx and ry but got ${points.toString()}`);
            }
        } else {
            throw new ArgumentError(`Unknown value of shapeType has been received ${shapeType}`);
        }
    }

    function findAngleDiff(rightAngle, leftAngle) {
        let angleDiff = rightAngle - leftAngle;
        angleDiff = ((angleDiff + 180) % 360) - 180;
        if (Math.abs(angleDiff) >= 180) {
            // if the main arc is bigger than 180, go another arc
            // to find it, just substract absolute value from 360 and inverse sign
            angleDiff = 360 - Math.abs(angleDiff) * Math.sign(angleDiff) * -1;
        }
        return angleDiff;
    }

    function checkShapeArea(shapeType, points) {
        const MIN_SHAPE_LENGTH = 3;
        const MIN_SHAPE_AREA = 9;

        if (shapeType === ObjectShape.POINTS) {
            return true;
        }

        if (shapeType === ObjectShape.ELLIPSE) {
            const [cx, cy, rightX, topY] = points;
            const [rx, ry] = [rightX - cx, cy - topY];
            return rx * ry * Math.PI > MIN_SHAPE_AREA;
        }

        let xmin = Number.MAX_SAFE_INTEGER;
        let xmax = Number.MIN_SAFE_INTEGER;
        let ymin = Number.MAX_SAFE_INTEGER;
        let ymax = Number.MIN_SAFE_INTEGER;

        for (let i = 0; i < points.length - 1; i += 2) {
            xmin = Math.min(xmin, points[i]);
            xmax = Math.max(xmax, points[i]);
            ymin = Math.min(ymin, points[i + 1]);
            ymax = Math.max(ymax, points[i + 1]);
        }

        if (shapeType === ObjectShape.POLYLINE) {
            const length = Math.max(xmax - xmin, ymax - ymin);
            return length >= MIN_SHAPE_LENGTH;
        }

        const area = (xmax - xmin) * (ymax - ymin);
        return area >= MIN_SHAPE_AREA;
    }

    function rotatePoint(x, y, angle, cx = 0, cy = 0) {
        const sin = Math.sin((angle * Math.PI) / 180);
        const cos = Math.cos((angle * Math.PI) / 180);
        const rotX = (x - cx) * cos - (y - cy) * sin + cx;
        const rotY = (y - cy) * cos + (x - cx) * sin + cy;
        return [rotX, rotY];
    }

    function fitPoints(shapeType, points, rotation, maxX, maxY) {
        checkObjectType('rotation', rotation, 'number', null);
        points.forEach((coordinate) => checkObjectType('coordinate', coordinate, 'number', null));

        if (shapeType === ObjectShape.CUBOID || shapeType === ObjectShape.ELLIPSE || !!rotation) {
            // cuboids and rotated bounding boxes cannot be fitted
            return points;
        }

        const fittedPoints = [];

        for (let i = 0; i < points.length - 1; i += 2) {
            const x = points[i];
            const y = points[i + 1];
            const clampedX = Math.clamp(x, 0, maxX);
            const clampedY = Math.clamp(y, 0, maxY);
            fittedPoints.push(clampedX, clampedY);
        }

        return fittedPoints;
    }

    function validateAttributeValue(value, attr) {
        const { values } = attr;
        const type = attr.inputType;

        if (typeof value !== 'string') {
            throw new ArgumentError(`Attribute value is expected to be string, but got ${typeof value}`);
        }

        if (type === AttributeType.NUMBER) {
            return +value >= +values[0] && +value <= +values[1];
        }

        if (type === AttributeType.CHECKBOX) {
            return ['true', 'false'].includes(value.toLowerCase());
        }

        if (type === AttributeType.TEXT) {
            return true;
        }

        return values.includes(value);
    }

    class Annotation {
        constructor(data, clientID, color, injection) {
            this.taskLabels = injection.labels;
            this.history = injection.history;
            this.groupColors = injection.groupColors;
            this.clientID = clientID;
            this.serverID = data.id;
            this.group = data.group;
            this.label = this.taskLabels[data.label_id];
            this.frame = data.frame;
            this.removed = false;
            this.lock = false;
            this.color = color;
            this.source = data.source;
            this.updated = Date.now();
            this.attributes = data.attributes.reduce((attributeAccumulator, attr) => {
                attributeAccumulator[attr.spec_id] = attr.value;
                return attributeAccumulator;
            }, {});
            this.groupObject = Object.defineProperties(
                {},
                {
                    color: {
                        get: () => {
                            if (this.group) {
                                return this.groupColors[this.group] || colors[this.group % colors.length];
                            }
                            return defaultGroupColor;
                        },
                        set: (newColor) => {
                            if (this.group && typeof newColor === 'string' && /^#[0-9A-F]{6}$/i.test(newColor)) {
                                this.groupColors[this.group] = newColor;
                            }
                        },
                    },
                    id: {
                        get: () => this.group,
                    },
                },
            );
            this.appendDefaultAttributes(this.label);

            injection.groups.max = Math.max(injection.groups.max, this.group);
        }

        _saveLock(lock, frame) {
            const undoLock = this.lock;
            const redoLock = lock;

            this.history.do(
                HistoryActions.CHANGED_LOCK,
                () => {
                    this.lock = undoLock;
                    this.updated = Date.now();
                },
                () => {
                    this.lock = redoLock;
                    this.updated = Date.now();
                },
                [this.clientID],
                frame,
            );

            this.lock = lock;
        }

        _saveColor(color, frame) {
            const undoColor = this.color;
            const redoColor = color;

            this.history.do(
                HistoryActions.CHANGED_COLOR,
                () => {
                    this.color = undoColor;
                    this.updated = Date.now();
                },
                () => {
                    this.color = redoColor;
                    this.updated = Date.now();
                },
                [this.clientID],
                frame,
            );

            this.color = color;
        }

        _saveHidden(hidden, frame) {
            const undoHidden = this.hidden;
            const redoHidden = hidden;

            this.history.do(
                HistoryActions.CHANGED_HIDDEN,
                () => {
                    this.hidden = undoHidden;
                    this.updated = Date.now();
                },
                () => {
                    this.hidden = redoHidden;
                    this.updated = Date.now();
                },
                [this.clientID],
                frame,
            );

            this.hidden = hidden;
        }

        _saveLabel(label, frame) {
            const undoLabel = this.label;
            const redoLabel = label;
            const undoAttributes = { ...this.attributes };
            this.label = label;
            this.attributes = {};
            this.appendDefaultAttributes(label);

            // Try to keep old attributes if name matches and old value is still valid
            for (const attribute of redoLabel.attributes) {
                for (const oldAttribute of undoLabel.attributes) {
                    if (
                        attribute.name === oldAttribute.name &&
                        validateAttributeValue(undoAttributes[oldAttribute.id], attribute)
                    ) {
                        this.attributes[attribute.id] = undoAttributes[oldAttribute.id];
                    }
                }
            }
            const redoAttributes = { ...this.attributes };

            this.history.do(
                HistoryActions.CHANGED_LABEL,
                () => {
                    this.label = undoLabel;
                    this.attributes = undoAttributes;
                    this.updated = Date.now();
                },
                () => {
                    this.label = redoLabel;
                    this.attributes = redoAttributes;
                    this.updated = Date.now();
                },
                [this.clientID],
                frame,
            );
        }

        _saveAttributes(attributes, frame) {
            const undoAttributes = { ...this.attributes };

            for (const attrID of Object.keys(attributes)) {
                this.attributes[attrID] = attributes[attrID];
            }

            const redoAttributes = { ...this.attributes };

            this.history.do(
                HistoryActions.CHANGED_ATTRIBUTES,
                () => {
                    this.attributes = undoAttributes;
                    this.updated = Date.now();
                },
                () => {
                    this.attributes = redoAttributes;
                    this.updated = Date.now();
                },
                [this.clientID],
                frame,
            );
        }

        _validateStateBeforeSave(frame, data, updated) {
            let fittedPoints = [];

            if (updated.label) {
                checkObjectType('label', data.label, null, Label);
            }

            const labelAttributes = data.label.attributes.reduce((accumulator, value) => {
                accumulator[value.id] = value;
                return accumulator;
            }, {});

            if (updated.attributes) {
                for (const attrID of Object.keys(data.attributes)) {
                    const value = data.attributes[attrID];
                    if (attrID in labelAttributes) {
                        if (!validateAttributeValue(value, labelAttributes[attrID])) {
                            throw new ArgumentError(
                                `Trying to save an attribute attribute with id ${attrID} and invalid value ${value}`,
                            );
                        }
                    } else {
                        throw new ArgumentError(
                            `The label of the shape doesn't have the attribute with id ${attrID} and value ${value}`,
                        );
                    }
                }
            }

            if (updated.descriptions) {
                if (!Array.isArray(data.descriptions) || data.descriptions.some((desc) => typeof desc !== 'string')) {
                    throw new ArgumentError(
                        `Descriptions are expected to be an array of strings but got ${data.descriptions}`,
                    );
                }
            }

            if (updated.points) {
                checkObjectType('points', data.points, null, Array);
                checkNumberOfPoints(this.shapeType, data.points);
                // cut points
                const { width, height, filename } = this.frameMeta[frame];
                fittedPoints = fitPoints(this.shapeType, data.points, data.rotation, width, height);
                let check = true;
                if (filename && filename.slice(filename.length - 3) === 'pcd') {
                    check = false;
                }
                if (check) {
                    if (!checkShapeArea(this.shapeType, fittedPoints)) {
                        fittedPoints = [];
                    }
                }
            }

            if (updated.occluded) {
                checkObjectType('occluded', data.occluded, 'boolean', null);
            }

            if (updated.outside) {
                checkObjectType('outside', data.outside, 'boolean', null);
            }

            if (updated.zOrder) {
                checkObjectType('zOrder', data.zOrder, 'integer', null);
            }

            if (updated.lock) {
                checkObjectType('lock', data.lock, 'boolean', null);
            }

            if (updated.pinned) {
                checkObjectType('pinned', data.pinned, 'boolean', null);
            }

            if (updated.color) {
                checkObjectType('color', data.color, 'string', null);
                if (!/^#[0-9A-F]{6}$/i.test(data.color)) {
                    throw new ArgumentError(`Got invalid color value: "${data.color}"`);
                }
            }

            if (updated.hidden) {
                checkObjectType('hidden', data.hidden, 'boolean', null);
            }

            if (updated.keyframe) {
                checkObjectType('keyframe', data.keyframe, 'boolean', null);
                if (!this.shapes || (Object.keys(this.shapes).length === 1 && !data.keyframe)) {
                    throw new ArgumentError(
                        'Can not remove the latest keyframe of an object. Consider removing the object instead',
                    );
                }
            }

            return fittedPoints;
        }

        appendDefaultAttributes(label) {
            const labelAttributes = label.attributes;
            for (const attribute of labelAttributes) {
                if (!(attribute.id in this.attributes)) {
                    this.attributes[attribute.id] = attribute.defaultValue;
                }
            }
        }

        updateTimestamp(updated) {
            const anyChanges = Object.keys(updated).some((key) => !!updated[key]);
            if (anyChanges) {
                this.updated = Date.now();
            }
        }

        delete(frame, force) {
            if (!this.lock || force) {
                this.removed = true;

                this.history.do(
                    HistoryActions.REMOVED_OBJECT,
                    () => {
                        this.serverID = undefined;
                        this.removed = false;
                        this.updated = Date.now();
                    },
                    () => {
                        this.removed = true;
                        this.updated = Date.now();
                    },
                    [this.clientID],
                    frame,
                );
            }

            return this.removed;
        }
    }

    class Drawn extends Annotation {
        constructor(data, clientID, color, injection) {
            super(data, clientID, color, injection);
            this.frameMeta = injection.frameMeta;
            this.descriptions = data.descriptions || [];
            this.hidden = false;
            this.pinned = true;
            this.shapeType = null;
        }

        _saveDescriptions(descriptions) {
            this.descriptions = [...descriptions];
        }

        _savePinned(pinned, frame) {
            const undoPinned = this.pinned;
            const redoPinned = pinned;

            this.history.do(
                HistoryActions.CHANGED_PINNED,
                () => {
                    this.pinned = undoPinned;
                    this.updated = Date.now();
                },
                () => {
                    this.pinned = redoPinned;
                    this.updated = Date.now();
                },
                [this.clientID],
                frame,
            );

            this.pinned = pinned;
        }

        save() {
            throw new ScriptingError('Is not implemented');
        }

        get() {
            throw new ScriptingError('Is not implemented');
        }

        toJSON() {
            throw new ScriptingError('Is not implemented');
        }
    }

    class Shape extends Drawn {
        constructor(data, clientID, color, injection) {
            super(data, clientID, color, injection);
            this.points = data.points;
            this.rotation = data.rotation || 0;
            this.occluded = data.occluded;
            this.zOrder = data.z_order;
        }

        // Method is used to export data to the server
        toJSON() {
            return {
                type: this.shapeType,
                clientID: this.clientID,
                occluded: this.occluded,
                z_order: this.zOrder,
                points: [...this.points],
                rotation: this.rotation,
                attributes: Object.keys(this.attributes).reduce((attributeAccumulator, attrId) => {
                    attributeAccumulator.push({
                        spec_id: attrId,
                        value: this.attributes[attrId],
                    });

                    return attributeAccumulator;
                }, []),
                id: this.serverID,
                frame: this.frame,
                label_id: this.label.id,
                group: this.group,
                source: this.source,
            };
        }

        // Method is used to construct ObjectState objects
        get(frame) {
            if (frame !== this.frame) {
                throw new ScriptingError('Got frame is not equal to the frame of the shape');
            }

            return {
                objectType: ObjectType.SHAPE,
                shapeType: this.shapeType,
                clientID: this.clientID,
                serverID: this.serverID,
                occluded: this.occluded,
                lock: this.lock,
                zOrder: this.zOrder,
                points: [...this.points],
                rotation: this.rotation,
                attributes: { ...this.attributes },
                descriptions: [...this.descriptions],
                label: this.label,
                group: this.groupObject,
                color: this.color,
                hidden: this.hidden,
                updated: this.updated,
                pinned: this.pinned,
                frame,
                source: this.source,
            };
        }

        _savePoints(points, rotation, frame) {
            const undoPoints = this.points;
            const undoRotation = this.rotation;
            const redoPoints = points;
            const redoRotation = rotation;
            const undoSource = this.source;
            const redoSource = Source.MANUAL;

            this.history.do(
                HistoryActions.CHANGED_POINTS,
                () => {
                    this.points = undoPoints;
                    this.source = undoSource;
                    this.rotation = undoRotation;
                    this.updated = Date.now();
                },
                () => {
                    this.points = redoPoints;
                    this.source = redoSource;
                    this.rotation = redoRotation;
                    this.updated = Date.now();
                },
                [this.clientID],
                frame,
            );

            this.source = Source.MANUAL;
            this.points = points;
            this.rotation = rotation;
        }

        _saveOccluded(occluded, frame) {
            const undoOccluded = this.occluded;
            const redoOccluded = occluded;
            const undoSource = this.source;
            const redoSource = Source.MANUAL;

            this.history.do(
                HistoryActions.CHANGED_OCCLUDED,
                () => {
                    this.occluded = undoOccluded;
                    this.source = undoSource;
                    this.updated = Date.now();
                },
                () => {
                    this.occluded = redoOccluded;
                    this.source = redoSource;
                    this.updated = Date.now();
                },
                [this.clientID],
                frame,
            );

            this.source = Source.MANUAL;
            this.occluded = occluded;
        }

        _saveZOrder(zOrder, frame) {
            const undoZOrder = this.zOrder;
            const redoZOrder = zOrder;
            const undoSource = this.source;
            const redoSource = Source.MANUAL;

            this.history.do(
                HistoryActions.CHANGED_ZORDER,
                () => {
                    this.zOrder = undoZOrder;
                    this.source = undoSource;
                    this.updated = Date.now();
                },
                () => {
                    this.zOrder = redoZOrder;
                    this.source = redoSource;
                    this.updated = Date.now();
                },
                [this.clientID],
                frame,
            );

            this.source = Source.MANUAL;
            this.zOrder = zOrder;
        }

        save(frame, data) {
            if (frame !== this.frame) {
                throw new ScriptingError('Got frame is not equal to the frame of the shape');
            }

            if (this.lock && data.lock) {
                return objectStateFactory.call(this, frame, this.get(frame));
            }

            const updated = data.updateFlags;
            const fittedPoints = this._validateStateBeforeSave(frame, data, updated);
            const { rotation } = data;

            // Now when all fields are validated, we can apply them
            if (updated.label) {
                this._saveLabel(data.label, frame);
            }

            if (updated.attributes) {
                this._saveAttributes(data.attributes, frame);
            }

            if (updated.descriptions) {
                this._saveDescriptions(data.descriptions);
            }

            if (updated.points && fittedPoints.length) {
                this._savePoints(fittedPoints, rotation, frame);
            }

            if (updated.occluded) {
                this._saveOccluded(data.occluded, frame);
            }

            if (updated.zOrder) {
                this._saveZOrder(data.zOrder, frame);
            }

            if (updated.lock) {
                this._saveLock(data.lock, frame);
            }

            if (updated.pinned) {
                this._savePinned(data.pinned, frame);
            }

            if (updated.color) {
                this._saveColor(data.color, frame);
            }

            if (updated.hidden) {
                this._saveHidden(data.hidden, frame);
            }

            this.updateTimestamp(updated);
            updated.reset();

            return objectStateFactory.call(this, frame, this.get(frame));
        }
    }

    class Track extends Drawn {
        constructor(data, clientID, color, injection) {
            super(data, clientID, color, injection);
            this.shapes = data.shapes.reduce((shapeAccumulator, value) => {
                shapeAccumulator[value.frame] = {
                    serverID: value.id,
                    occluded: value.occluded,
                    zOrder: value.z_order,
                    points: value.points,
                    outside: value.outside,
                    rotation: value.rotation || 0,
                    attributes: value.attributes.reduce((attributeAccumulator, attr) => {
                        attributeAccumulator[attr.spec_id] = attr.value;
                        return attributeAccumulator;
                    }, {}),
                };

                return shapeAccumulator;
            }, {});
        }

        // Method is used to export data to the server
        toJSON() {
            const labelAttributes = this.label.attributes.reduce((accumulator, attribute) => {
                accumulator[attribute.id] = attribute;
                return accumulator;
            }, {});

            return {
                clientID: this.clientID,
                id: this.serverID,
                frame: this.frame,
                label_id: this.label.id,
                group: this.group,
                source: this.source,
                attributes: Object.keys(this.attributes).reduce((attributeAccumulator, attrId) => {
                    if (!labelAttributes[attrId].mutable) {
                        attributeAccumulator.push({
                            spec_id: attrId,
                            value: this.attributes[attrId],
                        });
                    }

                    return attributeAccumulator;
                }, []),
                shapes: Object.keys(this.shapes).reduce((shapesAccumulator, frame) => {
                    shapesAccumulator.push({
                        type: this.shapeType,
                        occluded: this.shapes[frame].occluded,
                        z_order: this.shapes[frame].zOrder,
                        points: [...this.shapes[frame].points],
                        rotation: this.shapes[frame].rotation,
                        outside: this.shapes[frame].outside,
                        attributes: Object.keys(this.shapes[frame].attributes).reduce(
                            (attributeAccumulator, attrId) => {
                                if (labelAttributes[attrId].mutable) {
                                    attributeAccumulator.push({
                                        spec_id: attrId,
                                        value: this.shapes[frame].attributes[attrId],
                                    });
                                }

                                return attributeAccumulator;
                            },
                            [],
                        ),
                        id: this.shapes[frame].serverID,
                        frame: +frame,
                    });

                    return shapesAccumulator;
                }, []),
            };
        }

        // Method is used to construct ObjectState objects
        get(frame) {
            const {
                prev, next, first, last,
            } = this.boundedKeyframes(frame);

            return {
                ...this.getPosition(frame, prev, next),
                attributes: this.getAttributes(frame),
                descriptions: [...this.descriptions],
                group: this.groupObject,
                objectType: ObjectType.TRACK,
                shapeType: this.shapeType,
                clientID: this.clientID,
                serverID: this.serverID,
                lock: this.lock,
                color: this.color,
                hidden: this.hidden,
                updated: this.updated,
                label: this.label,
                pinned: this.pinned,
                keyframes: {
                    prev,
                    next,
                    first,
                    last,
                },
                frame,
                source: this.source,
            };
        }

        boundedKeyframes(targetFrame) {
            const frames = Object.keys(this.shapes).map((frame) => +frame);
            let lDiff = Number.MAX_SAFE_INTEGER;
            let rDiff = Number.MAX_SAFE_INTEGER;
            let first = Number.MAX_SAFE_INTEGER;
            let last = Number.MIN_SAFE_INTEGER;

            for (const frame of frames) {
                if (frame in this.frameMeta.deleted_frames) {
                    continue;
                }

                if (frame < first) {
                    first = frame;
                }
                if (frame > last) {
                    last = frame;
                }

                const diff = Math.abs(targetFrame - frame);

                if (frame < targetFrame && diff < lDiff) {
                    lDiff = diff;
                } else if (frame > targetFrame && diff < rDiff) {
                    rDiff = diff;
                }
            }

            const prev = lDiff === Number.MAX_SAFE_INTEGER ? null : targetFrame - lDiff;
            const next = rDiff === Number.MAX_SAFE_INTEGER ? null : targetFrame + rDiff;

            return {
                prev,
                next,
                first,
                last,
            };
        }

        getAttributes(targetFrame) {
            const result = {};

            // First of all copy all unmutable attributes
            for (const attrID in this.attributes) {
                if (Object.prototype.hasOwnProperty.call(this.attributes, attrID)) {
                    result[attrID] = this.attributes[attrID];
                }
            }

            // Secondly get latest mutable attributes up to target frame
            const frames = Object.keys(this.shapes).sort((a, b) => +a - +b);
            for (const frame of frames) {
                if (frame <= targetFrame) {
                    const { attributes } = this.shapes[frame];

                    for (const attrID in attributes) {
                        if (Object.prototype.hasOwnProperty.call(attributes, attrID)) {
                            result[attrID] = attributes[attrID];
                        }
                    }
                }
            }

            return result;
        }

        _saveLabel(label, frame) {
            const undoLabel = this.label;
            const redoLabel = label;
            const undoAttributes = {
                unmutable: { ...this.attributes },
                mutable: Object.keys(this.shapes).map((key) => ({
                    frame: +key,
                    attributes: { ...this.shapes[key].attributes },
                })),
            };

            this.label = label;
            this.attributes = {};
            for (const shape of Object.values(this.shapes)) {
                shape.attributes = {};
            }
            this.appendDefaultAttributes(label);

            const redoAttributes = {
                unmutable: { ...this.attributes },
                mutable: Object.keys(this.shapes).map((key) => ({
                    frame: +key,
                    attributes: { ...this.shapes[key].attributes },
                })),
            };

            this.history.do(
                HistoryActions.CHANGED_LABEL,
                () => {
                    this.label = undoLabel;
                    this.attributes = undoAttributes.unmutable;
                    for (const mutable of undoAttributes.mutable) {
                        this.shapes[mutable.frame].attributes = mutable.attributes;
                    }
                    this.updated = Date.now();
                },
                () => {
                    this.label = redoLabel;
                    this.attributes = redoAttributes.unmutable;
                    for (const mutable of redoAttributes.mutable) {
                        this.shapes[mutable.frame].attributes = mutable.attributes;
                    }
                    this.updated = Date.now();
                },
                [this.clientID],
                frame,
            );
        }

        _saveAttributes(attributes, frame) {
            const current = this.get(frame);
            const labelAttributes = this.label.attributes.reduce((accumulator, value) => {
                accumulator[value.id] = value;
                return accumulator;
            }, {});

            const wasKeyframe = frame in this.shapes;
            const undoAttributes = this.attributes;
            const undoShape = wasKeyframe ? this.shapes[frame] : undefined;

            let mutableAttributesUpdated = false;
            const redoAttributes = { ...this.attributes };
            for (const attrID of Object.keys(attributes)) {
                if (!labelAttributes[attrID].mutable) {
                    redoAttributes[attrID] = attributes[attrID];
                } else if (attributes[attrID] !== current.attributes[attrID]) {
                    mutableAttributesUpdated = mutableAttributesUpdated ||
                        // not keyframe yet
                        !(frame in this.shapes) ||
                        // keyframe, but without this attrID
                        !(attrID in this.shapes[frame].attributes) ||
                        // keyframe with attrID, but with another value
                        this.shapes[frame].attributes[attrID] !== attributes[attrID];
                }
            }
            let redoShape;
            if (mutableAttributesUpdated) {
                if (wasKeyframe) {
                    redoShape = {
                        ...this.shapes[frame],
                        attributes: {
                            ...this.shapes[frame].attributes,
                        },
                    };
                } else {
                    redoShape = {
                        frame,
                        zOrder: current.zOrder,
                        points: current.points,
                        outside: current.outside,
                        occluded: current.occluded,
                        attributes: {},
                    };
                }
            }

            for (const attrID of Object.keys(attributes)) {
                if (labelAttributes[attrID].mutable && attributes[attrID] !== current.attributes[attrID]) {
                    redoShape.attributes[attrID] = attributes[attrID];
                }
            }

            this.attributes = redoAttributes;
            if (redoShape) {
                this.shapes[frame] = redoShape;
            }

            this.history.do(
                HistoryActions.CHANGED_ATTRIBUTES,
                () => {
                    this.attributes = undoAttributes;
                    if (undoShape) {
                        this.shapes[frame] = undoShape;
                    } else if (redoShape) {
                        delete this.shapes[frame];
                    }
                    this.updated = Date.now();
                },
                () => {
                    this.attributes = redoAttributes;
                    if (redoShape) {
                        this.shapes[frame] = redoShape;
                    }
                    this.updated = Date.now();
                },
                [this.clientID],
                frame,
            );
        }

        _appendShapeActionToHistory(actionType, frame, undoShape, redoShape, undoSource, redoSource) {
            this.history.do(
                actionType,
                () => {
                    if (!undoShape) {
                        delete this.shapes[frame];
                    } else {
                        this.shapes[frame] = undoShape;
                    }
                    this.source = undoSource;
                    this.updated = Date.now();
                },
                () => {
                    if (!redoShape) {
                        delete this.shapes[frame];
                    } else {
                        this.shapes[frame] = redoShape;
                    }
                    this.source = redoSource;
                    this.updated = Date.now();
                },
                [this.clientID],
                frame,
            );
        }

        _savePoints(points, rotation, frame) {
            const current = this.get(frame);
            const wasKeyframe = frame in this.shapes;
            const undoSource = this.source;
            const redoSource = Source.MANUAL;
            const undoShape = wasKeyframe ? this.shapes[frame] : undefined;
            const redoShape = wasKeyframe ? { ...this.shapes[frame], points, rotation } : {
                frame,
                points,
                rotation,
                zOrder: current.zOrder,
                outside: current.outside,
                occluded: current.occluded,
                attributes: {},
            };

            this.shapes[frame] = redoShape;
            this.source = Source.MANUAL;
            this._appendShapeActionToHistory(
                HistoryActions.CHANGED_POINTS,
                frame,
                undoShape,
                redoShape,
                undoSource,
                redoSource,
            );
        }

        _saveOutside(frame, outside) {
            const current = this.get(frame);
            const wasKeyframe = frame in this.shapes;
            const undoSource = this.source;
            const redoSource = Source.MANUAL;
            const undoShape = wasKeyframe ? this.shapes[frame] : undefined;
            const redoShape = wasKeyframe ?
                { ...this.shapes[frame], outside } :
                {
                    frame,
                    outside,
                    rotation: current.rotation,
                    zOrder: current.zOrder,
                    points: current.points,
                    occluded: current.occluded,
                    attributes: {},
                };

            this.shapes[frame] = redoShape;
            this.source = Source.MANUAL;
            this._appendShapeActionToHistory(
                HistoryActions.CHANGED_OUTSIDE,
                frame,
                undoShape,
                redoShape,
                undoSource,
                redoSource,
            );
        }

        _saveOccluded(occluded, frame) {
            const current = this.get(frame);
            const wasKeyframe = frame in this.shapes;
            const undoSource = this.source;
            const redoSource = Source.MANUAL;
            const undoShape = wasKeyframe ? this.shapes[frame] : undefined;
            const redoShape = wasKeyframe ?
                { ...this.shapes[frame], occluded } :
                {
                    frame,
                    occluded,
                    rotation: current.rotation,
                    zOrder: current.zOrder,
                    points: current.points,
                    outside: current.outside,
                    attributes: {},
                };

            this.shapes[frame] = redoShape;
            this.source = Source.MANUAL;
            this._appendShapeActionToHistory(
                HistoryActions.CHANGED_OCCLUDED,
                frame,
                undoShape,
                redoShape,
                undoSource,
                redoSource,
            );
        }

        _saveZOrder(zOrder, frame) {
            const current = this.get(frame);
            const wasKeyframe = frame in this.shapes;
            const undoSource = this.source;
            const redoSource = Source.MANUAL;
            const undoShape = wasKeyframe ? this.shapes[frame] : undefined;
            const redoShape = wasKeyframe ?
                { ...this.shapes[frame], zOrder } :
                {
                    frame,
                    zOrder,
                    rotation: current.rotation,
                    occluded: current.occluded,
                    points: current.points,
                    outside: current.outside,
                    attributes: {},
                };

            this.shapes[frame] = redoShape;
            this.source = Source.MANUAL;
            this._appendShapeActionToHistory(
                HistoryActions.CHANGED_ZORDER,
                frame,
                undoShape,
                redoShape,
                undoSource,
                redoSource,
            );
        }

        _saveKeyframe(frame, keyframe) {
            const current = this.get(frame);
            const wasKeyframe = frame in this.shapes;

            if ((keyframe && wasKeyframe) || (!keyframe && !wasKeyframe)) {
                return;
            }

            const undoSource = this.source;
            const redoSource = Source.MANUAL;
            const undoShape = wasKeyframe ? this.shapes[frame] : undefined;
            const redoShape = keyframe ?
                {
                    frame,
                    rotation: current.rotation,
                    zOrder: current.zOrder,
                    points: current.points,
                    outside: current.outside,
                    occluded: current.occluded,
                    attributes: {},
                    source: current.source,
                } :
                undefined;

            this.source = Source.MANUAL;
            if (redoShape) {
                this.shapes[frame] = redoShape;
            } else {
                delete this.shapes[frame];
            }

            this._appendShapeActionToHistory(
                HistoryActions.CHANGED_KEYFRAME,
                frame,
                undoShape,
                redoShape,
                undoSource,
                redoSource,
            );
        }

        save(frame, data) {
            if (this.lock && data.lock) {
                return objectStateFactory.call(this, frame, this.get(frame));
            }

            const updated = data.updateFlags;
            const fittedPoints = this._validateStateBeforeSave(frame, data, updated);
            const { rotation } = data;

            if (updated.label) {
                this._saveLabel(data.label, frame);
            }

            if (updated.lock) {
                this._saveLock(data.lock, frame);
            }

            if (updated.pinned) {
                this._savePinned(data.pinned, frame);
            }

            if (updated.color) {
                this._saveColor(data.color, frame);
            }

            if (updated.hidden) {
                this._saveHidden(data.hidden, frame);
            }

            if (updated.points && fittedPoints.length) {
                this._savePoints(fittedPoints, rotation, frame);
            }

            if (updated.outside) {
                this._saveOutside(frame, data.outside);
            }

            if (updated.occluded) {
                this._saveOccluded(data.occluded, frame);
            }

            if (updated.zOrder) {
                this._saveZOrder(data.zOrder, frame);
            }

            if (updated.attributes) {
                this._saveAttributes(data.attributes, frame);
            }

            if (updated.descriptions) {
                this._saveDescriptions(data.descriptions);
            }

            if (updated.keyframe) {
                this._saveKeyframe(frame, data.keyframe);
            }

            this.updateTimestamp(updated);
            updated.reset();

            return objectStateFactory.call(this, frame, this.get(frame));
        }

        getPosition(targetFrame, leftKeyframe, rightFrame) {
            const leftFrame = targetFrame in this.shapes ? targetFrame : leftKeyframe;
            const rightPosition = Number.isInteger(rightFrame) ? this.shapes[rightFrame] : null;
            const leftPosition = Number.isInteger(leftFrame) ? this.shapes[leftFrame] : null;

            if (leftPosition && rightPosition) {
                return {
                    ...this.interpolatePosition(
                        leftPosition,
                        rightPosition,
                        (targetFrame - leftFrame) / (rightFrame - leftFrame),
                    ),
                    keyframe: targetFrame in this.shapes,
                };
            }

            if (leftPosition) {
                return {
                    points: [...leftPosition.points],
                    rotation: leftPosition.rotation,
                    occluded: leftPosition.occluded,
                    outside: leftPosition.outside,
                    zOrder: leftPosition.zOrder,
                    keyframe: targetFrame in this.shapes,
                };
            }

            if (rightPosition) {
                return {
                    points: [...rightPosition.points],
                    rotation: rightPosition.rotation,
                    occluded: rightPosition.occluded,
                    zOrder: rightPosition.zOrder,
                    keyframe: targetFrame in this.shapes,
                    outside: true,
                };
            }

            throw new DataError(
                'No one left position or right position was found. ' +
                    `Interpolation impossible. Client ID: ${this.clientID}`,
            );
        }
    }

    class Tag extends Annotation {
        // Method is used to export data to the server
        toJSON() {
            return {
                clientID: this.clientID,
                id: this.serverID,
                frame: this.frame,
                label_id: this.label.id,
                group: this.group,
                source: this.source,
                attributes: Object.keys(this.attributes).reduce((attributeAccumulator, attrId) => {
                    attributeAccumulator.push({
                        spec_id: attrId,
                        value: this.attributes[attrId],
                    });

                    return attributeAccumulator;
                }, []),
            };
        }

        // Method is used to construct ObjectState objects
        get(frame) {
            if (frame !== this.frame) {
                throw new ScriptingError('Got frame is not equal to the frame of the shape');
            }

            return {
                objectType: ObjectType.TAG,
                clientID: this.clientID,
                serverID: this.serverID,
                lock: this.lock,
                attributes: { ...this.attributes },
                label: this.label,
                group: this.groupObject,
                color: this.color,
                updated: this.updated,
                frame,
                source: this.source,
            };
        }

        save(frame, data) {
            if (frame !== this.frame) {
                throw new ScriptingError('Got frame is not equal to the frame of the tag');
            }

            if (this.lock && data.lock) {
                return objectStateFactory.call(this, frame, this.get(frame));
            }

            const updated = data.updateFlags;
            this._validateStateBeforeSave(frame, data, updated);

            // Now when all fields are validated, we can apply them
            if (updated.label) {
                this._saveLabel(data.label, frame);
            }

            if (updated.attributes) {
                this._saveAttributes(data.attributes, frame);
            }

            if (updated.lock) {
                this._saveLock(data.lock, frame);
            }

            if (updated.color) {
                this._saveColor(data.color, frame);
            }

            this.updateTimestamp(updated);
            updated.reset();

            return objectStateFactory.call(this, frame, this.get(frame));
        }
    }

    class RectangleShape extends Shape {
        constructor(data, clientID, color, injection) {
            super(data, clientID, color, injection);
            this.shapeType = ObjectShape.RECTANGLE;
            this.pinned = false;
            checkNumberOfPoints(this.shapeType, this.points);
        }

        static distance(points, x, y, angle) {
            const [xtl, ytl, xbr, ybr] = points;
            const cx = xtl + (xbr - xtl) / 2;
            const cy = ytl + (ybr - ytl) / 2;
            const [rotX, rotY] = rotatePoint(x, y, -angle, cx, cy);

            if (!(rotX >= xtl && rotX <= xbr && rotY >= ytl && rotY <= ybr)) {
                // Cursor is outside of a box
                return null;
            }

            // The shortest distance from point to an edge
            return Math.min.apply(null, [rotX - xtl, rotY - ytl, xbr - rotX, ybr - rotY]);
        }
    }

    class EllipseShape extends Shape {
        constructor(data, clientID, color, injection) {
            super(data, clientID, color, injection);
            this.shapeType = ObjectShape.ELLIPSE;
            this.pinned = false;
            checkNumberOfPoints(this.shapeType, this.points);
        }

        static distance(points, x, y, angle) {
            const [cx, cy, rightX, topY] = points;
            const [rx, ry] = [rightX - cx, cy - topY];
            const [rotX, rotY] = rotatePoint(x, y, -angle, cx, cy);
            // https://math.stackexchange.com/questions/76457/check-if-a-point-is-within-an-ellipse
            const pointWithinEllipse = (_x, _y) => (
                ((_x - cx) ** 2) / rx ** 2) + (((_y - cy) ** 2) / ry ** 2
            ) <= 1;

            if (!pointWithinEllipse(rotX, rotY)) {
                // Cursor is outside of an ellipse
                return null;
            }

            if (Math.abs(x - cx) < Number.EPSILON && Math.abs(y - cy) < Number.EPSILON) {
                // cursor is near to the center, just return minimum of height, width
                return Math.min(rx, ry);
            }

            // ellipse equation is x^2/rx^2 + y^2/ry^2 = 1
            // from this equation:
            // x^2 = ((rx * ry)^2 - (y * rx)^2) / ry^2
            // y^2 = ((rx * ry)^2 - (x * ry)^2) / rx^2

            // we have one point inside the ellipse, let's build two lines (horizontal and vertical) through the point
            // and find their interception with ellipse
            const x2Equation = (_y) => (((rx * ry) ** 2) - ((_y * rx) ** 2)) / (ry ** 2);
            const y2Equation = (_x) => (((rx * ry) ** 2) - ((_x * ry) ** 2)) / (rx ** 2);

            // shift x,y to the ellipse coordinate system to compute equation correctly
            // y axis is inverted
            const [shiftedX, shiftedY] = [x - cx, cy - y];
            const [x1, x2] = [Math.sqrt(x2Equation(shiftedY)), -Math.sqrt(x2Equation(shiftedY))];
            const [y1, y2] = [Math.sqrt(y2Equation(shiftedX)), -Math.sqrt(y2Equation(shiftedX))];

            // found two points on ellipse edge
            const ellipseP1X = shiftedX >= 0 ? x1 : x2; // ellipseP1Y is shiftedY
            const ellipseP2Y = shiftedY >= 0 ? y1 : y2; // ellipseP1X is shiftedX

            // found diffs between two points on edges and target point
            const diff1X = ellipseP1X - shiftedX;
            const diff2Y = ellipseP2Y - shiftedY;

            // return minimum, get absolute value because we need distance, not diff
            return Math.min(Math.abs(diff1X), Math.abs(diff2Y));
        }
    }

    class PolyShape extends Shape {
        constructor(data, clientID, color, injection) {
            super(data, clientID, color, injection);
            this.rotation = 0; // is not supported
        }
    }

    class PolygonShape extends PolyShape {
        constructor(data, clientID, color, injection) {
            super(data, clientID, color, injection);
            this.shapeType = ObjectShape.POLYGON;
            checkNumberOfPoints(this.shapeType, this.points);
        }

        static distance(points, x, y) {
            function position(x1, y1, x2, y2) {
                return (x2 - x1) * (y - y1) - (x - x1) * (y2 - y1);
            }

            let wn = 0;
            const distances = [];

            for (let i = 0, j = points.length - 2; i < points.length - 1; j = i, i += 2) {
                // Current point
                const x1 = points[j];
                const y1 = points[j + 1];

                // Next point
                const x2 = points[i];
                const y2 = points[i + 1];

                // Check if a point is inside a polygon
                // with a winding numbers algorithm
                // https://en.wikipedia.org/wiki/Point_in_polygon#Winding_number_algorithm
                if (y1 <= y) {
                    if (y2 > y) {
                        if (position(x1, y1, x2, y2) > 0) {
                            wn++;
                        }
                    }
                } else if (y2 <= y) {
                    if (position(x1, y1, x2, y2) < 0) {
                        wn--;
                    }
                }

                // Find the shortest distance from point to an edge
                // Get an equation of a line in general
                const aCoef = y1 - y2;
                const bCoef = x2 - x1;

                // Vector (aCoef, bCoef) is a perpendicular to line
                // Now find the point where two lines
                // (edge and its perpendicular through the point (x,y)) are cross
                const xCross = x - aCoef;
                const yCross = y - bCoef;

                if ((xCross - x1) * (x2 - xCross) >= 0 && (yCross - y1) * (y2 - yCross) >= 0) {
                    // Cross point is on segment between p1(x1,y1) and p2(x2,y2)
                    distances.push(Math.sqrt((x - xCross) ** 2 + (y - yCross) ** 2));
                } else {
                    distances.push(
                        Math.min(
                            Math.sqrt((x1 - x) ** 2 + (y1 - y) ** 2),
                            Math.sqrt((x2 - x) ** 2 + (y2 - y) ** 2),
                        ),
                    );
                }
            }

            if (wn !== 0) {
                return Math.min.apply(null, distances);
            }

            return null;
        }
    }

    class PolylineShape extends PolyShape {
        constructor(data, clientID, color, injection) {
            super(data, clientID, color, injection);
            this.shapeType = ObjectShape.POLYLINE;
            checkNumberOfPoints(this.shapeType, this.points);
        }

        static distance(points, x, y) {
            const distances = [];
            for (let i = 0; i < points.length - 2; i += 2) {
                // Current point
                const x1 = points[i];
                const y1 = points[i + 1];

                // Next point
                const x2 = points[i + 2];
                const y2 = points[i + 3];

                // Find the shortest distance from point to an edge
                if ((x - x1) * (x2 - x) >= 0 && (y - y1) * (y2 - y) >= 0) {
                    // Find the length of a perpendicular
                    // https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line
                    distances.push(
                        Math.abs((y2 - y1) * x - (x2 - x1) * y + x2 * y1 - y2 * x1) /
                            Math.sqrt((y2 - y1) ** 2 + (x2 - x1) ** 2),
                    );
                } else {
                    // The link below works for lines (which have infinite length)
                    // There is a case when perpendicular doesn't cross the edge
                    // In this case we don't use the computed distance
                    // Instead we use just distance to the nearest point
                    distances.push(
                        Math.min(
                            Math.sqrt((x1 - x) ** 2 + (y1 - y) ** 2),
                            Math.sqrt((x2 - x) ** 2 + (y2 - y) ** 2),
                        ),
                    );
                }
            }

            return Math.min.apply(null, distances);
        }
    }

    class PointsShape extends PolyShape {
        constructor(data, clientID, color, injection) {
            super(data, clientID, color, injection);
            this.shapeType = ObjectShape.POINTS;
            checkNumberOfPoints(this.shapeType, this.points);
        }

        static distance(points, x, y) {
            const distances = [];
            for (let i = 0; i < points.length; i += 2) {
                const x1 = points[i];
                const y1 = points[i + 1];

                distances.push(Math.sqrt((x1 - x) ** 2 + (y1 - y) ** 2));
            }

            return Math.min.apply(null, distances);
        }
    }

    class CuboidShape extends Shape {
        constructor(data, clientID, color, injection) {
            super(data, clientID, color, injection);
            this.rotation = 0;
            this.shapeType = ObjectShape.CUBOID;
            this.pinned = false;
            checkNumberOfPoints(this.shapeType, this.points);
        }

        static makeHull(geoPoints) {
            // Returns the convex hull, assuming that each points[i] <= points[i + 1].
            function makeHullPresorted(points) {
                if (points.length <= 1) return points.slice();

                // Andrew's monotone chain algorithm. Positive y coordinates correspond to 'up'
                // as per the mathematical convention, instead of 'down' as per the computer
                // graphics convention. This doesn't affect the correctness of the result.

                const upperHull = [];
                for (let i = 0; i < points.length; i += 1) {
                    const p = points[`${i}`];
                    while (upperHull.length >= 2) {
                        const q = upperHull[upperHull.length - 1];
                        const r = upperHull[upperHull.length - 2];
                        if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x)) upperHull.pop();
                        else break;
                    }
                    upperHull.push(p);
                }
                upperHull.pop();

                const lowerHull = [];
                for (let i = points.length - 1; i >= 0; i -= 1) {
                    const p = points[`${i}`];
                    while (lowerHull.length >= 2) {
                        const q = lowerHull[lowerHull.length - 1];
                        const r = lowerHull[lowerHull.length - 2];
                        if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x)) lowerHull.pop();
                        else break;
                    }
                    lowerHull.push(p);
                }
                lowerHull.pop();

                if (
                    upperHull.length === 1 &&
                    lowerHull.length === 1 &&
                    upperHull[0].x === lowerHull[0].x &&
                    upperHull[0].y === lowerHull[0].y
                ) return upperHull;
                return upperHull.concat(lowerHull);
            }

            function POINT_COMPARATOR(a, b) {
                if (a.x < b.x) return -1;
                if (a.x > b.x) return +1;
                if (a.y < b.y) return -1;
                if (a.y > b.y) return +1;
                return 0;
            }

            const newPoints = geoPoints.slice();
            newPoints.sort(POINT_COMPARATOR);
            return makeHullPresorted(newPoints);
        }

        static contain(shapePoints, x, y) {
            function isLeft(P0, P1, P2) {
                return (P1.x - P0.x) * (P2.y - P0.y) - (P2.x - P0.x) * (P1.y - P0.y);
            }
            const points = CuboidShape.makeHull(shapePoints);
            let wn = 0;
            for (let i = 0; i < points.length; i += 1) {
                const p1 = points[`${i}`];
                const p2 = points[i + 1] || points[0];

                if (p1.y <= y) {
                    if (p2.y > y) {
                        if (isLeft(p1, p2, { x, y }) > 0) {
                            wn += 1;
                        }
                    }
                } else if (p2.y < y) {
                    if (isLeft(p1, p2, { x, y }) < 0) {
                        wn -= 1;
                    }
                }
            }

            return wn !== 0;
        }

        static distance(actualPoints, x, y) {
            const points = [];

            for (let i = 0; i < 16; i += 2) {
                points.push({ x: actualPoints[i], y: actualPoints[i + 1] });
            }

            if (!CuboidShape.contain(points, x, y)) return null;

            let minDistance = Number.MAX_SAFE_INTEGER;
            for (let i = 0; i < points.length; i += 1) {
                const p1 = points[`${i}`];
                const p2 = points[i + 1] || points[0];

                // perpendicular from point to straight length
                const distance = Math.abs((p2.y - p1.y) * x - (p2.x - p1.x) * y + p2.x * p1.y - p2.y * p1.x) /
                    Math.sqrt((p2.y - p1.y) ** 2 + (p2.x - p1.x) ** 2);

                // check if perpendicular belongs to the straight segment
                const a = (p1.x - x) ** 2 + (p1.y - y) ** 2;
                const b = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
                const c = (p2.x - x) ** 2 + (p2.y - y) ** 2;
                if (distance < minDistance && a + b - c >= 0 && c + b - a >= 0) {
                    minDistance = distance;
                }
            }
            return minDistance;
        }
    }

    class RectangleTrack extends Track {
        constructor(data, clientID, color, injection) {
            super(data, clientID, color, injection);
            this.shapeType = ObjectShape.RECTANGLE;
            this.pinned = false;
            for (const shape of Object.values(this.shapes)) {
                checkNumberOfPoints(this.shapeType, shape.points);
            }
        }

        interpolatePosition(leftPosition, rightPosition, offset) {
            const positionOffset = leftPosition.points.map((point, index) => rightPosition.points[index] - point);
            return {
                points: leftPosition.points.map((point, index) => point + positionOffset[index] * offset),
                rotation:
                    (leftPosition.rotation + findAngleDiff(
                        rightPosition.rotation, leftPosition.rotation,
                    ) * offset + 360) % 360,
                occluded: leftPosition.occluded,
                outside: leftPosition.outside,
                zOrder: leftPosition.zOrder,
            };
        }
    }

    class EllipseTrack extends Track {
        constructor(data, clientID, color, injection) {
            super(data, clientID, color, injection);
            this.shapeType = ObjectShape.ELLIPSE;
            this.pinned = false;
            for (const shape of Object.values(this.shapes)) {
                checkNumberOfPoints(this.shapeType, shape.points);
            }
        }

        interpolatePosition(leftPosition, rightPosition, offset) {
            const positionOffset = leftPosition.points.map((point, index) => rightPosition.points[index] - point);

            return {
                points: leftPosition.points.map((point, index) => point + positionOffset[index] * offset),
                rotation:
                    (leftPosition.rotation + findAngleDiff(
                        rightPosition.rotation, leftPosition.rotation,
                    ) * offset + 360) % 360,
                occluded: leftPosition.occluded,
                outside: leftPosition.outside,
                zOrder: leftPosition.zOrder,
            };
        }
    }

    class PolyTrack extends Track {
        constructor(data, clientID, color, injection) {
            super(data, clientID, color, injection);
            for (const shape of Object.values(this.shapes)) {
                shape.rotation = 0; // is not supported
            }
        }

        interpolatePosition(leftPosition, rightPosition, offset) {
            if (offset === 0) {
                return {
                    points: [...leftPosition.points],
                    rotation: leftPosition.rotation,
                    occluded: leftPosition.occluded,
                    outside: leftPosition.outside,
                    zOrder: leftPosition.zOrder,
                };
            }

            function toArray(points) {
                return points.reduce((acc, val) => {
                    acc.push(val.x, val.y);
                    return acc;
                }, []);
            }

            function toPoints(array) {
                return array.reduce((acc, _, index) => {
                    if (index % 2) {
                        acc.push({
                            x: array[index - 1],
                            y: array[index],
                        });
                    }

                    return acc;
                }, []);
            }

            function curveLength(points) {
                return points.slice(1).reduce((acc, _, index) => {
                    const dx = points[index + 1].x - points[index].x;
                    const dy = points[index + 1].y - points[index].y;
                    return acc + Math.sqrt(dx ** 2 + dy ** 2);
                }, 0);
            }

            function curveToOffsetVec(points, length) {
                const offsetVector = [0]; // with initial value
                let accumulatedLength = 0;

                points.slice(1).forEach((_, index) => {
                    const dx = points[index + 1].x - points[index].x;
                    const dy = points[index + 1].y - points[index].y;
                    accumulatedLength += Math.sqrt(dx ** 2 + dy ** 2);
                    offsetVector.push(accumulatedLength / length);
                });

                return offsetVector;
            }

            function findNearestPair(value, curve) {
                let minimum = [0, Math.abs(value - curve[0])];
                for (let i = 1; i < curve.length; i++) {
                    const distance = Math.abs(value - curve[i]);
                    if (distance < minimum[1]) {
                        minimum = [i, distance];
                    }
                }

                return minimum[0];
            }

            function matchLeftRight(leftCurve, rightCurve) {
                const matching = {};
                for (let i = 0; i < leftCurve.length; i++) {
                    matching[i] = [findNearestPair(leftCurve[i], rightCurve)];
                }

                return matching;
            }

            function matchRightLeft(leftCurve, rightCurve, leftRightMatching) {
                const matchedRightPoints = Object.values(leftRightMatching).flat();
                const unmatchedRightPoints = rightCurve
                    .map((_, index) => index)
                    .filter((index) => !matchedRightPoints.includes(index));
                const updatedMatching = { ...leftRightMatching };

                for (const rightPoint of unmatchedRightPoints) {
                    const leftPoint = findNearestPair(rightCurve[rightPoint], leftCurve);
                    updatedMatching[leftPoint].push(rightPoint);
                }

                for (const key of Object.keys(updatedMatching)) {
                    const sortedRightIndexes = updatedMatching[key].sort((a, b) => a - b);
                    updatedMatching[key] = sortedRightIndexes;
                }

                return updatedMatching;
            }

            function reduceInterpolation(interpolatedPoints, matching, leftPoints, rightPoints) {
                function averagePoint(points) {
                    let sumX = 0;
                    let sumY = 0;
                    for (const point of points) {
                        sumX += point.x;
                        sumY += point.y;
                    }

                    return {
                        x: sumX / points.length,
                        y: sumY / points.length,
                    };
                }

                function computeDistance(point1, point2) {
                    return Math.sqrt((point1.x - point2.x) ** 2 + (point1.y - point2.y) ** 2);
                }

                function minimizeSegment(baseLength, N, startInterpolated, stopInterpolated) {
                    const threshold = baseLength / (2 * N);
                    const minimized = [interpolatedPoints[startInterpolated]];
                    let latestPushed = startInterpolated;
                    for (let i = startInterpolated + 1; i < stopInterpolated; i++) {
                        const distance = computeDistance(interpolatedPoints[latestPushed], interpolatedPoints[i]);

                        if (distance >= threshold) {
                            minimized.push(interpolatedPoints[i]);
                            latestPushed = i;
                        }
                    }

                    minimized.push(interpolatedPoints[stopInterpolated]);

                    if (minimized.length === 2) {
                        const distance = computeDistance(
                            interpolatedPoints[startInterpolated],
                            interpolatedPoints[stopInterpolated],
                        );

                        if (distance < threshold) {
                            return [averagePoint(minimized)];
                        }
                    }

                    return minimized;
                }

                const reduced = [];
                const interpolatedIndexes = {};
                let accumulated = 0;
                for (let i = 0; i < leftPoints.length; i++) {
                    // eslint-disable-next-line
                    interpolatedIndexes[i] = matching[i].map(() => accumulated++);
                }

                function leftSegment(start, stop) {
                    const startInterpolated = interpolatedIndexes[start][0];
                    const stopInterpolated = interpolatedIndexes[stop][0];

                    if (startInterpolated === stopInterpolated) {
                        reduced.push(interpolatedPoints[startInterpolated]);
                        return;
                    }

                    const baseLength = curveLength(leftPoints.slice(start, stop + 1));
                    const N = stop - start + 1;

                    reduced.push(...minimizeSegment(baseLength, N, startInterpolated, stopInterpolated));
                }

                function rightSegment(leftPoint) {
                    const start = matching[leftPoint][0];
                    const [stop] = matching[leftPoint].slice(-1);
                    const startInterpolated = interpolatedIndexes[leftPoint][0];
                    const [stopInterpolated] = interpolatedIndexes[leftPoint].slice(-1);
                    const baseLength = curveLength(rightPoints.slice(start, stop + 1));
                    const N = stop - start + 1;

                    reduced.push(...minimizeSegment(baseLength, N, startInterpolated, stopInterpolated));
                }

                let previousOpened = null;
                for (let i = 0; i < leftPoints.length; i++) {
                    if (matching[i].length === 1) {
                        // check if left segment is opened
                        if (previousOpened !== null) {
                            // check if we should continue the left segment
                            if (matching[i][0] === matching[previousOpened][0]) {
                                continue;
                            } else {
                                // left segment found
                                const start = previousOpened;
                                const stop = i - 1;
                                leftSegment(start, stop);

                                // start next left segment
                                previousOpened = i;
                            }
                        } else {
                            // start next left segment
                            previousOpened = i;
                        }
                    } else {
                        // check if left segment is opened
                        if (previousOpened !== null) {
                            // left segment found
                            const start = previousOpened;
                            const stop = i - 1;
                            leftSegment(start, stop);

                            previousOpened = null;
                        }

                        // right segment found
                        rightSegment(i);
                    }
                }

                // check if there is an opened segment
                if (previousOpened !== null) {
                    leftSegment(previousOpened, leftPoints.length - 1);
                }

                return reduced;
            }

            // the algorithm below is based on fact that both left and right
            // polyshapes have the same start point and the same draw direction
            const leftPoints = toPoints(leftPosition.points);
            const rightPoints = toPoints(rightPosition.points);
            const leftOffsetVec = curveToOffsetVec(leftPoints, curveLength(leftPoints));
            const rightOffsetVec = curveToOffsetVec(rightPoints, curveLength(rightPoints));

            const matching = matchLeftRight(leftOffsetVec, rightOffsetVec);
            const completedMatching = matchRightLeft(leftOffsetVec, rightOffsetVec, matching);

            const interpolatedPoints = Object.keys(completedMatching)
                .map((leftPointIdx) => +leftPointIdx)
                .sort((a, b) => a - b)
                .reduce((acc, leftPointIdx) => {
                    const leftPoint = leftPoints[leftPointIdx];
                    for (const rightPointIdx of completedMatching[leftPointIdx]) {
                        const rightPoint = rightPoints[rightPointIdx];
                        acc.push({
                            x: leftPoint.x + (rightPoint.x - leftPoint.x) * offset,
                            y: leftPoint.y + (rightPoint.y - leftPoint.y) * offset,
                        });
                    }

                    return acc;
                }, []);

            const reducedPoints = reduceInterpolation(interpolatedPoints, completedMatching, leftPoints, rightPoints);

            return {
                points: toArray(reducedPoints),
                rotation: leftPosition.rotation,
                occluded: leftPosition.occluded,
                outside: leftPosition.outside,
                zOrder: leftPosition.zOrder,
            };
        }
    }

    class PolygonTrack extends PolyTrack {
        constructor(data, clientID, color, injection) {
            super(data, clientID, color, injection);
            this.shapeType = ObjectShape.POLYGON;
            for (const shape of Object.values(this.shapes)) {
                checkNumberOfPoints(this.shapeType, shape.points);
            }
        }

        interpolatePosition(leftPosition, rightPosition, offset) {
            const copyLeft = {
                ...leftPosition,
                points: [...leftPosition.points, leftPosition.points[0], leftPosition.points[1]],
            };

            const copyRight = {
                ...rightPosition,
                points: [...rightPosition.points, rightPosition.points[0], rightPosition.points[1]],
            };

            const result = PolyTrack.prototype.interpolatePosition.call(this, copyLeft, copyRight, offset);

            return {
                ...result,
                points: result.points.slice(0, -2),
            };
        }
    }

    class PolylineTrack extends PolyTrack {
        constructor(data, clientID, color, injection) {
            super(data, clientID, color, injection);
            this.shapeType = ObjectShape.POLYLINE;
            for (const shape of Object.values(this.shapes)) {
                checkNumberOfPoints(this.shapeType, shape.points);
            }
        }
    }

    class PointsTrack extends PolyTrack {
        constructor(data, clientID, color, injection) {
            super(data, clientID, color, injection);
            this.shapeType = ObjectShape.POINTS;
            for (const shape of Object.values(this.shapes)) {
                checkNumberOfPoints(this.shapeType, shape.points);
            }
        }

        interpolatePosition(leftPosition, rightPosition, offset) {
            // interpolate only when one point in both left and right positions
            if (leftPosition.points.length === 2 && rightPosition.points.length === 2) {
                return {
                    points: leftPosition.points.map(
                        (value, index) => value + (rightPosition.points[index] - value) * offset,
                    ),
                    rotation: leftPosition.rotation,
                    occluded: leftPosition.occluded,
                    outside: leftPosition.outside,
                    zOrder: leftPosition.zOrder,
                };
            }

            return {
                points: [...leftPosition.points],
                rotation: leftPosition.rotation,
                occluded: leftPosition.occluded,
                outside: leftPosition.outside,
                zOrder: leftPosition.zOrder,
            };
        }
    }

    class CuboidTrack extends Track {
        constructor(data, clientID, color, injection) {
            super(data, clientID, color, injection);
            this.shapeType = ObjectShape.CUBOID;
            this.pinned = false;
            for (const shape of Object.values(this.shapes)) {
                checkNumberOfPoints(this.shapeType, shape.points);
                shape.rotation = 0; // is not supported
            }
        }

        interpolatePosition(leftPosition, rightPosition, offset) {
            const positionOffset = leftPosition.points.map((point, index) => rightPosition.points[index] - point);

            return {
                points: leftPosition.points.map((point, index) => point + positionOffset[index] * offset),
                rotation: leftPosition.rotation,
                occluded: leftPosition.occluded,
                outside: leftPosition.outside,
                zOrder: leftPosition.zOrder,
            };
        }
    }

    RectangleTrack.distance = RectangleShape.distance;
    PolygonTrack.distance = PolygonShape.distance;
    PolylineTrack.distance = PolylineShape.distance;
    PointsTrack.distance = PointsShape.distance;
    EllipseTrack.distance = EllipseShape.distance;
    CuboidTrack.distance = CuboidShape.distance;

    module.exports = {
        RectangleShape,
        PolygonShape,
        PolylineShape,
        PointsShape,
        EllipseShape,
        CuboidShape,
        RectangleTrack,
        PolygonTrack,
        PolylineTrack,
        PointsTrack,
        EllipseTrack,
        CuboidTrack,
        Track,
        Shape,
        Tag,
        objectStateFactory,
    };
})();
