import { fn } from '@vitest/spy';
import { setTimeout } from 'node:timers/promises';
import { clear } from 'idb-keyval';
import { asyncPrefs } from '../../../modules/core/preferences';

describe('iD.coreHistory', function () {
    var context, history, spy;
    var actionNoop = function(g) { return g; };
    var actionAddNode = function (nodeID) {
        return function(g) {
            return g.replace(new iD.osmNode({ id: nodeID }));
        };
    };

    beforeEach(function () {
        context = iD.coreContext().assetPath('../dist/').init();
        history = context.history();
        spy = fn();
    });

    describe('#graph', function () {
        it('returns the current graph', function () {
            expect(history.graph()).toBeInstanceOf(iD.coreGraph);
        });
    });

    describe('#merge', function () {
        it('merges the entities into all graph versions', function () {
            var n = new iD.osmNode({id: 'n'});
            history.merge([n]);
            expect(history.graph().entity('n')).toEqual(n);
        });

        it('emits a merge event with the new entities', function () {
            var n = new iD.osmNode({id: 'n'});
            history.on('merge', spy);
            history.merge([n]);
            expect(spy).toHaveBeenCalledWith([n]);
        });
    });

    describe('#perform', function () {
        it('returns a difference', function () {
            expect(history.perform(actionNoop).changes()).toEqual({});
        });

        it('updates the graph', function () {
            var node = new iD.osmNode();
            history.perform(function (graph) { return graph.replace(node); });
            expect(history.graph().entity(node.id)).toEqual(node);
        });

        it('pushes an undo annotation', function () {
            history.perform(actionNoop, 'annotation');
            expect(history.undoAnnotation()).toEqual('annotation');
        });

        it('emits a change event', function () {
            history.on('change', spy);
            var difference = history.perform(actionNoop);
            expect(spy).toHaveBeenCalledExactlyOnceWith(difference);
        });

        it('performs multiple actions', function () {
            const action1 = fn().mockReturnValue(new iD.coreGraph());
            const action2 = fn().mockReturnValue(new iD.coreGraph());
            history.perform(action1, action2, 'annotation');
            expect(action1).toHaveBeenCalled();
            expect(action2).toHaveBeenCalled();
            expect(history.undoAnnotation()).toEqual('annotation');
        });

        it('performs transitionable actions in a transition', async () => {
            var action1 = function() { return new iD.coreGraph(); };
            action1.transitionable = true;
            history.on('change', spy);
            await history.perform(action1);
            await setTimeout(300);
            expect(spy.mock.calls.length).toBeGreaterThan(2);
        });
    });

    describe('#replace', function () {
        it('returns a difference', function () {
            expect(history.replace(actionNoop).changes()).toEqual({});
        });

        it('updates the graph', function () {
            var node = new iD.osmNode();
            history.replace(function (graph) { return graph.replace(node); });
            expect(history.graph().entity(node.id)).toEqual(node);
        });

        it('replaces the undo annotation', function () {
            history.perform(actionNoop, 'annotation1');
            history.replace(actionNoop, 'annotation2');
            expect(history.undoAnnotation()).toEqual('annotation2');
        });

        it('emits a change event', function () {
            history.on('change', spy);
            var difference = history.replace(actionNoop);
            expect(spy).toHaveBeenCalledWith(difference);
        });

        it('performs multiple actions', function () {
            const action1 = fn().mockReturnValue(new iD.coreGraph());
            const action2 = fn().mockReturnValue(new iD.coreGraph());
            history.replace(action1, action2, 'annotation');
            expect(action1).toHaveBeenCalled();
            expect(action2).toHaveBeenCalled();
            expect(history.undoAnnotation()).toEqual('annotation');
        });
    });

    describe('#pop', function () {
        it('returns a difference', function () {
            history.perform(actionNoop, 'annotation');
            expect(history.pop().changes()).toEqual({});
        });

        it('updates the graph', function () {
            history.perform(actionNoop, 'annotation');
            history.pop();
            expect(history.undoAnnotation()).toBeUndefined();
        });

        it('does not push the redo stack', function () {
            history.perform(actionNoop, 'annotation');
            history.pop();
            expect(history.redoAnnotation()).toBeUndefined();
        });

        it('emits a change event', function () {
            history.perform(actionNoop);
            history.on('change', spy);
            var difference = history.pop();
            expect(spy).toHaveBeenCalledWith(difference);
        });

        it('pops n times', function () {
            history.perform(actionNoop, 'annotation1');
            history.perform(actionNoop, 'annotation2');
            history.perform(actionNoop, 'annotation3');
            history.pop(2);
            expect(history.undoAnnotation()).toEqual('annotation1');
        });

        it('pops 0 times', function () {
            history.perform(actionNoop, 'annotation1');
            history.perform(actionNoop, 'annotation2');
            history.perform(actionNoop, 'annotation3');
            history.pop(0);
            expect(history.undoAnnotation()).toEqual('annotation3');
        });

        it('pops 1 time if argument is invalid', function () {
            history.perform(actionNoop, 'annotation1');
            history.perform(actionNoop, 'annotation2');
            history.perform(actionNoop, 'annotation3');
            history.pop('foo');
            expect(history.undoAnnotation()).toEqual('annotation2');
            history.pop(-1);
            expect(history.undoAnnotation()).toEqual('annotation1');
        });
    });

    describe('#undo', function () {
        it('returns a difference', function () {
            expect(history.undo().changes()).toEqual({});
        });

        it('pops the undo stack', function () {
            history.perform(actionNoop, 'annotation');
            history.undo();
            expect(history.undoAnnotation()).toBeUndefined();
        });

        it('pushes the redo stack', function () {
            history.perform(actionNoop, 'annotation');
            history.undo();
            expect(history.redoAnnotation()).toEqual('annotation');
        });

        it('emits an undone event', function () {
            history.perform(actionNoop);
            history.on('undone', spy);
            history.undo();
            expect(spy).toHaveBeenCalled();
        });

        it('emits a change event', function () {
            history.perform(actionNoop);
            history.on('change', spy);
            var difference = history.undo();
            expect(spy).toHaveBeenCalledWith(difference);
        });
    });

    describe('#redo', function () {
        it('returns a difference', function () {
            expect(history.redo().changes()).toEqual({});
        });

        it('does redo into an annotated state', function () {
            history.perform(actionNoop, 'annotation');
            history.on('redone', spy);
            history.undo();
            history.redo();
            expect(history.undoAnnotation()).toEqual('annotation');
            expect(spy).toHaveBeenCalled();
        });

        it('does not redo into a non-annotated state', function () {
            history.perform(actionNoop);
            history.on('redone', spy);
            history.undo();
            history.redo();
            expect(spy).not.toHaveBeenCalled();
        });

        it('emits a change event', function () {
            history.perform(actionNoop);
            history.undo();
            history.on('change', spy);
            var difference = history.redo();
            expect(spy).toHaveBeenCalledWith(difference);
        });
    });

    describe('#pauseChangeDispatch / #resumeChangeDispatch', function() {
        it('prevents change events from getting dispatched', function() {
            history.perform(actionNoop, 'base');
            history.on('change', spy);

            history.pauseChangeDispatch();

            history.perform(actionNoop, 'perform');
            expect(spy).not.toHaveBeenCalled();
            history.replace(actionNoop, 'replace');
            expect(spy).not.toHaveBeenCalled();
            history.undo();
            expect(spy).not.toHaveBeenCalled();
            history.redo();
            expect(spy).not.toHaveBeenCalled();
            history.pop();
            expect(spy).not.toHaveBeenCalled();

            var diff = history.resumeChangeDispatch();
            expect(spy).toHaveBeenCalledExactlyOnceWith(diff);
        });

        it('does nothing if resume called before pause', function() {
            history.perform(actionNoop, 'base');
            history.on('change', spy);

            history.resumeChangeDispatch();
            expect(spy).not.toHaveBeenCalled();
        });

        it('uses earliest difference if pause called multiple times', function() {
            history.perform(actionNoop, 'base');
            history.on('change', spy);

            history.pauseChangeDispatch();
            history.perform(actionAddNode('a'), 'perform');

            history.pauseChangeDispatch();
            history.perform(actionAddNode('b'), 'perform');

            var diff = history.resumeChangeDispatch();
            expect(spy).toHaveBeenCalledExactlyOnceWith(diff);
            expect(Object.keys(diff.changes())).toEqual(expect.arrayContaining(['a', 'b']));
        });
    });

    describe('#changes', function () {
        it('includes created entities', function () {
            var node = new iD.osmNode();
            history.perform(function (graph) { return graph.replace(node); });
            expect(history.changes().created).toEqual([node]);
        });

        it('includes modified entities', function () {
            var node1 = new iD.osmNode({id: 'n1'});
            var node2 = node1.update({ tags: { yes: 'no' } });
            history.merge([node1]);
            history.perform(function (graph) { return graph.replace(node2); });
            expect(history.changes().modified).toEqual([node2]);
        });

        it('includes deleted entities', function () {
            var node = new iD.osmNode({id: 'n1'});
            history.merge([node]);
            history.perform(function (graph) { return graph.remove(node); });
            expect(history.changes().deleted).toEqual([node]);
        });
    });

    describe('#hasChanges', function() {
        it('is true when any of change\'s values are nonempty', function() {
            var node = new iD.osmNode();
            history.perform(function (graph) { return graph.replace(node); });
            expect(history.hasChanges()).toEqual(true);
        });

        it('is false when all of change\'s values are empty', function() {
            expect(history.hasChanges()).toEqual(false);
        });
    });

    describe('#reset', function () {
        it('clears the version stack', function () {
            history.perform(actionNoop, 'annotation');
            history.perform(actionNoop, 'annotation');
            history.undo();
            history.reset();
            expect(history.undoAnnotation()).toBeUndefined();
            expect(history.redoAnnotation()).toBeUndefined();
        });

        it('emits a change event', function () {
            history.on('change', spy);
            history.reset();
            expect(spy).toHaveBeenCalled();
        });
    });

    describe('#checkpoint', function () {
        it('saves and resets to checkpoints', function () {
            history.perform(actionNoop, 'annotation1');
            history.perform(actionNoop, 'annotation2');
            history.perform(actionNoop, 'annotation3');
            history.checkpoint('check1');
            history.perform(actionNoop, 'annotation4');
            history.perform(actionNoop, 'annotation5');
            history.checkpoint('check2');
            history.perform(actionNoop, 'annotation6');
            history.perform(actionNoop, 'annotation7');
            history.perform(actionNoop, 'annotation8');

            history.reset('check1');
            expect(history.undoAnnotation()).toEqual('annotation3');

            history.reset('check2');
            expect(history.undoAnnotation()).toEqual('annotation5');

            history.reset('check1');
            expect(history.undoAnnotation()).toEqual('annotation3');
        });

        it('emits a change event', function () {
            history.on('change', spy);
            history.reset();
            expect(spy).toHaveBeenCalled();
        });
    });

    describe('#toJSON', function() {
        it('doesn\'t generate unsaveable changes', function() {
            history.perform(actionAddNode('n-1'));
            history.perform(iD.actionDeleteNode('n-1'));
            expect(history.toJSON()).toBeFalsy();
        });

        it('generates v3 JSON', function() {
            var node_1 = new iD.osmNode({id: 'n-1'});
            var node1 = new iD.osmNode({id: 'n1'});
            var node2 = new iD.osmNode({id: 'n2'});
            var node3 = new iD.osmNode({id: 'n3'});
            history.merge([node1, node2, node3]);
            history.perform(iD.actionAddEntity(node_1));           // addition
            history.perform(iD.actionChangeTags('n2', {k: 'v'}));  // modification
            history.perform(iD.actionDeleteNode('n3'));            // deletion

            var json = history.toJSON();
            var node2_upd = node2.update({tags: {k: 'v'}});

            expect(json.version).toEqual(3);
            expect(json.entities).toContainEqual(expect.objectContaining(node_1));
            expect(json.entities).not.toContain(node1);
            expect(json.entities).toContainEqual(expect.objectContaining(node2_upd));
            expect(json.entities).not.toContain(node3);

            expect(json.baseEntities).not.toContain(node_1);
            expect(json.baseEntities).not.toContain(node1);
            expect(json.baseEntities).toContainEqual(expect.objectContaining(node2));
            expect(json.baseEntities).toContainEqual(expect.objectContaining(node3));
        });
    });

    describe('#fromJSON', function() {
        it('restores from v1 JSON (creation)', function() {
            var json = {
                'stack': [
                    {'entities': {}},
                    {'entities': {'n-1': {'loc': [1, 2], 'id': 'n-1'}}, 'imageryUsed': ['Bing'], 'annotation': 'Added a point.'}
                ],
                'nextIDs': {'node': -2, 'way': -1, 'relation': -1},
                'index': 1
            };
            history.fromJSON(json);
            expect(history.graph().entity('n-1')).toEqual(new iD.osmNode({id: 'n-1', loc: [1, 2]}));
            expect(history.undoAnnotation()).toEqual('Added a point.');
            expect(history.imageryUsed()).toEqual(['Bing']);
            expect(iD.osmIdManager.next).toEqual({node: -2, way: -1, relation: -1});
        });

        it('restores from v1 JSON (modification)', function() {
            var json = {
                'stack': [
                    {'entities': {}},
                    {'entities': {'n-1': {'loc': [1, 2], 'id': 'n-1'}}, 'imageryUsed': ['Bing'], 'annotation': 'Added a point.'},
                    {'entities': {'n-1': {'loc': [2, 3], 'id': 'n-1', 'v': 1}}, 'imageryUsed': ['Bing'], 'annotation': 'Moved a point.'}
                ],
                'nextIDs': {'node': -2, 'way': -1, 'relation': -1},
                'index': 2
            };
            history.fromJSON(json);
            expect(history.graph().entity('n-1')).toEqual(new iD.osmNode({id: 'n-1', loc: [2, 3], v: 1}));
            expect(history.undoAnnotation()).toEqual('Moved a point.');
            expect(history.imageryUsed()).toEqual(['Bing']);
            expect(iD.osmIdManager.next).toEqual({node: -2, way: -1, relation: -1});
        });

        it('restores from v1 JSON (deletion)', function() {
            var json = {
                'stack': [
                    {'entities': {}},
                    {'entities': {'n1': 'undefined'}, 'imageryUsed': ['Bing'], 'annotation': 'Deleted a point.'}
                ],
                'nextIDs': {'node': -1, 'way': -2, 'relation': -3},
                'index': 1
            };
            history.fromJSON(json);
            history.merge([new iD.osmNode({id: 'n1'})]);
            expect(history.graph().hasEntity('n1')).toBeUndefined();
            expect(history.undoAnnotation()).toEqual('Deleted a point.');
            expect(history.imageryUsed()).toEqual(['Bing']);
            expect(iD.osmIdManager.next).toEqual({node: -1, way: -2, relation: -3});
        });

        it('restores from v2 JSON (creation)', function() {
            var json = {
                'version': 2,
                'entities': [
                    {'loc': [1, 2], 'id': 'n-1'}
                ],
                'stack': [
                    {},
                    {'modified': ['n-1v0'], 'imageryUsed': ['Bing'], 'annotation': 'Added a point.'}
                ],
                'nextIDs': {'node': -2, 'way': -1, 'relation': -1},
                'index': 1
            };
            history.fromJSON(json);
            expect(history.graph().entity('n-1')).toEqual(new iD.osmNode({id: 'n-1', loc: [1, 2]}));
            expect(history.undoAnnotation()).toEqual('Added a point.');
            expect(history.imageryUsed()).toEqual(['Bing']);
            expect(iD.osmIdManager.next).toEqual({node: -2, way: -1, relation: -1});
            expect(history.difference().created().length).toEqual(1);
        });

        it('restores from v2 JSON (modification)', function() {
            var json = {
                'version': 2,
                'entities': [
                    {'loc': [2, 3], 'id': 'n1', 'v': 1}
                ],
                'stack': [
                    {},
                    {'modified': ['n1v1'], 'imageryUsed': ['Bing'], 'annotation': 'Moved a point.'}
                ],
                'nextIDs': {'node': -2, 'way': -1, 'relation': -1},
                'index': 1
            };
            history.fromJSON(json);
            history.merge([new iD.osmNode({id: 'n1'})]); // Shouldn't be necessary; flaw in v2 format (see #2135)
            expect(history.graph().entity('n1')).toEqual(new iD.osmNode({id: 'n1', loc: [2, 3], v: 1}));
            expect(history.undoAnnotation()).toEqual('Moved a point.');
            expect(history.imageryUsed()).toEqual(['Bing']);
            expect(iD.osmIdManager.next).toEqual({node: -2, way: -1, relation: -1});
            expect(history.difference().modified().length).toEqual(1);
        });

        it('restores from v2 JSON (deletion)', function() {
            var json = {
                'version': 2,
                'entities': [],
                'stack': [
                    {},
                    {'deleted': ['n1'], 'imageryUsed': ['Bing'], 'annotation': 'Deleted a point.'}
                ],
                'nextIDs': {'node': -1, 'way': -2, 'relation': -3},
                'index': 1
            };
            history.fromJSON(json);
            history.merge([new iD.osmNode({id: 'n1'})]); // Shouldn't be necessary; flaw in v2 format (see #2135)
            expect(history.graph().hasEntity('n1')).toBeUndefined();
            expect(history.undoAnnotation()).toEqual('Deleted a point.');
            expect(history.imageryUsed()).toEqual(['Bing']);
            expect(iD.osmIdManager.next).toEqual({node: -1, way: -2, relation: -3});
            expect(history.difference().deleted().length).toEqual(1);
        });

        it('restores from v3 JSON (creation)', function() {
            var json = {
                'version': 3,
                'entities': [
                    {'loc': [1, 2], 'id': 'n-1'}
                ],
                'baseEntities': [],
                'stack': [
                    {},
                    {'modified': ['n-1v0'], 'imageryUsed': ['Bing'], 'annotation': 'Added a point.'}
                ],
                'nextIDs': {'node': -2, 'way': -1, 'relation': -1},
                'index': 1
            };
            history.fromJSON(json);
            expect(history.graph().entity('n-1')).toEqual(new iD.osmNode({id: 'n-1', loc: [1, 2]}));
            expect(history.undoAnnotation()).toEqual('Added a point.');
            expect(history.imageryUsed()).toEqual(['Bing']);
            expect(iD.osmIdManager.next).toEqual({node: -2, way: -1, relation: -1});
            expect(history.difference().created().length).toEqual(1);
        });

        it('restores from v3 JSON (modification)', function() {
            var json = {
                'version': 3,
                'entities': [
                    {'loc': [2, 3], 'id': 'n1', 'v': 1}
                ],
                'baseEntities': [{'loc': [1, 2], 'id': 'n1'}],
                'stack': [
                    {},
                    {'modified': ['n1v1'], 'imageryUsed': ['Bing'], 'annotation': 'Moved a point.'}
                ],
                'nextIDs': {'node': -2, 'way': -1, 'relation': -1},
                'index': 1
            };
            history.fromJSON(json);
            expect(history.graph().entity('n1')).toEqual(new iD.osmNode({id: 'n1', loc: [2, 3], v: 1}));
            expect(history.undoAnnotation()).toEqual('Moved a point.');
            expect(history.imageryUsed()).toEqual(['Bing']);
            expect(iD.osmIdManager.next).toEqual({node: -2, way: -1, relation: -1});
            expect(history.difference().modified().length).toEqual(1);
        });

        it('restores from v3 JSON (deletion)', function() {
            var json = {
                'version': 3,
                'entities': [],
                'baseEntities': [{'loc': [1, 2], 'id': 'n1'}],
                'stack': [
                    {},
                    {'deleted': ['n1'], 'imageryUsed': ['Bing'], 'annotation': 'Deleted a point.'}
                ],
                'nextIDs': {'node': -1, 'way': -2, 'relation': -3},
                'index': 1
            };
            history.fromJSON(json);
            expect(history.graph().hasEntity('n1')).toBeUndefined();
            expect(history.undoAnnotation()).toEqual('Deleted a point.');
            expect(history.imageryUsed()).toEqual(['Bing']);
            expect(iD.osmIdManager.next).toEqual({node: -1, way: -2, relation: -3});
            expect(history.difference().deleted().length).toEqual(1);
        });
    });

    describe('#replaceLocalStorageWithIndexedDB', function() {
        afterEach(async function() {
            await clear();
        });

        it('asyncPrefs get and set work correctly', async function() {
            await asyncPrefs.set('test_async_key', 'test_async_value');
            var retrievedData = await asyncPrefs.get('test_async_key');
            expect(retrievedData).toEqual('test_async_value');
        });

        it('migrates history data from localStorage to IndexedDB', async function() {
            var oldKey = history._getLegacyKey('saved_history');
            var testHistoryData = {
                version: 3,
                entities: [],
                stack: [{}],
                index: 0,
                timestamp: Date.now()
            };

            iD.prefs(oldKey, JSON.stringify(testHistoryData));
            await history.migrateHistoryData();

            var migratedData = await asyncPrefs.get('saved_history');
            expect(migratedData).toEqual(testHistoryData);
            expect(iD.prefs(oldKey)).toBeNull();
        });
    });
});
