import { createOpenMct, resetApplicationState } from '../../utils/testing.js';
import ObjectAPI from './ObjectAPI.js';

describe('The Object API', () => {
  let objectAPI;
  let typeRegistry;
  let openmct = {};
  let mockDomainObject;
  const TEST_NAMESPACE = 'test-namespace';
  const TEST_KEY = 'test-key';
  const USERNAME = 'Joan Q Public';
  const FIFTEEN_MINUTES = 15 * 60 * 1000;

  beforeEach((done) => {
    typeRegistry = jasmine.createSpyObj('typeRegistry', ['get']);
    const userProvider = {
      isLoggedIn() {
        return true;
      },
      getCurrentUser() {
        return Promise.resolve({
          getName() {
            return USERNAME;
          }
        });
      },
      getPossibleRoles() {
        return Promise.resolve([]);
      }
    };
    openmct = createOpenMct();
    openmct.user.setProvider(userProvider);
    objectAPI = openmct.objects;

    openmct.editor = {};
    openmct.editor.isEditing = () => false;

    mockDomainObject = {
      identifier: {
        namespace: TEST_NAMESPACE,
        key: TEST_KEY
      },
      name: 'test object',
      type: 'test-type'
    };
    openmct.on('start', () => {
      done();
    });
    openmct.startHeadless();
  });

  afterEach(async () => {
    await resetApplicationState(openmct);
  });

  describe('The save function', () => {
    it('Rejects if no provider available', async () => {
      let rejected = false;
      objectAPI.providers = {};
      objectAPI.fallbackProvider = null;

      try {
        await objectAPI.save(mockDomainObject);
      } catch (error) {
        rejected = true;
      }

      expect(rejected).toBe(true);
    });
    describe('when a provider is available', () => {
      let mockProvider;
      beforeEach(() => {
        mockProvider = jasmine.createSpyObj('mock provider', ['create', 'update']);
        mockProvider.create.and.returnValue(Promise.resolve(true));
        mockProvider.update.and.returnValue(Promise.resolve(true));
        objectAPI.addProvider(TEST_NAMESPACE, mockProvider);
      });
      it("Adds a 'created' timestamp to new objects", async () => {
        await objectAPI.save(mockDomainObject);
        expect(mockDomainObject.created).not.toBeUndefined();
      });
      it("Calls 'create' on provider if object is new", async () => {
        await objectAPI.save(mockDomainObject);
        expect(mockProvider.create).toHaveBeenCalled();
        expect(mockProvider.update).not.toHaveBeenCalled();
      });
      it("Calls 'update' on provider if object is not new", async () => {
        mockDomainObject.persisted = Date.now() - FIFTEEN_MINUTES;
        mockDomainObject.modified = Date.now();

        await objectAPI.save(mockDomainObject);
        expect(mockProvider.create).not.toHaveBeenCalled();
        expect(mockProvider.update).toHaveBeenCalled();
      });
      describe('the persisted timestamp for existing objects', () => {
        let persistedTimestamp;
        beforeEach(() => {
          persistedTimestamp = Date.now() - FIFTEEN_MINUTES;
          mockDomainObject.persisted = persistedTimestamp;
          mockDomainObject.modified = Date.now();
        });

        it('is updated', async () => {
          await objectAPI.save(mockDomainObject);
          expect(mockDomainObject.persisted).toBeDefined();
          expect(mockDomainObject.persisted > persistedTimestamp).toBe(true);
        });
        it('is >= modified timestamp', async () => {
          await objectAPI.save(mockDomainObject);
          expect(mockDomainObject.persisted >= mockDomainObject.modified).toBe(true);
        });
      });
      describe('the persisted timestamp for new objects', () => {
        it('is updated', async () => {
          await objectAPI.save(mockDomainObject);
          expect(mockDomainObject.persisted).toBeDefined();
        });
        it('is >= modified timestamp', async () => {
          await objectAPI.save(mockDomainObject);
          expect(mockDomainObject.persisted >= mockDomainObject.modified).toBe(true);
        });
      });

      it("Sets the current user for 'createdBy' on new objects", async () => {
        await objectAPI.save(mockDomainObject);
        expect(mockDomainObject.createdBy).toBe(USERNAME);
      });
      it("Sets the current user for 'modifiedBy' on existing objects", async () => {
        mockDomainObject.persisted = Date.now() - FIFTEEN_MINUTES;
        mockDomainObject.modified = Date.now();

        await objectAPI.save(mockDomainObject);
        expect(mockDomainObject.modifiedBy).toBe(USERNAME);
      });

      it('Does not persist if the object is unchanged', () => {
        mockDomainObject.persisted = mockDomainObject.modified = Date.now();

        objectAPI.save(mockDomainObject);
        expect(mockProvider.create).not.toHaveBeenCalled();
        expect(mockProvider.update).not.toHaveBeenCalled();
      });

      describe('Shows a notification on persistence conflict', () => {
        beforeEach(() => {
          openmct.notifications.error = jasmine.createSpy('error');
        });

        it('on create', () => {
          mockProvider.create.and.returnValue(
            Promise.reject(new openmct.objects.errors.Conflict('Test Conflict error'))
          );

          return objectAPI.save(mockDomainObject).catch(() => {
            expect(openmct.notifications.error).toHaveBeenCalledWith(
              `Conflict detected while saving ${TEST_NAMESPACE}:${TEST_KEY}`
            );
          });
        });

        it('on update', () => {
          mockProvider.update.and.returnValue(
            Promise.reject(new openmct.objects.errors.Conflict('Test Conflict error'))
          );
          mockDomainObject.persisted = Date.now() - FIFTEEN_MINUTES;
          mockDomainObject.modified = Date.now();

          return objectAPI.save(mockDomainObject).catch(() => {
            expect(openmct.notifications.error).toHaveBeenCalledWith(
              `Conflict detected while saving ${TEST_NAMESPACE}:${TEST_KEY}`
            );
          });
        });
      });
    });
  });

  describe('The get function', () => {
    describe('when a provider is available', () => {
      let mockProvider;
      let mockInterceptor;
      let anotherMockInterceptor;
      let notApplicableMockInterceptor;
      beforeEach(() => {
        mockProvider = jasmine.createSpyObj('mock provider', ['get']);
        mockProvider.get.and.returnValue(Promise.resolve(mockDomainObject));

        mockInterceptor = jasmine.createSpyObj('mock interceptor', ['appliesTo', 'invoke']);
        mockInterceptor.appliesTo.and.returnValue(true);
        mockInterceptor.invoke.and.callFake((identifier, object) => {
          return Object.assign(
            {
              changed: true
            },
            object
          );
        });

        anotherMockInterceptor = jasmine.createSpyObj('another mock interceptor', [
          'appliesTo',
          'invoke'
        ]);
        anotherMockInterceptor.appliesTo.and.returnValue(true);
        anotherMockInterceptor.invoke.and.callFake((identifier, object) => {
          return Object.assign(
            {
              alsoChanged: true
            },
            object
          );
        });

        notApplicableMockInterceptor = jasmine.createSpyObj('not applicable mock interceptor', [
          'appliesTo',
          'invoke'
        ]);
        notApplicableMockInterceptor.appliesTo.and.returnValue(false);
        notApplicableMockInterceptor.invoke.and.callFake((identifier, object) => {
          return Object.assign(
            {
              shouldNotBeChanged: true
            },
            object
          );
        });
        objectAPI.addProvider(TEST_NAMESPACE, mockProvider);
        objectAPI.addGetInterceptor(mockInterceptor);
        objectAPI.addGetInterceptor(anotherMockInterceptor);
        objectAPI.addGetInterceptor(notApplicableMockInterceptor);
      });

      it('Caches multiple requests for the same object', () => {
        const promises = [];
        expect(mockProvider.get.calls.count()).toBe(0);
        promises.push(objectAPI.get(mockDomainObject.identifier));
        expect(mockProvider.get.calls.count()).toBe(1);
        promises.push(objectAPI.get(mockDomainObject.identifier));
        expect(mockProvider.get.calls.count()).toBe(1);

        return Promise.all(promises);
      });

      it('applies any applicable interceptors', () => {
        expect(mockDomainObject.changed).toBeUndefined();

        return objectAPI.get(mockDomainObject.identifier).then((object) => {
          expect(object.changed).toBeTrue();
          expect(object.alsoChanged).toBeTrue();
          expect(object.shouldNotBeChanged).toBeUndefined();
        });
      });

      it('displays a notification in the event of an error', () => {
        openmct.notifications.warn = jasmine.createSpy('warn');
        mockProvider.get.and.returnValue(
          Promise.reject({
            name: 'Error',
            status: 404,
            statusText: 'Not Found'
          })
        );

        return objectAPI.get(mockDomainObject.identifier).catch(() => {
          expect(openmct.notifications.warn).toHaveBeenCalledWith(
            `Failed to retrieve object ${TEST_NAMESPACE}:${TEST_KEY}`
          );
        });
      });
    });
  });

  describe('the mutation API', () => {
    let testObject;
    let updatedTestObject;
    let mutable;
    let mockProvider;
    let callbacks = [];

    beforeEach(function () {
      objectAPI = new ObjectAPI(typeRegistry, openmct);
      testObject = {
        identifier: {
          namespace: TEST_NAMESPACE,
          key: TEST_KEY
        },
        name: 'test object',
        type: 'notebook',
        otherAttribute: 'other-attribute-value',
        modified: 0,
        persisted: 0,
        objectAttribute: {
          embeddedObject: {
            embeddedKey: 'embedded-value'
          }
        }
      };
      updatedTestObject = Object.assign(
        {
          otherAttribute: 'changed-attribute-value'
        },
        testObject
      );
      updatedTestObject.modified = 1;
      updatedTestObject.persisted = 1;

      mockProvider = jasmine.createSpyObj('mock provider', [
        'get',
        'create',
        'update',
        'observe',
        'observeObjectChanges'
      ]);
      mockProvider.get.and.returnValue(Promise.resolve(testObject));
      mockProvider.create.and.returnValue(Promise.resolve(true));
      mockProvider.update.and.returnValue(Promise.resolve(true));
      mockProvider.observeObjectChanges.and.callFake(() => {
        callbacks[0](updatedTestObject);
        callbacks.splice(0, 1);

        return () => {};
      });
      mockProvider.observe.and.callFake((id, callback) => {
        if (callbacks.length === 0) {
          callbacks.push(callback);
        } else {
          callbacks[0] = callback;
        }

        return () => {};
      });

      objectAPI.addProvider(TEST_NAMESPACE, mockProvider);

      return objectAPI.getMutable(testObject.identifier).then((object) => {
        mutable = object;

        return mutable;
      });
    });

    afterEach(() => {
      mutable.$destroy();
    });

    it('mutates the original object', () => {
      const MUTATED_NAME = 'mutated name';
      objectAPI.mutate(testObject, 'name', MUTATED_NAME);
      expect(testObject.name).toBe(MUTATED_NAME);
    });

    it('Provides a way of refreshing an object from the persistence store', () => {
      const modifiedTestObject = JSON.parse(JSON.stringify(testObject));
      const OTHER_ATTRIBUTE_VALUE = 'Modified value';
      const NEW_ATTRIBUTE_VALUE = 'A new attribute';
      modifiedTestObject.otherAttribute = OTHER_ATTRIBUTE_VALUE;
      modifiedTestObject.newAttribute = NEW_ATTRIBUTE_VALUE;
      delete modifiedTestObject.objectAttribute;

      spyOn(objectAPI, 'get');
      objectAPI.get.and.returnValue(Promise.resolve(modifiedTestObject));

      expect(objectAPI.get).not.toHaveBeenCalled();

      return objectAPI.refresh(testObject).then(() => {
        expect(objectAPI.get).toHaveBeenCalledWith(testObject.identifier, null, false);

        expect(testObject.otherAttribute).toEqual(OTHER_ATTRIBUTE_VALUE);
        expect(testObject.newAttribute).toEqual(NEW_ATTRIBUTE_VALUE);
        expect(testObject.objectAttribute).not.toBeDefined();
      });
    });

    describe('uses a MutableDomainObject', () => {
      it('and retains properties of original object ', function () {
        expect(hasOwnProperty(mutable, 'identifier')).toBe(true);
        expect(hasOwnProperty(mutable, 'otherAttribute')).toBe(true);
        expect(mutable.identifier).toEqual(testObject.identifier);
        expect(mutable.otherAttribute).toEqual(testObject.otherAttribute);
      });

      it('that is identical to original object when serialized', function () {
        expect(JSON.stringify(mutable)).toEqual(JSON.stringify(testObject));
      });

      it('that observes for object changes', function () {
        let mockListener = jasmine.createSpy('mockListener');
        objectAPI.observe(testObject, '*', mockListener);
        mockProvider.observeObjectChanges();
        expect(mockListener).toHaveBeenCalled();
      });
    });

    describe('uses events', function () {
      let testObjectDuplicate;
      let mutableSecondInstance;

      beforeEach(function () {
        // Duplicate object to guarantee we are not sharing object instance, which would invalidate test
        testObjectDuplicate = JSON.parse(JSON.stringify(testObject));
        mutableSecondInstance = objectAPI.toMutable(testObjectDuplicate);
      });

      afterEach(() => {
        mutableSecondInstance.$destroy();
      });

      it('to stay synchronized when mutated', function () {
        objectAPI.mutate(mutable, 'otherAttribute', 'new-attribute-value');
        expect(mutableSecondInstance.otherAttribute).toBe('new-attribute-value');
      });

      it('to indicate when a property changes', function () {
        let mutationCallback = jasmine.createSpy('mutation-callback');
        let unlisten;

        return new Promise(function (resolve) {
          mutationCallback.and.callFake(resolve);
          unlisten = objectAPI.observe(mutableSecondInstance, 'otherAttribute', mutationCallback);
          objectAPI.mutate(mutable, 'otherAttribute', 'some-new-value');
        }).then(function () {
          expect(mutationCallback).toHaveBeenCalledWith('some-new-value', 'other-attribute-value');
          unlisten();
        });
      });

      it('to indicate when a child property has changed', function () {
        let embeddedKeyCallback = jasmine.createSpy('embeddedKeyCallback');
        let embeddedObjectCallback = jasmine.createSpy('embeddedObjectCallback');
        let objectAttributeCallback = jasmine.createSpy('objectAttribute');
        let listeners = [];

        return new Promise(function (resolve) {
          objectAttributeCallback.and.callFake(resolve);

          listeners.push(
            objectAPI.observe(
              mutableSecondInstance,
              'objectAttribute.embeddedObject.embeddedKey',
              embeddedKeyCallback
            )
          );
          listeners.push(
            objectAPI.observe(
              mutableSecondInstance,
              'objectAttribute.embeddedObject',
              embeddedObjectCallback
            )
          );
          listeners.push(
            objectAPI.observe(mutableSecondInstance, 'objectAttribute', objectAttributeCallback)
          );

          objectAPI.mutate(
            mutable,
            'objectAttribute.embeddedObject.embeddedKey',
            'updated-embedded-value'
          );
        }).then(function () {
          expect(embeddedKeyCallback).toHaveBeenCalledWith(
            'updated-embedded-value',
            'embedded-value'
          );
          expect(embeddedObjectCallback).toHaveBeenCalledWith(
            {
              embeddedKey: 'updated-embedded-value'
            },
            {
              embeddedKey: 'embedded-value'
            }
          );
          expect(objectAttributeCallback).toHaveBeenCalledWith(
            {
              embeddedObject: {
                embeddedKey: 'updated-embedded-value'
              }
            },
            {
              embeddedObject: {
                embeddedKey: 'embedded-value'
              }
            }
          );

          listeners.forEach((listener) => listener());
        });
      });
    });
  });

  describe('getOriginalPath', () => {
    let mockGrandParentObject;
    let mockParentObject;
    let mockChildObject;

    beforeEach(() => {
      const mockObjectProvider = jasmine.createSpyObj('mock object provider', [
        'create',
        'update',
        'get'
      ]);

      mockGrandParentObject = {
        type: 'folder',
        name: 'Grand Parent Folder',
        location: 'fooNameSpace:child',
        identifier: {
          key: 'grandParent',
          namespace: 'fooNameSpace'
        }
      };
      mockParentObject = {
        type: 'folder',
        name: 'Parent Folder',
        location: 'fooNameSpace:grandParent',
        identifier: {
          key: 'parent',
          namespace: 'fooNameSpace'
        }
      };
      mockChildObject = {
        type: 'folder',
        name: 'Child Folder',
        location: 'fooNameSpace:parent',
        identifier: {
          key: 'child',
          namespace: 'fooNameSpace'
        }
      };

      // eslint-disable-next-line require-await
      mockObjectProvider.get = async (identifier) => {
        if (identifier.key === mockGrandParentObject.identifier.key) {
          return mockGrandParentObject;
        } else if (identifier.key === mockParentObject.identifier.key) {
          return mockParentObject;
        } else if (identifier.key === mockChildObject.identifier.key) {
          return mockChildObject;
        } else {
          return null;
        }
      };

      openmct.objects.addProvider('fooNameSpace', mockObjectProvider);

      mockObjectProvider.create.and.returnValue(Promise.resolve(true));
      mockObjectProvider.update.and.returnValue(Promise.resolve(true));

      openmct.objects.addProvider('fooNameSpace', mockObjectProvider);
    });

    it('can construct paths even with cycles', async () => {
      const objectPath = await objectAPI.getOriginalPath(mockChildObject.identifier);
      expect(objectPath.length).toEqual(3);
    });
  });

  describe('transactions', () => {
    beforeEach(() => {
      spyOn(openmct.editor, 'isEditing').and.returnValue(true);
    });

    it('there is no active transaction', () => {
      expect(objectAPI.isTransactionActive()).toBe(false);
    });

    it('start a transaction', () => {
      objectAPI.startTransaction();
      expect(objectAPI.isTransactionActive()).toBe(true);
    });

    it('has active transaction', () => {
      objectAPI.startTransaction();
      const activeTransaction = objectAPI.getActiveTransaction();
      expect(activeTransaction).not.toBe(null);
    });

    it('end a transaction', () => {
      objectAPI.endTransaction();
      expect(objectAPI.isTransactionActive()).toBe(false);
    });

    it('returns dirty object on get', (done) => {
      spyOn(objectAPI, 'supportsMutation').and.returnValue(true);

      objectAPI.startTransaction();
      objectAPI.mutate(mockDomainObject, 'name', 'dirty object');

      const dirtyObject = objectAPI.transaction.getDirtyObject(mockDomainObject.identifier);

      objectAPI
        .get(mockDomainObject.identifier)
        .then((object) => {
          const areEqual = JSON.stringify(object) === JSON.stringify(dirtyObject);
          expect(areEqual).toBe(true);
        })
        .finally(done);
    });
  });
});

function hasOwnProperty(object, property) {
  return Object.prototype.hasOwnProperty.call(object, property);
}
