/**
 * A way to identify a feature, either by string or by number
 */
export type GeoJSONFeatureId = number | string;

/**
 * The geojson source diff object
 */
export type GeoJSONSourceDiff = {
    /**
     * When set to `true` it will remove all features
     */
    removeAll?: boolean;
    /**
     * An array of features IDs to remove
     */
    remove?: Array<GeoJSONFeatureId>;
    /**
     * An array of features to add
     */
    add?: Array<GeoJSON.Feature>;
    /**
     * An array of update objects
     */
    update?: Array<GeoJSONFeatureDiff>;
};

/**
 * A geojson feature diff object
 */
export type GeoJSONFeatureDiff = {
    /**
     * The feature ID
     */
    id: GeoJSONFeatureId;
    /**
     * If it's a new geometry, place it here
     */
    newGeometry?: GeoJSON.Geometry;
    /**
     * Setting to `true` will remove all preperties
     */
    removeAllProperties?: boolean;
    /**
     * The properties keys to remove
     */
    removeProperties?: Array<string>;
    /**
     * The properties to add or update along side their values
     */
    addOrUpdateProperties?: Array<{key: string; value: any}>;
};

export type UpdateableGeoJSON = GeoJSON.Feature | GeoJSON.FeatureCollection | undefined;

function getFeatureId(feature: GeoJSON.Feature, promoteId?: string): GeoJSONFeatureId | undefined {
    return promoteId ? feature.properties[promoteId] : feature.id;
}

export function isUpdateableGeoJSON(data: GeoJSON.GeoJSON | undefined, promoteId?: string): data is UpdateableGeoJSON {
    // null can be updated
    if (data == null) {
        return true;
    }

    // a single feature with an id can be updated, need to explicitly check against null because 0 is a valid feature id that is falsy
    if (data.type === 'Feature') {
        return getFeatureId(data, promoteId) != null;
    }

    // a feature collection can be updated if every feature has an id, and the ids are all unique
    // this prevents us from silently dropping features if ids get reused
    if (data.type === 'FeatureCollection') {
        const seenIds = new Set<GeoJSONFeatureId>();
        for (const feature of data.features) {
            const id = getFeatureId(feature, promoteId);
            if (id == null) {
                return false;
            }

            if (seenIds.has(id)) {
                return false;
            }

            seenIds.add(id);
        }

        return true;
    }

    return false;
}

export function toUpdateable(data: UpdateableGeoJSON, promoteId?: string) {
    const result = new Map<GeoJSONFeatureId, GeoJSON.Feature>();
    if (data == null) {
        // empty result
    } else if (data.type === 'Feature') {
        result.set(getFeatureId(data, promoteId)!, data);
    } else {
        for (const feature of data.features) {
            result.set(getFeatureId(feature, promoteId)!, feature);
        }
    }

    return result;
}

// mutates updateable
export function applySourceDiff(updateable: Map<GeoJSONFeatureId, GeoJSON.Feature>, diff: GeoJSONSourceDiff, promoteId?: string): void {
    if (diff.removeAll) {
        updateable.clear();
    }

    if (diff.remove) {
        for (const id of diff.remove) {
            updateable.delete(id);
        }
    }

    if (diff.add) {
        for (const feature of diff.add) {
            const id = getFeatureId(feature, promoteId);

            if (id != null) {
                updateable.set(id, feature);
            }
        }
    }

    if (diff.update) {
        for (const update of diff.update) {
            let feature = updateable.get(update.id);

            if (feature == null) {
                continue;
            }

            // be careful to clone the feature and/or properties objects to avoid mutating our input
            const cloneFeature = update.newGeometry || update.removeAllProperties;
            // note: removeAllProperties gives us a new properties object, so we can skip the clone step
            const cloneProperties = !update.removeAllProperties && (update.removeProperties?.length > 0 || update.addOrUpdateProperties?.length > 0);
            if (cloneFeature || cloneProperties) {
                feature = {...feature};
                updateable.set(update.id, feature);
                if (cloneProperties) {
                    feature.properties = {...feature.properties};
                }
            }

            if (update.newGeometry) {
                feature.geometry = update.newGeometry;
            }

            if (update.removeAllProperties) {
                feature.properties = {};
            } else if (update.removeProperties?.length > 0) {
                for (const prop of update.removeProperties) {
                    if (Object.prototype.hasOwnProperty.call(feature.properties, prop)) {
                        delete feature.properties[prop];
                    }
                }
            }

            if (update.addOrUpdateProperties?.length > 0) {
                for (const {key, value} of update.addOrUpdateProperties) {
                    feature.properties[key] = value;
                }
            }
        }
    }
}

export function mergeSourceDiffs(
    existingDiff: GeoJSONSourceDiff | undefined,
    newDiff: GeoJSONSourceDiff | undefined
): GeoJSONSourceDiff {
    if (!existingDiff) {
        return newDiff ?? {};
    }

    if (!newDiff) {
        return existingDiff;
    }

    let merged: GeoJSONSourceDiff = {...existingDiff};

    if (newDiff.removeAll) {
        merged = {removeAll: true};
    }

    if (newDiff.remove) {
        const newRemovedSet = new Set(newDiff.remove);
        if (merged.add) {
            merged.add = merged.add.filter(f => !newRemovedSet.has(f.id));
        }
        if (merged.update) {
            merged.update = merged.update.filter(f => !newRemovedSet.has(f.id));
        }

        const existingAddSet = new Set((existingDiff.add ?? []).map((f) => f.id));
        newDiff.remove = newDiff.remove.filter(id => !existingAddSet.has(id));
    }

    if (newDiff.remove) {
        const removedSet = new Set(merged.remove ? merged.remove.concat(newDiff.remove) : newDiff.remove);
        merged.remove = Array.from(removedSet.values());
    }

    if (newDiff.add) {
        const combinedAdd = merged.add ? merged.add.concat(newDiff.add) : newDiff.add;
        const addMap = new Map(combinedAdd.map((feature) => [feature.id, feature]));
        merged.add = Array.from(addMap.values());
    }

    if (newDiff.update) {
        const updateMap = new Map(merged.update?.map((feature) => [feature.id, feature]));
        for (const feature of newDiff.update) {
            const featureUpdate = updateMap.get(feature.id) ?? {id: feature.id} satisfies GeoJSONFeatureDiff;

            if (feature.newGeometry) {
                featureUpdate.newGeometry = feature.newGeometry;
            }
            if (feature.addOrUpdateProperties) {
                featureUpdate.addOrUpdateProperties = (featureUpdate.addOrUpdateProperties ?? []).concat(feature.addOrUpdateProperties);
            }
            if (feature.removeProperties) {
                featureUpdate.removeProperties = (featureUpdate.removeProperties ?? []).concat(feature.removeProperties);
            }
            if (feature.removeAllProperties) {
                featureUpdate.removeAllProperties = true;
            }

            updateMap.set(feature.id, featureUpdate);
        }

        merged.update = Array.from(updateMap.values());
    }

    if (merged.remove && merged.add) {
        merged.remove = merged.remove.filter(id => merged.add.findIndex((f) => f.id === id) === -1);
    }

    return merged;
}
