import {
    GeoJSONSourceSpecification,
    LayerSpecification,
    LightSpecification,
    ProjectionSpecification,
    SkySpecification,
    SourceSpecification,
    SpriteSpecification,
    StyleSpecification,
    TerrainSpecification,
    TransitionSpecification,
    StateSpecification
} from './types.g';
import {deepEqual} from './util/deep_equal';

/**
 * Operations that can be performed by the diff.
 * Below are the operations and their arguments, the arguments should be aligned with the style methods in maplibre-gl-js.
 */
export type DiffOperationsMap = {
    setStyle: [StyleSpecification];
    addLayer: [LayerSpecification, string | null];
    removeLayer: [string];
    setPaintProperty: [string, string, unknown, string | null];
    setLayoutProperty: [string, string, unknown, string | null];
    setFilter: [string, unknown];
    addSource: [string, SourceSpecification];
    removeSource: [string];
    setGeoJSONSourceData: [string, unknown];
    setLayerZoomRange: [string, number, number];
    setLayerProperty: [string, string, unknown];
    setCenter: [number[]];
    setCenterAltitude: [number];
    setZoom: [number];
    setBearing: [number];
    setPitch: [number];
    setRoll: [number];
    setSprite: [SpriteSpecification];
    setGlyphs: [string];
    setTransition: [TransitionSpecification];
    setLight: [LightSpecification];
    setTerrain: [TerrainSpecification];
    setSky: [SkySpecification];
    setProjection: [ProjectionSpecification];
    setGlobalState: [StateSpecification];
};

export type DiffOperations = keyof DiffOperationsMap;

export type DiffCommand<T extends DiffOperations> = {
    command: T;
    args: DiffOperationsMap[T];
};

/**
 * The main reason for this method is to allow type check when adding a command to the array.
 * @param commands - The commands array to add to
 * @param command - The command to add
 */
function addCommand<T extends DiffOperations>(
    commands: DiffCommand<DiffOperations>[],
    command: DiffCommand<T>
) {
    commands.push(command);
}

function addSource(
    sourceId: string,
    after: {[key: string]: SourceSpecification},
    commands: DiffCommand<DiffOperations>[]
) {
    addCommand(commands, {command: 'addSource', args: [sourceId, after[sourceId]]});
}

function removeSource(
    sourceId: string,
    commands: DiffCommand<DiffOperations>[],
    sourcesRemoved: {[key: string]: boolean}
) {
    addCommand(commands, {command: 'removeSource', args: [sourceId]});
    sourcesRemoved[sourceId] = true;
}

function updateSource(
    sourceId: string,
    after: {[key: string]: SourceSpecification},
    commands: DiffCommand<DiffOperations>[],
    sourcesRemoved: {[key: string]: boolean}
) {
    removeSource(sourceId, commands, sourcesRemoved);
    addSource(sourceId, after, commands);
}

function canUpdateGeoJSON(
    before: {[key: string]: SourceSpecification},
    after: {[key: string]: SourceSpecification},
    sourceId: string
) {
    let prop;
    for (prop in before[sourceId]) {
        if (!Object.prototype.hasOwnProperty.call(before[sourceId], prop)) continue;
        if (prop !== 'data' && !deepEqual(before[sourceId][prop], after[sourceId][prop])) {
            return false;
        }
    }
    for (prop in after[sourceId]) {
        if (!Object.prototype.hasOwnProperty.call(after[sourceId], prop)) continue;
        if (prop !== 'data' && !deepEqual(before[sourceId][prop], after[sourceId][prop])) {
            return false;
        }
    }
    return true;
}

function diffSources(
    before: {[key: string]: SourceSpecification},
    after: {[key: string]: SourceSpecification},
    commands: DiffCommand<DiffOperations>[],
    sourcesRemoved: {[key: string]: boolean}
) {
    before = before || ({} as {[key: string]: SourceSpecification});
    after = after || ({} as {[key: string]: SourceSpecification});

    let sourceId: string;

    // look for sources to remove
    for (sourceId in before) {
        if (!Object.prototype.hasOwnProperty.call(before, sourceId)) continue;
        if (!Object.prototype.hasOwnProperty.call(after, sourceId)) {
            removeSource(sourceId, commands, sourcesRemoved);
        }
    }

    // look for sources to add/update
    for (sourceId in after) {
        if (!Object.prototype.hasOwnProperty.call(after, sourceId)) continue;
        if (!Object.prototype.hasOwnProperty.call(before, sourceId)) {
            addSource(sourceId, after, commands);
        } else if (!deepEqual(before[sourceId], after[sourceId])) {
            if (
                before[sourceId].type === 'geojson' &&
                after[sourceId].type === 'geojson' &&
                canUpdateGeoJSON(before, after, sourceId)
            ) {
                addCommand(commands, {
                    command: 'setGeoJSONSourceData',
                    args: [sourceId, (after[sourceId] as GeoJSONSourceSpecification).data]
                });
            } else {
                // no update command, must remove then add
                updateSource(sourceId, after, commands, sourcesRemoved);
            }
        }
    }
}

function diffLayerPropertyChanges(
    before: LayerSpecification['layout'] | LayerSpecification['paint'],
    after: LayerSpecification['layout'] | LayerSpecification['paint'],
    commands: DiffCommand<DiffOperations>[],
    layerId: string,
    klass: string | null,
    command: 'setPaintProperty' | 'setLayoutProperty'
) {
    before = before || ({} as LayerSpecification['layout'] | LayerSpecification['paint']);
    after = after || ({} as LayerSpecification['layout'] | LayerSpecification['paint']);

    for (const prop in before) {
        if (!Object.prototype.hasOwnProperty.call(before, prop)) continue;
        if (!deepEqual(before[prop], after[prop])) {
            commands.push({command, args: [layerId, prop, after[prop], klass]});
        }
    }
    for (const prop in after) {
        if (
            !Object.prototype.hasOwnProperty.call(after, prop) ||
            Object.prototype.hasOwnProperty.call(before, prop)
        )
            continue;
        if (!deepEqual(before[prop], after[prop])) {
            commands.push({command, args: [layerId, prop, after[prop], klass]});
        }
    }
}

function pluckId(layer: LayerSpecification) {
    return layer.id;
}
function indexById(group: {[key: string]: LayerSpecification}, layer: LayerSpecification) {
    group[layer.id] = layer;
    return group;
}

function diffLayers(
    before: LayerSpecification[],
    after: LayerSpecification[],
    commands: DiffCommand<DiffOperations>[]
) {
    before = before || [];
    after = after || [];

    // order of layers by id
    const beforeOrder = before.map(pluckId);
    const afterOrder = after.map(pluckId);

    // index of layer by id
    const beforeIndex = before.reduce(indexById, {});
    const afterIndex = after.reduce(indexById, {});

    // track order of layers as if they have been mutated
    const tracker = beforeOrder.slice();

    // layers that have been added do not need to be diffed
    const clean = Object.create(null);

    let layerId: string;
    let beforeLayer: LayerSpecification & {source?: string; filter?: unknown};
    let afterLayer: LayerSpecification & {source?: string; filter?: unknown};
    let insertBeforeLayerId: string;
    let prop: string;

    // remove layers
    for (let i = 0, d = 0; i < beforeOrder.length; i++) {
        layerId = beforeOrder[i];
        if (!Object.prototype.hasOwnProperty.call(afterIndex, layerId)) {
            addCommand(commands, {command: 'removeLayer', args: [layerId]});
            tracker.splice(tracker.indexOf(layerId, d), 1);
        } else {
            // limit where in tracker we need to look for a match
            d++;
        }
    }

    // add/reorder layers
    for (let i = 0, d = 0; i < afterOrder.length; i++) {
        // work backwards as insert is before an existing layer
        layerId = afterOrder[afterOrder.length - 1 - i];

        if (tracker[tracker.length - 1 - i] === layerId) continue;

        if (Object.prototype.hasOwnProperty.call(beforeIndex, layerId)) {
            // remove the layer before we insert at the correct position
            addCommand(commands, {command: 'removeLayer', args: [layerId]});
            tracker.splice(tracker.lastIndexOf(layerId, tracker.length - d), 1);
        } else {
            // limit where in tracker we need to look for a match
            d++;
        }

        // add layer at correct position
        insertBeforeLayerId = tracker[tracker.length - i];
        addCommand(commands, {
            command: 'addLayer',
            args: [afterIndex[layerId], insertBeforeLayerId]
        });
        tracker.splice(tracker.length - i, 0, layerId);
        clean[layerId] = true;
    }

    // update layers
    for (let i = 0; i < afterOrder.length; i++) {
        layerId = afterOrder[i];
        beforeLayer = beforeIndex[layerId];
        afterLayer = afterIndex[layerId];

        // no need to update if previously added (new or moved)
        if (clean[layerId] || deepEqual(beforeLayer, afterLayer)) continue;

        // If source, source-layer, or type have changes, then remove the layer
        // and add it back 'from scratch'.
        if (
            !deepEqual(beforeLayer.source, afterLayer.source) ||
            !deepEqual(beforeLayer['source-layer'], afterLayer['source-layer']) ||
            !deepEqual(beforeLayer.type, afterLayer.type)
        ) {
            addCommand(commands, {command: 'removeLayer', args: [layerId]});
            // we add the layer back at the same position it was already in, so
            // there's no need to update the `tracker`
            insertBeforeLayerId = tracker[tracker.lastIndexOf(layerId) + 1];
            addCommand(commands, {command: 'addLayer', args: [afterLayer, insertBeforeLayerId]});
            continue;
        }

        // layout, paint, filter, minzoom, maxzoom
        diffLayerPropertyChanges(
            beforeLayer.layout,
            afterLayer.layout,
            commands,
            layerId,
            null,
            'setLayoutProperty'
        );
        diffLayerPropertyChanges(
            beforeLayer.paint,
            afterLayer.paint,
            commands,
            layerId,
            null,
            'setPaintProperty'
        );
        if (!deepEqual(beforeLayer.filter, afterLayer.filter)) {
            addCommand(commands, {command: 'setFilter', args: [layerId, afterLayer.filter]});
        }
        if (
            !deepEqual(beforeLayer.minzoom, afterLayer.minzoom) ||
            !deepEqual(beforeLayer.maxzoom, afterLayer.maxzoom)
        ) {
            addCommand(commands, {
                command: 'setLayerZoomRange',
                args: [layerId, afterLayer.minzoom, afterLayer.maxzoom]
            });
        }

        // handle all other layer props, including paint.*
        for (prop in beforeLayer) {
            if (!Object.prototype.hasOwnProperty.call(beforeLayer, prop)) continue;
            if (
                prop === 'layout' ||
                prop === 'paint' ||
                prop === 'filter' ||
                prop === 'metadata' ||
                prop === 'minzoom' ||
                prop === 'maxzoom'
            )
                continue;
            if (prop.indexOf('paint.') === 0) {
                diffLayerPropertyChanges(
                    beforeLayer[prop],
                    afterLayer[prop],
                    commands,
                    layerId,
                    prop.slice(6),
                    'setPaintProperty'
                );
            } else if (!deepEqual(beforeLayer[prop], afterLayer[prop])) {
                addCommand(commands, {
                    command: 'setLayerProperty',
                    args: [layerId, prop, afterLayer[prop]]
                });
            }
        }
        for (prop in afterLayer) {
            if (
                !Object.prototype.hasOwnProperty.call(afterLayer, prop) ||
                Object.prototype.hasOwnProperty.call(beforeLayer, prop)
            )
                continue;
            if (
                prop === 'layout' ||
                prop === 'paint' ||
                prop === 'filter' ||
                prop === 'metadata' ||
                prop === 'minzoom' ||
                prop === 'maxzoom'
            )
                continue;
            if (prop.indexOf('paint.') === 0) {
                diffLayerPropertyChanges(
                    beforeLayer[prop],
                    afterLayer[prop],
                    commands,
                    layerId,
                    prop.slice(6),
                    'setPaintProperty'
                );
            } else if (!deepEqual(beforeLayer[prop], afterLayer[prop])) {
                addCommand(commands, {
                    command: 'setLayerProperty',
                    args: [layerId, prop, afterLayer[prop]]
                });
            }
        }
    }
}

/**
 * Diff two stylesheet
 *
 * Creates semanticly aware diffs that can easily be applied at runtime.
 * Operations produced by the diff closely resemble the maplibre-gl-js API. Any
 * error creating the diff will fall back to the 'setStyle' operation.
 *
 * Example diff:
 * [
 *     { command: 'setConstant', args: ['@water', '#0000FF'] },
 *     { command: 'setPaintProperty', args: ['background', 'background-color', 'black'] }
 * ]
 *
 * @private
 * @param {*} [before] stylesheet to compare from
 * @param {*} after stylesheet to compare to
 * @returns Array list of changes
 */
export function diff(
    before: StyleSpecification,
    after: StyleSpecification
): DiffCommand<DiffOperations>[] {
    if (!before) return [{command: 'setStyle', args: [after]}];

    let commands: DiffCommand<DiffOperations>[] = [];

    try {
        // Handle changes to top-level properties
        if (!deepEqual(before.version, after.version)) {
            return [{command: 'setStyle', args: [after]}];
        }
        if (!deepEqual(before.center, after.center)) {
            commands.push({command: 'setCenter', args: [after.center]});
        }
        if (!deepEqual(before.state, after.state)) {
            commands.push({command: 'setGlobalState', args: [after.state]});
        }
        if (!deepEqual(before.centerAltitude, after.centerAltitude)) {
            commands.push({command: 'setCenterAltitude', args: [after.centerAltitude]});
        }
        if (!deepEqual(before.zoom, after.zoom)) {
            commands.push({command: 'setZoom', args: [after.zoom]});
        }
        if (!deepEqual(before.bearing, after.bearing)) {
            commands.push({command: 'setBearing', args: [after.bearing]});
        }
        if (!deepEqual(before.pitch, after.pitch)) {
            commands.push({command: 'setPitch', args: [after.pitch]});
        }
        if (!deepEqual(before.roll, after.roll)) {
            commands.push({command: 'setRoll', args: [after.roll]});
        }
        if (!deepEqual(before.sprite, after.sprite)) {
            commands.push({command: 'setSprite', args: [after.sprite]});
        }
        if (!deepEqual(before.glyphs, after.glyphs)) {
            commands.push({command: 'setGlyphs', args: [after.glyphs]});
        }
        if (!deepEqual(before.transition, after.transition)) {
            commands.push({command: 'setTransition', args: [after.transition]});
        }
        if (!deepEqual(before.light, after.light)) {
            commands.push({command: 'setLight', args: [after.light]});
        }
        if (!deepEqual(before.terrain, after.terrain)) {
            commands.push({command: 'setTerrain', args: [after.terrain]});
        }
        if (!deepEqual(before.sky, after.sky)) {
            commands.push({command: 'setSky', args: [after.sky]});
        }
        if (!deepEqual(before.projection, after.projection)) {
            commands.push({command: 'setProjection', args: [after.projection]});
        }

        // Handle changes to `sources`
        // If a source is to be removed, we also--before the removeSource
        // command--need to remove all the style layers that depend on it.
        const sourcesRemoved = {};

        // First collect the {add,remove}Source commands
        const removeOrAddSourceCommands = [];
        diffSources(before.sources, after.sources, removeOrAddSourceCommands, sourcesRemoved);

        // Push a removeLayer command for each style layer that depends on a
        // source that's being removed.
        // Also, exclude any such layers them from the input to `diffLayers`
        // below, so that diffLayers produces the appropriate `addLayers`
        // command
        const beforeLayers = [];
        if (before.layers) {
            before.layers.forEach((layer) => {
                if ('source' in layer && sourcesRemoved[layer.source]) {
                    commands.push({command: 'removeLayer', args: [layer.id]});
                } else {
                    beforeLayers.push(layer);
                }
            });
        }
        commands = commands.concat(removeOrAddSourceCommands);

        // Handle changes to `layers`
        diffLayers(beforeLayers, after.layers, commands);
    } catch (e) {
        // fall back to setStyle
        console.warn('Unable to compute style diff:', e);
        commands = [{command: 'setStyle', args: [after]}];
    }

    return commands;
}
