import { ramerDouglasPeucker } from "./simplify";
import { Coordinates, GPXFileAttributes, GPXFileType, LineStyleExtension, Link, Metadata, TrackExtensions, TrackPointExtensions, TrackPointType, TrackSegmentType, TrackType, WaypointType } from "./types";
import { immerable, isDraft, original, freeze } from "immer";

function cloneJSON<T>(obj: T): T {
    if (obj === null || typeof obj !== 'object') {
        return null;
    }
    return JSON.parse(JSON.stringify(obj));
}

// An abstract class that groups functions that need to be computed recursively in the GPX file hierarchy
export abstract class GPXTreeElement<T extends GPXTreeElement<any>> {
    _data: { [key: string]: any } = {};

    abstract isLeaf(): boolean;
    abstract get children(): ReadonlyArray<T>;

    abstract getNumberOfTrackPoints(): number;
    abstract getStartTimestamp(): Date | undefined;
    abstract getEndTimestamp(): Date | undefined;
    abstract getStatistics(): GPXStatistics;
    abstract getSegments(): TrackSegment[];

    abstract toGeoJSON(): GeoJSON.Feature | GeoJSON.Feature[] | GeoJSON.FeatureCollection | GeoJSON.FeatureCollection[];

    // Producers
    abstract _reverse(originalNextTimestamp?: Date, newPreviousTimestamp?: Date);
}

export type AnyGPXTreeElement = GPXTreeElement<GPXTreeElement<any>>;

// An abstract class that can be extended to facilitate functions working similarly with Tracks and TrackSegments
abstract class GPXTreeNode<T extends GPXTreeElement<any>> extends GPXTreeElement<T> {
    isLeaf(): boolean {
        return false;
    }

    getNumberOfTrackPoints(): number {
        return this.children.reduce((acc, child) => acc + child.getNumberOfTrackPoints(), 0);
    }

    getStartTimestamp(): Date | undefined {
        if (this.children.length === 0) {
            return undefined;
        }
        return this.children[0].getStartTimestamp();
    }

    getEndTimestamp(): Date | undefined {
        if (this.children.length === 0) {
            return undefined;
        }
        return this.children[this.children.length - 1].getEndTimestamp();
    }

    getStatistics(): GPXStatistics {
        let statistics = new GPXStatistics();
        for (let child of this.children) {
            statistics.mergeWith(child.getStatistics());
        }
        return statistics;
    }

    getSegments(): TrackSegment[] {
        return this.children.flatMap((child) => child.getSegments());
    }

    // Producers
    _reverse(originalNextTimestamp?: Date, newPreviousTimestamp?: Date) {
        let og = getOriginal(this);
        if (!originalNextTimestamp && !newPreviousTimestamp) {
            originalNextTimestamp = og.getEndTimestamp();
            newPreviousTimestamp = og.getStartTimestamp();
        }

        let children = og.children.slice();
        children.reverse();

        for (let i = 0; i < og.children.length; i++) {
            let originalStartTimestamp = og.children[og.children.length - i - 1].getStartTimestamp();

            children[i]._reverse(originalNextTimestamp, newPreviousTimestamp);

            originalNextTimestamp = originalStartTimestamp;
            newPreviousTimestamp = children[i].getEndTimestamp();
        }

        if (this instanceof GPXFile) {
            // @ts-ignore
            this.trk = freeze(children);
        } else if (this instanceof Track) {
            // @ts-ignore
            this.trkseg = freeze(children);
        }
    }
}

// An abstract class that TrackSegment extends to implement the GPXTreeElement interface
abstract class GPXTreeLeaf extends GPXTreeElement<GPXTreeLeaf> {
    isLeaf(): boolean {
        return true;
    }

    get children(): ReadonlyArray<GPXTreeLeaf> {
        return [];
    }
}

// A class that represents a GPX file
export class GPXFile extends GPXTreeNode<Track>{
    [immerable] = true;

    attributes: GPXFileAttributes;
    metadata: Metadata;
    wpt: Waypoint[];
    trk: Track[];

    constructor(gpx?: GPXFileType & { _data?: any } | GPXFile) {
        super();
        if (gpx) {
            this.attributes = gpx.attributes
            this.metadata = gpx.metadata;
            this.wpt = gpx.wpt ? gpx.wpt.map((waypoint) => new Waypoint(waypoint)) : [];
            this.trk = gpx.trk ? gpx.trk.map((track) => new Track(track)) : [];
            if (gpx.hasOwnProperty('_data')) {
                this._data = gpx._data;
            }
        } else {
            this.attributes = {};
            this.metadata = {};
            this.wpt = [];
            this.trk = [new Track()];
        }

        this.trk.forEach((track, trackIndex) => {
            track._data['trackIndex'] = trackIndex;
            track.trkseg.forEach((segment, segmentIndex) => {
                segment._data['trackIndex'] = trackIndex;
                segment._data['segmentIndex'] = segmentIndex;
            });
        });
        this.wpt.forEach((waypoint, waypointIndex) => {
            waypoint._data['index'] = waypointIndex;
        });
    }

    get children(): ReadonlyArray<Track> {
        return this.trk;
    }

    getSegment(trackIndex: number, segmentIndex: number): TrackSegment {
        return this.trk[trackIndex].children[segmentIndex];
    }

    forEachSegment(callback: (segment: TrackSegment, trackIndex: number, segmentIndex: number) => void) {
        this.trk.forEach((track, trackIndex) => {
            track.children.forEach((segment, segmentIndex) => {
                callback(segment, trackIndex, segmentIndex);
            });
        });
    }

    getStyle(): MergedLineStyles {
        return this.trk.map((track) => track.getStyle()).reduce((acc, style) => {
            if (style) {
                if (style.color && !acc.color.includes(style.color)) {
                    acc.color.push(style.color);
                }
                if (style.opacity && !acc.opacity.includes(style.opacity)) {
                    acc.opacity.push(style.opacity);
                }
                if (style.weight && !acc.weight.includes(style.weight)) {
                    acc.weight.push(style.weight);
                }
            }
            return acc;
        }, {
            color: [],
            opacity: [],
            weight: []
        });
    }

    clone(): GPXFile {
        return new GPXFile({
            attributes: cloneJSON(this.attributes),
            metadata: cloneJSON(this.metadata),
            wpt: this.wpt.map((waypoint) => waypoint.clone()),
            trk: this.trk.map((track) => track.clone()),
            _data: cloneJSON(this._data),
        });
    }

    toGeoJSON(): GeoJSON.FeatureCollection {
        return {
            type: "FeatureCollection",
            features: this.children.flatMap((child) => child.toGeoJSON())
        };
    }

    toGPXFileType(): GPXFileType {
        return {
            attributes: cloneJSON(this.attributes),
            metadata: cloneJSON(this.metadata),
            wpt: this.wpt,
            trk: this.trk.map((track) => track.toTrackType())
        };
    }

    // Producers
    replaceTracks(start: number, end: number, tracks: Track[]) {
        if (this._data.style) {
            tracks.forEach((track) => track.setStyle(this._data.style, false));
        }
        this.trk.splice(start, end - start + 1, ...tracks);
    }

    replaceTrackSegments(trackIndex: number, start: number, end: number, segments: TrackSegment[]) {
        this.trk[trackIndex].replaceTrackSegments(start, end, segments);
    }

    replaceTrackPoints(trackIndex: number, segmentIndex: number, start: number, end: number, points: TrackPoint[], speed?: number, startTime?: Date) {
        this.trk[trackIndex].replaceTrackPoints(segmentIndex, start, end, points, speed, startTime);
    }

    replaceWaypoints(start: number, end: number, waypoints: Waypoint[]) {
        this.wpt.splice(start, end - start + 1, ...waypoints);
    }

    reverse() {
        return this._reverse(this.getEndTimestamp(), this.getStartTimestamp());
    }

    reverseTrack(trackIndex: number) {
        this.trk[trackIndex]._reverse();
    }

    reverseTrackSegment(trackIndex: number, segmentIndex: number) {
        this.trk[trackIndex].reverseTrackSegment(segmentIndex);
    }

    crop(start: number, end: number, trackIndices?: number[], segmentIndices?: number[]) {
        let i = 0;
        let trackIndex = 0;
        while (i < this.trk.length) {
            let length = this.trk[i].getNumberOfTrackPoints();
            if (trackIndices === undefined || trackIndices.includes(trackIndex)) {
                if (start >= length || end < 0) {
                    this.trk.splice(i, 1);
                } else {
                    if (start > 0 || end < length - 1) {
                        this.trk[i].crop(Math.max(0, start), Math.min(length - 1, end), segmentIndices);
                    }
                    i++;
                }
                start -= length;
                end -= length;
            } else {
                i++;
            }
            trackIndex++;
        }
    }

    clean(bounds: [Coordinates, Coordinates], inside: boolean, deleteTrackPoints: boolean, deleteWaypoints: boolean, trackIndices?: number[], segmentIndices?: number[], waypointIndices?: number[]) {
        if (deleteTrackPoints) {
            let i = 0;
            let trackIndex = 0;
            while (i < this.trk.length) {
                if (trackIndices === undefined || trackIndices.includes(trackIndex)) {
                    this.trk[i].clean(bounds, inside, segmentIndices);
                    if (this.trk[i].getNumberOfTrackPoints() === 0) {
                        this.trk.splice(i, 1);
                    } else {
                        i++;
                    }
                } else {
                    i++;
                }
                trackIndex++;
            }
        }
        if (deleteWaypoints) {
            let og = getOriginal(this); // Read as much as possible from the original object because it is faster
            let wpt = og.wpt.filter((point, waypointIndex) => {
                if (waypointIndices === undefined || waypointIndices.includes(waypointIndex)) {
                    let inBounds = point.attributes.lat >= bounds[0].lat && point.attributes.lat <= bounds[1].lat && point.attributes.lon >= bounds[0].lon && point.attributes.lon <= bounds[1].lon;
                    return inBounds !== inside;
                } else {
                    return true;
                }
            });
            this.wpt = freeze(wpt); // Pre-freeze the array, faster as well
        }
    }

    changeTimestamps(startTime: Date, speed: number, ratio: number, trackIndex?: number, segmentIndex?: number) {
        let lastPoint = undefined;
        this.trk.forEach((track, index) => {
            if (trackIndex === undefined || trackIndex === index) {
                track.changeTimestamps(startTime, speed, ratio, lastPoint, segmentIndex);
            }
        });
    }

    setStyle(style: LineStyleExtension) {
        this.trk.forEach((track) => {
            track.setStyle(style);
        });
        if (!this._data.style) {
            this._data.style = {};
        }
        if (style.color) {
            this._data.style.color = style.color;
        }
        if (style.opacity) {
            this._data.style.opacity = style.opacity;
        }
        if (style.weight) {
            this._data.style.weight = style.weight;
        }
    }

    setHidden(hidden: boolean, trackIndices?: number[], segmentIndices?: number[]) {
        let allHidden = hidden;
        this.trk.forEach((track, index) => {
            if (trackIndices === undefined || trackIndices.includes(index)) {
                track.setHidden(hidden, segmentIndices);
            } else {
                allHidden = allHidden && (track._data.hidden === true);
            }
        });
        this.wpt.forEach((waypoint) => {
            if (trackIndices === undefined && segmentIndices === undefined) {
                waypoint.setHidden(hidden);
            } else {
                allHidden = allHidden && (waypoint._data.hidden === true);
            }
        });

        if (trackIndices === undefined && segmentIndices === undefined) {
            this._data.hiddenWpt = hidden;
        }

        this._data.hidden = allHidden;
    }

    setHiddenWaypoints(hidden: boolean, waypointIndices?: number[]) {
        let allHiddenWpt = hidden;
        this.wpt.forEach((waypoint, index) => {
            if (waypointIndices === undefined || waypointIndices.includes(index)) {
                waypoint.setHidden(hidden);
            } else {
                allHiddenWpt = allHiddenWpt && (waypoint._data.hidden === true);
            }
        });

        let allHiddenTrk = true;
        this.trk.forEach((track) => {
            allHiddenTrk = allHiddenTrk && (track._data.hidden === true);
        });

        this._data.hiddenWpt = allHiddenWpt;
        this._data.hidden = allHiddenTrk && allHiddenWpt;
    }
};

// A class that represents a Track in a GPX file
export class Track extends GPXTreeNode<TrackSegment> {
    [immerable] = true;

    name?: string;
    cmt?: string;
    desc?: string;
    src?: string;
    link?: Link;
    type?: string;
    trkseg: TrackSegment[];
    extensions?: TrackExtensions;

    constructor(track?: TrackType & { _data?: any } | Track) {
        super();
        if (track) {
            this.name = track.name;
            this.cmt = track.cmt;
            this.desc = track.desc;
            this.src = track.src;
            this.link = track.link;
            this.type = track.type;
            this.trkseg = track.trkseg ? track.trkseg.map((seg) => new TrackSegment(seg)) : [];
            this.extensions = track.extensions;
            if (track.hasOwnProperty('_data')) {
                this._data = track._data;
            }
        } else {
            this.trkseg = [new TrackSegment()];
        }
    }

    get children(): ReadonlyArray<TrackSegment> {
        return this.trkseg;
    }

    clone(): Track {
        return new Track({
            name: this.name,
            cmt: this.cmt,
            desc: this.desc,
            src: this.src,
            link: cloneJSON(this.link),
            type: this.type,
            trkseg: this.trkseg.map((seg) => seg.clone()),
            extensions: cloneJSON(this.extensions),
            _data: cloneJSON(this._data),
        });
    }

    getStyle(): LineStyleExtension | undefined {
        return this.extensions && this.extensions['gpx_style:line'];
    }

    toGeoJSON(): GeoJSON.Feature[] {
        return this.children.map((child) => {
            let geoJSON = child.toGeoJSON();
            if (this.extensions && this.extensions['gpx_style:line']) {
                if (this.extensions['gpx_style:line'].color) {
                    geoJSON.properties['color'] = this.extensions['gpx_style:line'].color;
                }
                if (this.extensions['gpx_style:line'].opacity) {
                    geoJSON.properties['opacity'] = this.extensions['gpx_style:line'].opacity;
                }
                if (this.extensions['gpx_style:line'].weight) {
                    geoJSON.properties['weight'] = this.extensions['gpx_style:line'].weight;
                }
            }
            return geoJSON;
        });
    }

    toTrackType(): TrackType {
        return {
            name: this.name,
            cmt: this.cmt,
            desc: this.desc,
            src: this.src,
            link: this.link,
            type: this.type,
            trkseg: this.trkseg.map((seg) => seg.toTrackSegmentType()),
            extensions: this.extensions,
        };
    }

    // Producers
    replaceTrackSegments(start: number, end: number, segments: TrackSegment[]) {
        this.trkseg.splice(start, end - start + 1, ...segments);
    }

    replaceTrackPoints(segmentIndex: number, start: number, end: number, points: TrackPoint[], speed?: number, startTime?: Date) {
        this.trkseg[segmentIndex].replaceTrackPoints(start, end, points, speed, startTime);
    }

    reverseTrackSegment(segmentIndex: number) {
        this.trkseg[segmentIndex]._reverse(this.trkseg[segmentIndex].getEndTimestamp(), this.trkseg[segmentIndex].getStartTimestamp());
    }

    crop(start: number, end: number, segmentIndices?: number[]) {
        let i = 0;
        let segmentIndex = 0;
        while (i < this.trkseg.length) {
            let length = this.trkseg[i].getNumberOfTrackPoints();
            if (segmentIndices === undefined || segmentIndices.includes(segmentIndex)) {
                if (start >= length || end < 0) {
                    this.trkseg.splice(i, 1);
                } else {
                    if (start > 0 || end < length - 1) {
                        this.trkseg[i].crop(Math.max(0, start), Math.min(length - 1, end));
                    }
                    i++;
                }
                start -= length;
                end -= length;
            } else {
                i++;
            }
            segmentIndex++;
        }
    }

    clean(bounds: [Coordinates, Coordinates], inside: boolean, segmentIndices?: number[]) {
        let i = 0;
        let segmentIndex = 0;
        while (i < this.trkseg.length) {
            if (segmentIndices === undefined || segmentIndices.includes(segmentIndex)) {
                this.trkseg[i].clean(bounds, inside);
                if (this.trkseg[i].getNumberOfTrackPoints() === 0) {
                    this.trkseg.splice(i, 1);
                } else {
                    i++;
                }
            } else {
                i++;
            }
            segmentIndex++;
        }
    }

    changeTimestamps(startTime: Date, speed: number, ratio: number, lastPoint?: TrackPoint, segmentIndex?: number) {
        this.trkseg.forEach((segment, index) => {
            if (segmentIndex === undefined || segmentIndex === index) {
                segment.changeTimestamps(startTime, speed, ratio, lastPoint);
                if (segment.trkpt.length > 0) {
                    lastPoint = segment.trkpt[segment.trkpt.length - 1];
                }
            }
        });
    }

    setStyle(style: LineStyleExtension, force: boolean = true) {
        if (!this.extensions) {
            this.extensions = {};
        }
        if (!this.extensions['gpx_style:line']) {
            this.extensions['gpx_style:line'] = {};
        }
        if (style.color !== undefined && (force || this.extensions['gpx_style:line'].color === undefined)) {
            this.extensions['gpx_style:line'].color = style.color;
        }
        if (style.opacity !== undefined && (force || this.extensions['gpx_style:line'].opacity === undefined)) {
            this.extensions['gpx_style:line'].opacity = style.opacity;
        }
        if (style.weight !== undefined && (force || this.extensions['gpx_style:line'].weight === undefined)) {
            this.extensions['gpx_style:line'].weight = style.weight;
        }
    }

    setHidden(hidden: boolean, segmentIndices?: number[]) {
        let allHidden = hidden;
        this.trkseg.forEach((segment, index) => {
            if (segmentIndices === undefined || segmentIndices.includes(index)) {
                segment.setHidden(hidden);
            } else {
                allHidden = allHidden && (segment._data.hidden === true);
            }
        });
        this._data.hidden = allHidden;
    }
}

// A class that represents a TrackSegment in a GPX file
export class TrackSegment extends GPXTreeLeaf {
    [immerable] = true;

    trkpt: TrackPoint[];

    constructor(segment?: TrackSegmentType & { _data?: any } | TrackSegment) {
        super();
        if (segment) {
            this.trkpt = segment.trkpt.map((point) => new TrackPoint(point));
            if (segment.hasOwnProperty('_data')) {
                this._data = segment._data;
            }
        } else {
            this.trkpt = [];
        }
    }

    _computeStatistics(): GPXStatistics {
        let statistics = new GPXStatistics();

        statistics.local.points = this.trkpt.map((point) => point);

        statistics.local.elevation.smoothed = this._computeSmoothedElevation();
        statistics.local.slope.at = this._computeSlope();

        const points = this.trkpt;
        for (let i = 0; i < points.length; i++) {
            points[i]._data['index'] = i;

            // distance
            let dist = 0;
            if (i > 0) {
                dist = distance(points[i - 1].getCoordinates(), points[i].getCoordinates()) / 1000;

                statistics.global.distance.total += dist;
            }

            statistics.local.distance.total.push(statistics.global.distance.total);

            // elevation
            if (i > 0) {
                const ele = statistics.local.elevation.smoothed[i] - statistics.local.elevation.smoothed[i - 1];
                if (ele > 0) {
                    statistics.global.elevation.gain += ele;
                } else if (ele < 0) {
                    statistics.global.elevation.loss -= ele;
                }
            }

            statistics.local.elevation.gain.push(statistics.global.elevation.gain);
            statistics.local.elevation.loss.push(statistics.global.elevation.loss);

            // time
            if (points[i].time === undefined) {
                statistics.local.time.total.push(0);
            } else {
                if (statistics.global.time.start === undefined) {
                    statistics.global.time.start = points[i].time;
                }
                statistics.global.time.end = points[i].time;
                statistics.local.time.total.push((points[i].time.getTime() - statistics.global.time.start.getTime()) / 1000);
            }

            // speed
            let speed = 0;
            if (i > 0 && points[i - 1].time !== undefined && points[i].time !== undefined) {
                const time = (points[i].time.getTime() - points[i - 1].time.getTime()) / 1000;
                speed = dist / (time / 3600);

                if (speed >= 0.5) {
                    statistics.global.distance.moving += dist;
                    statistics.global.time.moving += time;
                }
            }

            statistics.local.distance.moving.push(statistics.global.distance.moving);
            statistics.local.time.moving.push(statistics.global.time.moving);

            // bounds
            statistics.global.bounds.southWest.lat = Math.min(statistics.global.bounds.southWest.lat, points[i].attributes.lat);
            statistics.global.bounds.southWest.lon = Math.min(statistics.global.bounds.southWest.lon, points[i].attributes.lon);
            statistics.global.bounds.northEast.lat = Math.max(statistics.global.bounds.northEast.lat, points[i].attributes.lat);
            statistics.global.bounds.northEast.lon = Math.max(statistics.global.bounds.northEast.lon, points[i].attributes.lon);
        }

        [statistics.local.slope.segment, statistics.local.slope.length] = this._computeSlopeSegments(statistics);

        statistics.global.time.total = statistics.global.time.start && statistics.global.time.end ? (statistics.global.time.end.getTime() - statistics.global.time.start.getTime()) / 1000 : 0;
        statistics.global.speed.total = statistics.global.time.total > 0 ? statistics.global.distance.total / (statistics.global.time.total / 3600) : 0;
        statistics.global.speed.moving = statistics.global.time.moving > 0 ? statistics.global.distance.moving / (statistics.global.time.moving / 3600) : 0;

        statistics.local.speed = distanceWindowSmoothingWithDistanceAccumulator(points, 200, (accumulated, start, end) => (points[start].time && points[end].time) ? 3600 * accumulated / (points[end].time.getTime() - points[start].time.getTime()) : undefined);

        return statistics;
    }

    _computeSmoothedElevation(): number[] {
        const points = this.trkpt;

        let smoothed = distanceWindowSmoothing(points, 100, (index) => points[index].ele, (accumulated, start, end) => accumulated / (end - start + 1));

        if (points.length > 0) {
            smoothed[0] = points[0].ele;
            smoothed[points.length - 1] = points[points.length - 1].ele;
        }

        return smoothed;
    }

    _computeSlope(): number[] {
        const points = this.trkpt;

        return distanceWindowSmoothingWithDistanceAccumulator(points, 50, (accumulated, start, end) => 100 * ((points[end].ele ?? 0) - (points[start].ele ?? 0)) / (accumulated > 0 ? accumulated : 1));
    }

    _computeSlopeSegments(statistics: GPXStatistics): [number[], number[]] {
        // x-coordinates are given by: statistics.local.distance.total[point._data.index] * 1000
        // y-coordinates are given by: point.ele
        // Compute the distance between point3 and the line defined by point1 and point2
        function elevationDistance(point1: TrackPoint, point2: TrackPoint, point3: TrackPoint): number {
            if (point1.ele === undefined || point2.ele === undefined || point3.ele === undefined) {
                return 0;
            }
            let x1 = statistics.local.distance.total[point1._data.index] * 1000;
            let x2 = statistics.local.distance.total[point2._data.index] * 1000;
            let x3 = statistics.local.distance.total[point3._data.index] * 1000;
            let y1 = point1.ele;
            let y2 = point2.ele;
            let y3 = point3.ele;

            let dist = Math.sqrt(Math.pow(y2 - y1, 2) + Math.pow(x2 - x1, 2));
            if (dist === 0) {
                return Math.sqrt(Math.pow(x3 - x1, 2) + Math.pow(y3 - y1, 2));
            }

            return Math.abs((y2 - y1) * x3 - (x2 - x1) * y3 + x2 * y1 - y2 * x1) / dist;
        }

        let simplified = ramerDouglasPeucker(this.trkpt, 20, elevationDistance);

        let slope = [];
        let length = [];

        for (let i = 0; i < simplified.length - 1; i++) {
            let start = simplified[i].point._data.index;
            let end = simplified[i + 1].point._data.index;
            let dist = statistics.local.distance.total[end] - statistics.local.distance.total[start];
            let ele = simplified[i + 1].point.ele - simplified[i].point.ele;

            for (let j = start; j < end + (i + 1 === simplified.length - 1 ? 1 : 0); j++) {
                slope.push(0.1 * ele / dist);
                length.push(dist);
            }
        }

        return [slope, length];
    }

    getNumberOfTrackPoints(): number {
        return this.trkpt.length;
    }

    getStartTimestamp(): Date | undefined {
        if (this.trkpt.length === 0) {
            return undefined;
        }
        return this.trkpt[0].time;
    }

    getEndTimestamp(): Date | undefined {
        if (this.trkpt.length === 0) {
            return undefined;
        }
        return this.trkpt[this.trkpt.length - 1].time;
    }

    getStatistics(): GPXStatistics {
        return this._computeStatistics();
    }

    getSegments(): TrackSegment[] {
        return [this];
    }

    toGeoJSON(): GeoJSON.Feature {
        return {
            type: "Feature",
            geometry: {
                type: "LineString",
                coordinates: this.trkpt.map((point) => [point.attributes.lon, point.attributes.lat])
            },
            properties: {}
        };
    }

    toTrackSegmentType(): TrackSegmentType {
        return {
            trkpt: this.trkpt.map((point) => point.toTrackPointType())
        };
    }

    clone(): TrackSegment {
        return new TrackSegment({
            trkpt: this.trkpt.map((point) => point.clone()),
            _data: cloneJSON(this._data),
        });
    }

    // Producers
    replaceTrackPoints(start: number, end: number, points: TrackPoint[], speed?: number, startTime?: Date) {
        let og = getOriginal(this); // Read as much as possible from the original object because it is faster
        let trkpt = og.trkpt.slice();

        if (speed !== undefined || (trkpt.length > 0 && trkpt[0].time !== undefined)) {
            if (start > 0 && trkpt[0].time === undefined) {
                trkpt.splice(0, 0, ...withTimestamps(trkpt.splice(0, start), speed, undefined, startTime));
            }
            if (points.length > 0) {
                let last = start > 0 ? trkpt[start - 1] : undefined;
                if (points[0].time === undefined || (points.length > 1 && points[1].time === undefined)) {
                    points = withTimestamps(points, speed, last, startTime);
                } else if (last !== undefined && points[0].time < last.time) {
                    points = withShiftedAndCompressedTimestamps(points, speed, 1, last);
                }
            }
            if (end < trkpt.length - 1) {
                let last = points.length > 0 ? points[points.length - 1] : start > 0 ? trkpt[start - 1] : undefined;
                if (trkpt[end + 1].time === undefined) {
                    trkpt.splice(end + 1, 0, ...withTimestamps(trkpt.splice(end + 1), speed, last, startTime));
                } else if (last !== undefined && trkpt[end + 1].time < last.time) {
                    points = withShiftedAndCompressedTimestamps(points, speed, 1, last);
                }
            }
        }

        trkpt.splice(start, end - start + 1, ...points);
        this.trkpt = freeze(trkpt); // Pre-freeze the array, faster as well
    }

    _reverse(originalNextTimestamp?: Date, newPreviousTimestamp?: Date) {
        let og = getOriginal(this); // Read as much as possible from the original object because it is faster
        let originalStartTimestamp = og.getStartTimestamp();
        let originalEndTimestamp = og.getEndTimestamp();
        if (!newPreviousTimestamp) {
            newPreviousTimestamp = originalStartTimestamp;
        }
        if (newPreviousTimestamp && originalEndTimestamp && !originalNextTimestamp) {
            originalNextTimestamp = new Date(newPreviousTimestamp.getTime() + originalEndTimestamp.getTime() - originalStartTimestamp.getTime());
        }
        if (originalNextTimestamp !== undefined && newPreviousTimestamp !== undefined && originalEndTimestamp !== undefined) {
            let newStartTimestamp = new Date(
                newPreviousTimestamp.getTime() + originalNextTimestamp.getTime() - originalEndTimestamp.getTime()
            );

            let trkpt = og.trkpt.map((point, i) => new TrackPoint({
                attributes: cloneJSON(point.attributes),
                ele: point.ele,
                time: new Date(
                    newStartTimestamp.getTime() + (originalEndTimestamp.getTime() - og.trkpt[i].time.getTime())
                ),
                extensions: cloneJSON(point.extensions),
                _data: cloneJSON(point._data),
            }));

            trkpt.reverse();

            this.trkpt = freeze(trkpt); // Pre-freeze the array, faster as well
        } else {
            this.trkpt.reverse();
        }
    }

    crop(start: number, end: number) {
        this.trkpt = this.trkpt.slice(start, end + 1);
    }

    clean(bounds: [Coordinates, Coordinates], inside: boolean) {
        let og = getOriginal(this); // Read as much as possible from the original object because it is faster
        let trkpt = og.trkpt.filter((point) => {
            let inBounds = point.attributes.lat >= bounds[0].lat && point.attributes.lat <= bounds[1].lat && point.attributes.lon >= bounds[0].lon && point.attributes.lon <= bounds[1].lon;
            return inBounds !== inside;
        });
        this.trkpt = freeze(trkpt); // Pre-freeze the array, faster as well
    }

    changeTimestamps(startTime: Date, speed: number, ratio: number, lastPoint?: TrackPoint) {
        if (lastPoint === undefined && this.trkpt.length > 0) {
            lastPoint = this.trkpt[0].clone();
            lastPoint.time = startTime;
        }

        let og = getOriginal(this); // Read as much as possible from the original object because it is faster
        if (og.trkpt.length > 0 && og.trkpt[0].time === undefined) {
            let trkpt = withTimestamps(og.trkpt, speed, lastPoint, startTime);
            this.trkpt = freeze(trkpt); // Pre-freeze the array, faster as well
        } else {
            let trkpt = withShiftedAndCompressedTimestamps(og.trkpt, speed, ratio, lastPoint);
            this.trkpt = freeze(trkpt); // Pre-freeze the array, faster as well
        }
    }
    setHidden(hidden: boolean) {
        this._data.hidden = hidden;
    }
};

export class TrackPoint {
    [immerable] = true;

    attributes: Coordinates;
    ele?: number;
    time?: Date;
    extensions?: TrackPointExtensions;
    _data: { [key: string]: any } = {};

    constructor(point: TrackPointType & { _data?: any } | TrackPoint) {
        this.attributes = point.attributes;
        this.ele = point.ele;
        this.time = point.time;
        this.extensions = point.extensions;
        if (point.hasOwnProperty('_data')) {
            this._data = point._data;
        }
    }

    getCoordinates(): Coordinates {
        return this.attributes;
    }

    setCoordinates(coordinates: Coordinates): void {
        this.attributes = coordinates;
    }

    getLatitude(): number {
        return this.attributes.lat;
    }

    getLongitude(): number {
        return this.attributes.lon;
    }

    getHeartRate(): number {
        return this.extensions && this.extensions['gpxtpx:TrackPointExtension'] && this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:hr'] ? this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:hr'] : undefined;
    }

    getCadence(): number {
        return this.extensions && this.extensions['gpxtpx:TrackPointExtension'] && this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:cad'] ? this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:cad'] : undefined;
    }

    getTemperature(): number {
        return this.extensions && this.extensions['gpxtpx:TrackPointExtension'] && this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:atemp'] ? this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:atemp'] : undefined;
    }

    getPower(): number {
        return this.extensions && this.extensions["gpxpx:PowerExtension"] && this.extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"] ? this.extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"] : undefined;
    }

    getSurface(): string {
        return this.extensions && this.extensions["gpxtpx:TrackPointExtension"] && this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"] && this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"].surface ? this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"].surface : undefined;
    }

    setSurface(surface: string): void {
        if (!this.extensions) {
            this.extensions = {};
        }
        if (!this.extensions["gpxtpx:TrackPointExtension"]) {
            this.extensions["gpxtpx:TrackPointExtension"] = {};
        }
        if (!this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"]) {
            this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"] = {};
        }
        this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"]["surface"] = surface;
    }

    toTrackPointType(): TrackPointType {
        return {
            attributes: this.attributes,
            ele: this.ele,
            time: this.time,
            extensions: this.extensions,
        };
    }

    clone(): TrackPoint {
        return new TrackPoint({
            attributes: cloneJSON(this.attributes),
            ele: this.ele,
            time: this.time ? new Date(this.time.getTime()) : undefined,
            extensions: cloneJSON(this.extensions),
            _data: cloneJSON(this._data),
        });
    }
};

export class Waypoint {
    [immerable] = true;

    attributes: Coordinates;
    ele?: number;
    time?: Date;
    name?: string;
    cmt?: string;
    desc?: string;
    link?: Link;
    sym?: string;
    type?: string;
    _data: { [key: string]: any } = {};

    constructor(waypoint: WaypointType & { _data?: any } | Waypoint) {
        this.attributes = waypoint.attributes;
        this.ele = waypoint.ele;
        this.time = waypoint.time;
        this.name = waypoint.name;
        this.cmt = waypoint.cmt;
        this.desc = waypoint.desc;
        this.link = waypoint.link;
        this.sym = waypoint.sym;
        this.type = waypoint.type;
        if (waypoint.hasOwnProperty('_data')) {
            this._data = waypoint._data;
        }
    }

    getCoordinates(): Coordinates {
        return this.attributes;
    }

    setCoordinates(coordinates: Coordinates): void {
        this.attributes = coordinates;
    }

    getLatitude(): number {
        return this.attributes.lat;
    }

    getLongitude(): number {
        return this.attributes.lon;
    }

    clone(): Waypoint {
        return new Waypoint({
            attributes: cloneJSON(this.attributes),
            ele: this.ele,
            time: this.time ? new Date(this.time.getTime()) : undefined,
            name: this.name,
            cmt: this.cmt,
            desc: this.desc,
            link: cloneJSON(this.link),
            sym: this.sym,
            type: this.type,
        });
    }

    // Producers
    setHidden(hidden: boolean) {
        this._data.hidden = hidden;
    }
}

export class GPXStatistics {
    global: {
        distance: {
            moving: number,
            total: number,
        },
        time: {
            start: Date | undefined,
            end: Date | undefined,
            moving: number,
            total: number,
        },
        speed: {
            moving: number,
            total: number,
        },
        elevation: {
            gain: number,
            loss: number,
        },
        bounds: {
            southWest: Coordinates,
            northEast: Coordinates,
        },
    };
    local: {
        points: TrackPoint[],
        distance: {
            moving: number[],
            total: number[],
        },
        time: {
            moving: number[],
            total: number[],
        },
        speed: number[],
        elevation: {
            smoothed: number[],
            gain: number[],
            loss: number[],
        },
        slope: {
            at: number[],
            segment: number[],
            length: number[],
        }
    };

    constructor() {
        this.global = {
            distance: {
                moving: 0,
                total: 0,
            },
            time: {
                start: undefined,
                end: undefined,
                moving: 0,
                total: 0,
            },
            speed: {
                moving: 0,
                total: 0,
            },
            elevation: {
                gain: 0,
                loss: 0,
            },
            bounds: {
                southWest: {
                    lat: 90,
                    lon: 180,
                },
                northEast: {
                    lat: -90,
                    lon: -180,
                },
            },
        };
        this.local = {
            points: [],
            distance: {
                moving: [],
                total: [],
            },
            time: {
                moving: [],
                total: [],
            },
            speed: [],
            elevation: {
                smoothed: [],
                gain: [],
                loss: [],
            },
            slope: {
                at: [],
                segment: [],
                length: [],
            }
        };
    }

    mergeWith(other: GPXStatistics): void {
        this.local.points = this.local.points.concat(other.local.points);

        this.local.distance.total = this.local.distance.total.concat(other.local.distance.total.map((distance) => distance + this.global.distance.total));
        this.local.distance.moving = this.local.distance.moving.concat(other.local.distance.moving.map((distance) => distance + this.global.distance.moving));
        this.local.time.total = this.local.time.total.concat(other.local.time.total.map((time) => time + this.global.time.total));
        this.local.time.moving = this.local.time.moving.concat(other.local.time.moving.map((time) => time + this.global.time.moving));
        this.local.elevation.gain = this.local.elevation.gain.concat(other.local.elevation.gain.map((gain) => gain + this.global.elevation.gain));
        this.local.elevation.loss = this.local.elevation.loss.concat(other.local.elevation.loss.map((loss) => loss + this.global.elevation.loss));

        this.local.speed = this.local.speed.concat(other.local.speed);
        this.local.elevation.smoothed = this.local.elevation.smoothed.concat(other.local.elevation.smoothed);
        this.local.slope.at = this.local.slope.at.concat(other.local.slope.at);
        this.local.slope.segment = this.local.slope.segment.concat(other.local.slope.segment);
        this.local.slope.length = this.local.slope.length.concat(other.local.slope.length);

        this.global.distance.total += other.global.distance.total;
        this.global.distance.moving += other.global.distance.moving;

        this.global.time.start = this.global.time.start !== undefined && other.global.time.start !== undefined ? new Date(Math.min(this.global.time.start.getTime(), other.global.time.start.getTime())) : this.global.time.start ?? other.global.time.start;
        this.global.time.end = this.global.time.end !== undefined && other.global.time.end !== undefined ? new Date(Math.max(this.global.time.end.getTime(), other.global.time.end.getTime())) : this.global.time.end ?? other.global.time.end;

        this.global.time.total += other.global.time.total;
        this.global.time.moving += other.global.time.moving;

        this.global.speed.moving = this.global.time.moving > 0 ? this.global.distance.moving / (this.global.time.moving / 3600) : 0;
        this.global.speed.total = this.global.time.total > 0 ? this.global.distance.total / (this.global.time.total / 3600) : 0;

        this.global.elevation.gain += other.global.elevation.gain;
        this.global.elevation.loss += other.global.elevation.loss;

        this.global.bounds.southWest.lat = Math.min(this.global.bounds.southWest.lat, other.global.bounds.southWest.lat);
        this.global.bounds.southWest.lon = Math.min(this.global.bounds.southWest.lon, other.global.bounds.southWest.lon);
        this.global.bounds.northEast.lat = Math.max(this.global.bounds.northEast.lat, other.global.bounds.northEast.lat);
        this.global.bounds.northEast.lon = Math.max(this.global.bounds.northEast.lon, other.global.bounds.northEast.lon);
    }

    slice(start: number, end: number): GPXStatistics {
        let statistics = new GPXStatistics();

        statistics.local.points = this.local.points.slice(start, end + 1);

        statistics.global.distance.total = this.local.distance.total[end] - this.local.distance.total[start];
        statistics.global.distance.moving = this.local.distance.moving[end] - this.local.distance.moving[start];

        statistics.global.time.start = this.local.points[start].time;
        statistics.global.time.end = this.local.points[end].time;

        statistics.global.time.total = this.local.time.total[end] - this.local.time.total[start];
        statistics.global.time.moving = this.local.time.moving[end] - this.local.time.moving[start];

        statistics.global.speed.moving = statistics.global.time.moving > 0 ? statistics.global.distance.moving / (statistics.global.time.moving / 3600) : 0;
        statistics.global.speed.total = statistics.global.time.total > 0 ? statistics.global.distance.total / (statistics.global.time.total / 3600) : 0;

        statistics.global.elevation.gain = this.local.elevation.gain[end] - this.local.elevation.gain[start];
        statistics.global.elevation.loss = this.local.elevation.loss[end] - this.local.elevation.loss[start];

        statistics.global.bounds.southWest.lat = this.global.bounds.southWest.lat;
        statistics.global.bounds.southWest.lon = this.global.bounds.southWest.lon;
        statistics.global.bounds.northEast.lat = this.global.bounds.northEast.lat;
        statistics.global.bounds.northEast.lon = this.global.bounds.northEast.lon;

        return statistics;
    }
}

const earthRadius = 6371008.8;
export function distance(coord1: Coordinates, coord2: Coordinates): number {
    const rad = Math.PI / 180;
    const lat1 = coord1.lat * rad;
    const lat2 = coord2.lat * rad;
    const a = Math.sin(lat1) * Math.sin(lat2) + Math.cos(lat1) * Math.cos(lat2) * Math.cos((coord2.lon - coord1.lon) * rad);
    const maxMeters = earthRadius * Math.acos(Math.min(a, 1));
    return maxMeters;
}

function distanceWindowSmoothing(points: TrackPoint[], distanceWindow: number, accumulate: (index: number) => number, compute: (accumulated: number, start: number, end: number) => number, remove?: (index: number) => number): number[] {
    let result = [];

    let start = 0, end = 0, accumulated = 0;
    for (var i = 0; i < points.length; i++) {
        while (start + 1 < i && distance(points[start].getCoordinates(), points[i].getCoordinates()) > distanceWindow) {
            if (remove) {
                accumulated -= remove(start);
            } else {
                accumulated -= accumulate(start);
            }
            start++;
        }
        while (end < points.length && distance(points[i].getCoordinates(), points[end].getCoordinates()) <= distanceWindow) {
            accumulated += accumulate(end);
            end++;
        }
        result[i] = compute(accumulated, start, end - 1);
    }

    return result;
}

function distanceWindowSmoothingWithDistanceAccumulator(points: TrackPoint[], distanceWindow: number, compute: (accumulated: number, start: number, end: number) => number): number[] {
    return distanceWindowSmoothing(points, distanceWindow, (index) => index > 0 ? distance(points[index - 1].getCoordinates(), points[index].getCoordinates()) : 0, compute, (index) => distance(points[index].getCoordinates(), points[index + 1].getCoordinates()));
}

function withTimestamps(points: TrackPoint[], speed: number, lastPoint: TrackPoint | undefined, startTime?: Date): TrackPoint[] {
    let last = lastPoint;
    if (last === undefined) {
        last = points[0].clone();
        last.time = startTime;
    }
    return points.map((point) => {
        let time = getTimestamp(last, point, speed);
        last = point.clone();
        last.time = time;
        return last;
    });
}

function withShiftedAndCompressedTimestamps(points: TrackPoint[], speed: number, ratio: number, lastPoint: TrackPoint): TrackPoint[] {
    let start = getTimestamp(lastPoint, points[0], speed);
    return points.map((point) => {
        let pt = point.clone();
        pt.time = new Date(start.getTime() + ratio * (point.time.getTime() - points[0].time.getTime()));
        return pt;
    });
}

function getTimestamp(a: TrackPoint, b: TrackPoint, speed: number): Date {
    let dist = distance(a.getCoordinates(), b.getCoordinates()) / 1000;
    return new Date(a.time.getTime() + 1000 * 3600 * dist / speed);
}

function getOriginal(obj: any): any {
    while (isDraft(obj)) {
        obj = original(obj);
    }
    return obj;
}

export type MergedLineStyles = {
    color: string[]
    opacity: number[],
    weight: number[],
};