/**
 * Copyright (c) 2016 - 2022 Alex Grant (@localnerve), LocalNerve LLC
 * Copyrights licensed under the BSD License. See the accompanying LICENSE file for terms.
 */
/* global Promise, after, before, beforeEach, describe, it */

import { expect } from 'chai';
import _ from 'lodash';
import syncable from 'utils/syncable';
import mocks from 'test/mocks';
import Self from 'test/mocks/self';

describe('sw/sync/serviceable', () => {
  const subscriptionId = '123456789';
  const unexpectedFlowError = new Error('unexpected flow');

  let index, treoMock, toolboxMock, self;
  let serviceable;
  let dehydratedRequests;

  before('sw/sync/serviceable setup', function () {
    this.timeout(5000);

    mocks.swData.begin();
    mocks.swSyncIndex.begin();

    self = new Self();
    self.setup({
      pushManager: {
        subReject: false,
        subscribed: true
      }
    });

    treoMock = require('treo');
    toolboxMock = require('sw-toolbox');

    toolboxMock.mockSetup();
    treoMock.setValue(null);

    global.Request = require('test/mocks/request');
    global.Response = require('test/mocks/response');
    global.Blob = require('test/mocks/blob');

    index = require('application/client/sw/sync');
    serviceable = require('application/client/sw/sync/serviceable');
  });

  after('sw/sync/serviceable teardown', () => {
    delete global.Request;
    delete global.Response;
    delete global.Blob;

    toolboxMock.mockTeardown();
    self.teardown();
    mocks.swSyncIndex.end();
    mocks.swData.end();
  });

  function createDehydratedRequests (bodies) {
    // the side-effect output
    dehydratedRequests = [];

    treoMock.setReporter((method, key, request) => {
      if (method === 'put') {
        // allow the body to determine fake ordering via fake timestamp.
        request.timestamp = request.body.timestamp || request.timestamp;
        delete request.body.timestamp;

        dehydratedRequests.push(request);
      }
    });

    return Promise.all(bodies.map((body) =>
      index.deferRequest('/api', new global.Request('someurl', {
        body: body
      }))
    ));
  }

  describe('getRequests', () => {
    it('should get contact and topic requests', (done) => {
      createDehydratedRequests([
        syncable.contact({
          some1: 'body1'
        }, 'test1@email'),
        syncable.push({
          some2: 'body2',
          tag: 'someTopic'
        }, subscriptionId, syncable.ops.updateTopics)
      ])
        .then(() => {
          return serviceable.getRequests(dehydratedRequests);
        })
        .then((serviceableRequests) => {
          const diff = _.xor(dehydratedRequests, serviceableRequests);

          expect(diff.length).to.equal(0);
          done();
        });
    });

    it('should get both contact and unsubscribe requests', (done) => {
      createDehydratedRequests([
        syncable.contact({
          some1: 'body1'
        }, 'test1@email'),
        syncable.push({
          some2: 'body2'
        }, subscriptionId, syncable.ops.unsubscribe)
      ])
        .then(() => {
          return serviceable.getRequests(dehydratedRequests);
        })
        .then((serviceableRequests) => {
          const diff = _.xor(dehydratedRequests, serviceableRequests);

          expect(diff.length).to.equal(0);
          done();
        });
    });

    it('should get unsubscribe requests over topics requests', (done) => {
      createDehydratedRequests([
        syncable.push({
          some1: 'body1'
        }, subscriptionId, syncable.ops.updateTopics),
        syncable.push({
          some2: 'body2'
        }, subscriptionId, syncable.ops.unsubscribe)
      ])
        .then(() => {
          return serviceable.getRequests(dehydratedRequests);
        })
        .then((serviceableRequests) => {
          expect(serviceableRequests.length).to.equal(1);
          expect(serviceableRequests[0].fallback.operation)
            .to.equal(syncable.ops.unsubscribe);
          done();
        });
    });
  });

  describe('pruneRequests', () => {
    it('should remove unserviceable requests', (done) => {
      const updateTopicsTime = 1001;
      let calledDel = 0;

      createDehydratedRequests([
        syncable.push({
          some1: 'body1',
          timestamp: updateTopicsTime
        }, subscriptionId, syncable.ops.updateTopics),
        syncable.push({
          some2: 'body2',
          timestamp: updateTopicsTime + 1
        }, subscriptionId, syncable.ops.unsubscribe)
      ])
        .then(() => {
          return serviceable.getRequests(dehydratedRequests);
        })
        .then((serviceableRequests) => {
          // Replace previous reporter to listen for 'del'
          treoMock.setReporter((method, key) => {
            if (method === 'del') {
              // should've deleted updateTopics as unserviceable since it
              // received unsubscribe later (updateTopicsTime + 1).
              expect(key).to.equal(updateTopicsTime);
              calledDel++;
            }
          });

          return serviceable.pruneRequests(dehydratedRequests, serviceableRequests);
        })
        .then(() => {
          expect(calledDel).to.equal(1);
          done();
        });
    });
  });

  describe('pruneRequestsByPolicy', () => {
    /**
     * Remove a dehydrateRequest from dehydratedRequests if it matches
     * the given timestamp.
     *
     * @param {String} the timestamp to match.
     * @returns {Object} The matching dehydrateRequest.
     */
    function removeMatchingDehydratedRequest (timestamp) {
      var matchingRequest;

      dehydratedRequests = dehydratedRequests.reduce((prev, curr) => {
        if (curr.timestamp === timestamp) {
          matchingRequest = curr;
          return prev;
        }
        prev.push(curr);
        return prev;
      }, []);

      return matchingRequest;
    }

    it('should remove prior matching contact requests by policy', (done) => {
      let calledDel = 0;
      const contactKey = 'test1@email';
      const contactTime = 1001;
      const contactTimesToDelete = [
        contactTime,
        contactTime + 1
      ];

      createDehydratedRequests([
        syncable.contact({
          some1: 'body1',
          timestamp: contactTimesToDelete[0]
        }, contactKey),
        syncable.contact({
          some2: 'body2',
          timestamp: contactTimesToDelete[1]
        }, contactKey),
        syncable.contact({
          some3: 'body3',
          timestamp: contactTime + 2 // 'successful'
        }, contactKey)
      ])
        .then(() => {
          return removeMatchingDehydratedRequest(contactTime + 2);
        })
        .then((reqContact) => {
          // Replace previous reporter to listen for 'del'
          treoMock.setReporter((method, key) => {
            if (method === 'del') {
              expect(contactTimesToDelete.indexOf(key) !== -1).to.be.true;
              calledDel++;
            }
          });

          return serviceable.pruneRequestsByPolicy(
            dehydratedRequests, reqContact.fallback
          );
        })
        .then(() => {
          expect(calledDel).to.equal(dehydratedRequests.length);
          done();
        });
    });

    describe('push requests', () => {
      let calledDel;
      const pushTime = 1001;
      const pushTimesToDelete = [
        pushTime,
        pushTime + 1,
        pushTime + 2,
        pushTime + 3,
        pushTime + 4
      ];
      const pushTimesToIgnore = [
        pushTime + 5,
        pushTime + 6,
        pushTime + 7,
        pushTime + 8,
        pushTime + 9
      ];
      const pushTimeSuccess = pushTime + 10;

      beforeEach(() => {
        calledDel = 0;
      });

      /**
       * Verify that all dehydratedRequests are removed by pruneRequestsByPolicy.
       *
       * @param {Number} timestamp - The key of the successful dehydrated
       * request to remove from the dehydratedRequests collection to use as the
       * successful request.
       * @param {function} done - done callback.
       * @param {Number} [deleted] - The expected number of deletes (removals).
       * If falsy, then defaults to all.
       * @returns {Promise} resolves to undefined on completion.
       */
      function shouldRemove (timestamp, done, deleted) {
        const reqPush = removeMatchingDehydratedRequest(timestamp);

        // Replace previous reporter to listen for 'del'
        treoMock.setReporter((method, key) => {
          if (method === 'del') {
            expect(pushTimesToDelete.indexOf(key) !== -1).to.be.true;
            calledDel++;
          }
        });

        return serviceable.pruneRequestsByPolicy(
          dehydratedRequests, reqPush.fallback, reqPush
        )
          .then(() => {
            expect(calledDel).to.equal(deleted || dehydratedRequests.length);
            done();
          })
          .catch((error) => {
            done(error || unexpectedFlowError);
          });
      }

      it('successful subscribe should remove all', (done) => {
        createDehydratedRequests([
          syncable.push({
            some1: 'body1',
            timestamp: pushTimesToDelete[0]
          }, subscriptionId, syncable.ops.updateTopics),
          syncable.push({
            some2: 'body2',
            timestamp: pushTimesToDelete[1]
          }, subscriptionId, syncable.ops.unsubscribe),
          syncable.push({
            some3: 'body3',
            timestamp: pushTimeSuccess
          }, subscriptionId, syncable.ops.subscribe)
        ]).then(() => {
          shouldRemove(pushTimeSuccess, done);
        });
      });

      it('successful unsubscribe should remove all', (done) => {
        createDehydratedRequests([
          syncable.push({
            some1: 'body1',
            timestamp: pushTimesToDelete[0]
          }, subscriptionId, syncable.ops.updateTopics),
          syncable.push({
            some2: 'body2',
            timestamp: pushTimesToDelete[1]
          }, subscriptionId, syncable.ops.subscribe),
          syncable.push({
            some3: 'body3',
            timestamp: pushTimeSuccess
          }, subscriptionId, syncable.ops.unsubscribe)
        ])
          .then(() => {
            shouldRemove(pushTimeSuccess, done);
          });
      });

      it('successful subscribe should remove all except updateSubscription', (done) => {
        createDehydratedRequests([
          syncable.push({
            some1: 'body1',
            timestamp: pushTimesToDelete[0]
          }, subscriptionId, syncable.ops.updateTopics),
          syncable.push({
            some2: 'body2',
            timestamp: pushTimesToIgnore[0]
          }, subscriptionId, syncable.ops.updateSubscription),
          syncable.push({
            some3: 'body3',
            timestamp: pushTimeSuccess
          }, subscriptionId, syncable.ops.subscribe)
        ])
          .then(() => {
            shouldRemove(pushTimeSuccess, done, 1);
          });
      });

      it('successful updateTopics should remove unsub and updateTopics with same tag', (done) => {
        const updateTopicsTag = 'push-update-topic';

        createDehydratedRequests([
          syncable.push({
            body: {
              context: {},
              requests: {
                g0: {
                  body: {
                    topics: [{
                      subscribed: true,
                      tag: updateTopicsTag,
                      label: 'blah'
                    }]
                  },
                  params: {
                    subscriptionId: '123'
                  },
                  resource: 'blah'
                }
              }
            },
            timestamp: pushTimesToDelete[0]
          }, subscriptionId, syncable.ops.updateTopics),
          syncable.push({
            body: {
              context: {},
              requests: {
                g0: {
                  body: {
                    topics: [{
                      subscribed: true,
                      tag: 'push-update-topic-2',
                      label: 'blah'
                    }]
                  },
                  params: {
                    subscriptionId: '123'
                  },
                  resource: 'blah'
                }
              }
            },
            timestamp: pushTimesToIgnore[0]
          }, subscriptionId, syncable.ops.updateTopics),
          syncable.push({
            some2: 'body2',
            timestamp: pushTimesToIgnore[1]
          }, subscriptionId, syncable.ops.updateSubscription),
          syncable.push({
            some3: 'body3',
            timestamp: pushTimesToIgnore[2]
          }, subscriptionId, syncable.ops.subscribe),
          syncable.push({
            some3: 'body3',
            timestamp: pushTimesToDelete[1]
          }, subscriptionId, syncable.ops.unsubscribe),
          syncable.push({
            body: {
              context: {},
              requests: {
                g0: {
                  body: {
                    topics: [{
                      subscribed: true,
                      tag: updateTopicsTag,
                      label: 'blah'
                    }]
                  },
                  params: {
                    subscriptionId: '123'
                  },
                  resource: 'blah'
                }
              }
            },
            timestamp: pushTimeSuccess
          }, subscriptionId, syncable.ops.updateTopics)
        ])
          .then(() => {
            shouldRemove(pushTimeSuccess, done, 2);
          });
      });
    });

    it('should resolve to undefined if no policy found', (done) => {
      let calledDel = 0;
      const invalidType = 'invalid';

      expect(Object.keys(syncable.types).indexOf(invalidType)).to.equal(-1);

      // Replace previous reporter to listen for 'del'
      treoMock.setReporter((method) => {
        if (method === 'del') {
          calledDel++;
        }
      });

      serviceable.pruneRequestsByPolicy(null, {
        type: invalidType
      })
        .then((result) => {
          expect(result).to.be.undefined;
          expect(calledDel).to.equal(0);
          done();
        });
    });
  });

  describe('updatePushSubscription', () => {
    beforeEach(() => {
      treoMock.setReporter(null);
      treoMock.setValue(null);
    });

    it('should do nothing if falsy subscriptionId supplied', (done) => {
      let calledAll = 0;

      // Replace previous reporter to listen for 'all'
      treoMock.setReporter((method) => {
        if (method === 'all') {
          calledAll++;
        }
      });

      serviceable.updatePushSubscription(false)
        .then(() => {
          expect(calledAll).to.equal(0);
          done();
        })
        .catch((error) => {
          done(error || unexpectedFlowError);
        });
    });

    it('should not update if params not found in request', (done) => {
      let calledPut = 0;

      createDehydratedRequests([
        syncable.push({
          subscriptionId: subscriptionId
        }, subscriptionId, syncable.ops.subscribe)
      ]).then(() => {
        treoMock.setValue(dehydratedRequests);

        // Replace previous reporter to listen for 'put'
        treoMock.setReporter((method) => {
          if (method === 'put') {
            calledPut++;
          }
        });

        serviceable.updatePushSubscription(subscriptionId)
          .then(() => {
            expect(calledPut).to.equal(0);
            done();
          })
          .catch((error) => {
            done(error || unexpectedFlowError);
          });
      });
    });

    it('should update if params and subscriptionId found in request', (done) => {
      const property = require('utils/property');
      let calledPut = 0;
      let putParams;
      const testObject = syncable.push({
        params: {
          subscriptionId: subscriptionId
        }
      }, subscriptionId, syncable.ops.subscribe);

      const preUpdateTestObject = Object.assign({}, testObject);
      preUpdateTestObject.params = {
        subscriptionId: subscriptionId + '-offbyone'
      };

      createDehydratedRequests([
        preUpdateTestObject
      ]).then(() => {
        treoMock.setValue(dehydratedRequests);

        // Replace previous reporter to listen for 'put'
        treoMock.setReporter((method, key, value) => {
          if (method === 'put') {
            calledPut++;
            putParams = property.find('params', value);
          }
        });

        // Make sure params are different as a precondition
        const preParams = property.find('params', dehydratedRequests[0]);
        expect(preParams).to.exist.and.not.eql(testObject.params);

        serviceable.updatePushSubscription(subscriptionId)
          .then(() => {
            expect(calledPut).to.equal(1);
            expect(putParams).to.eql(testObject.params);
            done();
          })
          .catch((error) => {
            done(error || unexpectedFlowError);
          });
      });
    });
  });
});
