define(['lodash', 'taoQtiTest/runner/navigator/navigator', 'taoQtiTest/runner/helpers/map', 'taoQtiTest/runner/helpers/navigation', 'taoQtiTest/runner/provider/dataUpdater', 'taoQtiTest/runner/proxy/qtiServiceProxy', 'taoQtiTest/runner/proxy/cache/itemStore', 'taoQtiTest/runner/proxy/cache/actionStore', 'taoQtiTest/runner/helpers/offlineErrorHelper'], function (_, testNavigatorFactory, mapHelper, navigationHelper, dataUpdater, qtiServiceProxy, itemStoreFactory, actionStoreFactory, offlineErrorHelper) { 'use strict';

    _ = _ && Object.prototype.hasOwnProperty.call(_, 'default') ? _['default'] : _;
    testNavigatorFactory = testNavigatorFactory && Object.prototype.hasOwnProperty.call(testNavigatorFactory, 'default') ? testNavigatorFactory['default'] : testNavigatorFactory;
    mapHelper = mapHelper && Object.prototype.hasOwnProperty.call(mapHelper, 'default') ? mapHelper['default'] : mapHelper;
    navigationHelper = navigationHelper && Object.prototype.hasOwnProperty.call(navigationHelper, 'default') ? navigationHelper['default'] : navigationHelper;
    dataUpdater = dataUpdater && Object.prototype.hasOwnProperty.call(dataUpdater, 'default') ? dataUpdater['default'] : dataUpdater;
    qtiServiceProxy = qtiServiceProxy && Object.prototype.hasOwnProperty.call(qtiServiceProxy, 'default') ? qtiServiceProxy['default'] : qtiServiceProxy;
    itemStoreFactory = itemStoreFactory && Object.prototype.hasOwnProperty.call(itemStoreFactory, 'default') ? itemStoreFactory['default'] : itemStoreFactory;
    actionStoreFactory = actionStoreFactory && Object.prototype.hasOwnProperty.call(actionStoreFactory, 'default') ? actionStoreFactory['default'] : actionStoreFactory;
    offlineErrorHelper = offlineErrorHelper && Object.prototype.hasOwnProperty.call(offlineErrorHelper, 'default') ? offlineErrorHelper['default'] : offlineErrorHelper;

    /**
     * This program is free software; you can redistribute it and/or
     * modify it under the terms of the GNU General Public License
     * as published by the Free Software Foundation; under version 2
     * of the License (non-upgradable).
     *
     * This program is distributed in the hope that it will be useful,
     * but WITHOUT ANY WARRANTY; without even the implied warranty of
     * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     * GNU General Public License for more details.
     *
     * You should have received a copy of the GNU General Public License
     * along with this program; if not, write to the Free Software
     * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
     *
     * Copyright (c) 2017-2021 Open Assessment Technologies SA
     */

    /**
     * The number of items to keep in the cache
     * @type {number}
     * @private
     */
    const cacheSize = 20;

    /**
     * The number of ms to wait after an item is loaded
     * to start loading the next.
     * This value is more or less the time needed to render an item.
     * @type {number}
     * @private
     */
    const loadNextDelay = 450;

    /**
     * The default TimeToLive for assets resolving, in seconds.
     * Each item comes with a baseUrl that may have a TTL bound to it.
     * Once this TTL is expired, the assets won't be reachable.
     * For this reason, we need to remove from the cache items having an expired TTL.
     * @type {number}
     * @private
     */
    const defaultItemTTL = 15 * 60;

    /**
     * Overrides the qtiServiceProxy with the precaching behavior
     * @extends taoQtiTest/runner/proxy/qtiServiceProxy
     */
    var proxy = _.defaults({
      name: 'precaching',
      /**
       * Installs the proxy
       * @param {object} config
       */
      install(config) {
        //install the parent proxy
        qtiServiceProxy.install.call(this);

        /**
         * Gets the value of an item caching option. All values are numeric only.
         * @param {string} name
         * @param {number} defaultValue
         * @returns {number}
         */
        const getItemCachingOption = (name, defaultValue) => {
          if (config && config.options && config.options.itemCaching) {
            return parseInt(config.options.itemCaching[name], 10) || defaultValue;
          }
          return defaultValue;
        };

        //we keep items here
        this.itemStore = itemStoreFactory({
          itemTTL: defaultItemTTL * 1000,
          maxSize: cacheSize,
          preload: true,
          testId: config.serviceCallId
        });

        //where we keep actions
        this.actiontStore = null;

        //can we load the next item from the cache/store ?
        this.getItemFromStore = false;

        //configuration params, that comes on every request/params
        this.requestConfig = {};

        //scheduled action promises which supposed to be resolved after action synchronization.
        this.actionPromises = {};

        //scheduled action reject promises which supposed to be rejected in case of failed synchronization.
        this.actionRejectPromises = {};

        //let's you update test data (testContext and testMap)
        this.dataUpdater = dataUpdater(this.getDataHolder());

        /**
         * Get the item cache size from the test data
         * @returns {number} the cache size
         */
        this.getCacheAmount = () => getItemCachingOption('amount', 1);

        /**
         * Get the item store TimeToLive
         * @returns {number} the item store TTL
         */
        this.getItemTTL = () => getItemCachingOption('itemStoreTTL', defaultItemTTL) * 1000;

        /**
         * Check whether we have the item in the store
         * @param {string} itemIdentifier - the item identifier
         * @returns {boolean}
         */
        this.hasItem = itemIdentifier => itemIdentifier && this.itemStore.has(itemIdentifier);

        /**
         * Check whether we have the next item in the store
         * @param {string} itemIdentifier - the CURRENT item identifier
         * @returns {boolean}
         */
        this.hasNextItem = itemIdentifier => {
          const sibling = navigationHelper.getNextItem(this.getDataHolder().get('testMap'), itemIdentifier);
          return sibling && this.hasItem(sibling.id);
        };

        /**
         * Check whether we have the previous item in the store
         * @param {string} itemIdentifier - the CURRENT item identifier
         * @returns {boolean}
         */
        this.hasPreviousItem = itemIdentifier => {
          const sibling = navigationHelper.getPreviousItem(this.getDataHolder().get('testMap'), itemIdentifier);
          return sibling && this.hasItem(sibling.id);
        };

        /**
         * Offline ? We try to navigate offline, or just say 'ok'
         *
         * @param {string} action - the action name (ie. move, skip, timeout)
         * @param {object} actionParams - the parameters sent along the action
         * @returns {object} action result
         */
        this.offlineAction = (action, actionParams) => {
          const result = {
            success: true
          };
          const blockingActions = ['exitTest', 'timeout'];
          const testContext = this.getDataHolder().get('testContext');
          const testMap = this.getDataHolder().get('testMap');
          if (action === 'pause') {
            throw offlineErrorHelper.buildErrorFromContext(offlineErrorHelper.getOfflinePauseError(), {
              reason: actionParams.reason
            });
          }

          //we just block those actions and the end of the test
          if (blockingActions.includes(action) || actionParams.direction === 'next' && navigationHelper.isLast(testMap, testContext.itemIdentifier)) {
            throw offlineErrorHelper.buildErrorFromContext(offlineErrorHelper.getOfflineExitError());
          }

          // try the navigation if the actionParams context meaningful data
          if (actionParams.direction && actionParams.scope) {
            const testNavigator = testNavigatorFactory(testContext, testMap);
            const newTestContext = testNavigator.navigate(actionParams.direction, actionParams.scope, actionParams.ref);

            //we are really not able to navigate
            if (!newTestContext || !newTestContext.itemIdentifier || !this.hasItem(newTestContext.itemIdentifier)) {
              throw offlineErrorHelper.buildErrorFromContext(offlineErrorHelper.getOfflineNavError());
            }
            result.testContext = newTestContext;
          }
          this.markActionAsOffline(actionParams);
          return result;
        };

        /**
         * Process action which should be sent using message channel.
         *
         * @param {string} action
         * @param {object} actionParams
         * @param {boolean} deferred
         *
         * @returns {Promise} resolves with the action result
         */
        this.processSyncAction = (action, actionParams, deferred) => {
          return new Promise((resolve, reject) => {
            this.scheduleAction(action, actionParams).then(actionData => {
              this.actionPromises[actionData.params.actionId] = resolve;
              this.actionRejectPromises[actionData.params.actionId] = reject;
              if (!deferred) {
                this.syncData().then(result => {
                  if (this.isOnline()) {
                    _.forEach(result, actionResult => {
                      const actionId = actionResult.requestParameters && actionResult.requestParameters.actionId ? actionResult.requestParameters.actionId : null;
                      if (!actionResult.success && this.actionRejectPromises[actionId]) {
                        const error = new Error(actionResult.message);
                        error.unrecoverable = true;
                        return reject(error);
                      }
                      if (actionId && this.actionPromises[actionId]) {
                        this.actionPromises[actionId](actionResult);
                      }
                    });
                  }
                }).catch(reject);
              }
            }).catch(reject);
          });
        };

        /**
         * Schedule an action do be done with next call
         *
         * @param {string} action - the action name (ie. move, skip, timeout)
         * @param {object} params - the parameters sent along the action
         * @returns {Promise} resolves with the action data
         */
        this.scheduleAction = (action, params) => {
          params.actionId = `${action}_${new Date().getTime()}`;
          return this.actiontStore.push(action, this.prepareParams(_.defaults(params || {}, this.requestConfig))).then(() => ({
            action,
            params
          }));
        };

        /**
         * Request/Offline strategy :
         *
         * ├─ Online
         * │  └─ run the request
         * │    ├─ request ok
         * │    └─ request fails
         * │       └─ run the offline action
         * └── Offline
         *    └─ send a telemetry request (connection could be back)
         *      ├─ request ok
         *      │  └─ sync data
         *      │     └─  run the request (back to the tree root)
         *      └─ request fails
         *         └─ run the offline action
         *
         * @param {string} url
         * @param {string} action - the action name (ie. move, skip, timeout)
         * @param {object} actionParams - the parameters sent along the action
         * @param {boolean} deferred whether action can be scheduled (put into queue) to be sent in a bunch of actions later.
         * @param {boolean} noToken whether the request should be sent with a CSRF token or not
         *
         * @returns {Promise} resolves with the action result
         */
        this.requestNetworkThenOffline = (url, action, actionParams, deferred, noToken) => {
          const testContext = this.getDataHolder().get('testContext');
          const communicationConfig = this.configStorage.getCommunicationConfig();

          //perform the request, but fallback on offline if the request itself fails
          const runRequestThenOffline = () => {
            let request;
            if (communicationConfig.syncActions && communicationConfig.syncActions.indexOf(action) >= 0) {
              request = this.processSyncAction(action, actionParams, deferred);
            } else {
              //action is not synchronizable
              //fallback to direct request
              request = this.request(url, actionParams, void 0, noToken || false);
              request.then(result => {
                if (this.isOffline()) {
                  return this.scheduleAction(action, actionParams);
                }
                return result;
              });
            }
            return request.then(result => {
              if (this.isOffline()) {
                return this.offlineAction(action, actionParams);
              }
              return result;
            }).catch(error => {
              if (this.isConnectivityError(error) && this.isOffline()) {
                return this.offlineAction(action, actionParams);
              }
              throw error;
            });
          };
          if (this.isOffline()) {
            //try the telemetry action, just in case
            return this.telemetry(testContext.itemIdentifier, 'up').then(() => {
              //if the up request succeed, we run the request
              if (this.isOnline()) {
                return runRequestThenOffline();
              }
              return this.scheduleAction(action, actionParams).then(() => {
                return this.offlineAction(action, actionParams);
              });
            }).catch(err => {
              if (this.isConnectivityError(err)) {
                return this.scheduleAction(action, actionParams).then(() => {
                  return this.offlineAction(action, actionParams);
                });
              }
              throw err;
            });
          }

          //by default we try to run the request first
          return runRequestThenOffline();
        };

        /**
         * Flush and synchronize actions collected while offline
         * @returns {Promise} resolves with the action result
         */
        this.syncData = () => {
          let actions;
          return this.queue.serie(() => {
            return this.actiontStore.flush().then(data => {
              actions = data;
              if (data && data.length) {
                return this.send('sync', data);
              }
            }).catch(err => {
              if (this.isConnectivityError(err)) {
                this.setOffline('communicator');
                _.forEach(actions, action => {
                  this.actiontStore.push(action.action, action.parameters);
                });
              }
              throw err;
            });
          });
        };

        /**
         * Flush the offline actions from the actionStore before reinserting them.
         * The exported copy can be used for file download.
         * The retained copy can still be synced as the test progresses.
         *
         * @returns {Promise} resolves with the store contents
         */
        this.exportActions = () => {
          return this.queue.serie(() => {
            return this.actiontStore.flush().then(data => {
              _.forEach(data, action => {
                this.actiontStore.push(action.action, action.parameters);
              });
              return data;
            });
          });
        };

        /**
         * Mark action as performed in offline mode
         * Action to mark as offline will be defined by actionParams.actionId parameter value.
         *
         * @param {object} actionParams - the action parameters
         * @returns {Promise}
         */
        this.markActionAsOffline = actionParams => {
          actionParams.offline = true;
          return this.queue.serie(() => {
            return this.actiontStore.update(this.prepareParams(_.defaults(actionParams || {}, this.requestConfig)));
          });
        };
      },
      /**
       * Initializes the proxy
       * @param {object} config - The config provided to the proxy factory
       * @param {string} config.testDefinition - The URI of the test
       * @param {string} config.testCompilation - The URI of the compiled delivery
       * @param {string} config.serviceCallId - The URI of the service call
       * @param {object} [params] - Some optional parameters to join to the call
       * @returns {Promise} - Returns a promise. The proxy will be fully initialized on resolve.
       *                      Any error will be provided if rejected.
       */
      init(config, params) {
        if (!this.getDataHolder()) {
          throw new Error('Unable to retrieve test runners data holder');
        }

        //those needs to be in each request params.
        this.requestConfig = _.pick(config, ['testDefinition', 'testCompilation', 'serviceCallId']);

        //set up the action store for the current service call
        this.actiontStore = actionStoreFactory(config.serviceCallId);

        //we resynchronise as soon as the connection is back
        this.on('reconnect', function () {
          return this.syncData().then(responses => {
            this.dataUpdater.update(responses);
          }).catch(err => {
            this.trigger('error', err);
          });
        });

        //if some actions remains not synchronized
        this.syncData();

        //run the init
        return qtiServiceProxy.init.call(this, config, params);
      },
      /**
       * Uninstalls the proxy
       * @returns {Promise} - Returns a promise. The proxy will be fully uninstalled on resolve.
       *                      Any error will be provided if rejected.
       */
      destroy() {
        this.itemStore.clear();
        this.getItemFromStore = false;
        return qtiServiceProxy.destroy.call(this);
      },
      /**
       * Gets an item definition by its identifier, also gets its current state
       * @param {string} itemIdentifier - The identifier of the item to get
       * @param {object} [params] - additional parameters
       * @returns {Promise} - Returns a promise. The item data will be provided on resolve.
       *                      Any error will be provided if rejected.
       */
      getItem(itemIdentifier, params) {
        // remove the expired entries from the cache
        // prune anyway, if an issue occurs it should not prevent the remaining process to happen
        const pruneStore = () => this.itemStore.prune().catch(_.noop);

        /**
         * try to load the next items
         */
        const loadNextItem = () => {
          const testMap = this.getDataHolder().get('testMap');
          const siblings = navigationHelper.getSiblingItems(testMap, itemIdentifier, 'both', this.getCacheAmount());
          const missing = _.reduce(siblings, (list, sibling) => {
            if (!this.hasItem(sibling.id)) {
              list.push(sibling.id);
            }
            return list;
          }, []);

          //don't run a request if not needed
          if (this.isOnline() && missing.length) {
            _.delay(() => {
              this.requestNetworkThenOffline(this.configStorage.getTestActionUrl('getNextItemData'), 'getNextItemData', {
                itemDefinition: missing
              }, false, true).then(response => {
                if (response && response.items) {
                  return pruneStore().then(() => {
                    _.forEach(response.items, item => {
                      if (item && item.itemIdentifier) {
                        //store the response and start caching assets
                        this.itemStore.set(item.itemIdentifier, item);
                      }
                    });
                  });
                }
              }).catch(_.noop);
            }, loadNextDelay);
          }
        };

        // the additional proxy options are supplied after the 'init' phase as a result of the `init` action,
        // we need to apply them later
        this.itemStore.setItemTTL(this.getItemTTL());

        //resolve from the store
        if (this.getItemFromStore && this.itemStore.has(itemIdentifier)) {
          loadNextItem();
          return this.itemStore.get(itemIdentifier);
        }
        return this.request(this.configStorage.getItemActionUrl(itemIdentifier, 'getItem'), params, void 0, true).then(response => {
          if (response && response.success) {
            pruneStore().then(() => this.itemStore.set(itemIdentifier, response));
          }
          loadNextItem();
          return response;
        });
      },
      /**
       * Submits the state and the response of a particular item
       * @param {string} itemIdentifier - The identifier of the item to update
       * @param {object} state - The state to submit
       * @param {object} response - The response object to submit
       * @param {object} [params] - Some optional parameters to join to the call
       * @returns {Promise} - Returns a promise. The result of the request will be provided on resolve.
       *                      Any error will be provided if rejected.
       */
      submitItem(itemIdentifier, state, response, params) {
        return this.itemStore.update(itemIdentifier, 'itemState', state).then(() => {
          return qtiServiceProxy.submitItem.call(this, itemIdentifier, state, response, params);
        });
      },
      /**
       * Sends the test variables
       * @param {object} variables
       * @param {boolean} deferred whether action can be scheduled (put into queue) to be sent in a bunch of actions later.
       * @returns {Promise} - Returns a promise. The result of the request will be provided on resolve.
       *                      Any error will be provided if rejected.
       * @fires sendVariables
       */
      sendVariables(variables, deferred) {
        const action = 'storeTraceData';
        const actionParams = {
          traceData: JSON.stringify(variables)
        };
        return this.requestNetworkThenOffline(this.configStorage.getTestActionUrl(action), action, actionParams, deferred);
      },
      /**
       * Calls an action related to the test
       * @param {string} action - The name of the action to call
       * @param {object} [params] - Some optional parameters to join to the call
       * @param {boolean} deferred whether action can be scheduled (put into queue) to be sent in a bunch of actions later.
       * @returns {Promise} - Returns a promise. The result of the request will be provided on resolve.
       *                      Any error will be provided if rejected.
       */
      callTestAction(action, params, deferred) {
        return this.requestNetworkThenOffline(this.configStorage.getTestActionUrl(action), action, params, deferred);
      },
      /**
       * Calls an action related to a particular item
       * @param {string} itemIdentifier - The identifier of the item for which call the action
       * @param {string} action - The name of the action to call
       * @param {object} [params] - Some optional parameters to join to the call
       * @param {boolean} deferred whether action can be scheduled (put into queue) to be sent in a bunch of actions later.
       * @returns {Promise} - Returns a promise. The result of the request will be provided on resolve.
       *                      Any error will be provided if rejected.
       */
      callItemAction(itemIdentifier, action, params, deferred) {
        let updateStatePromise = Promise.resolve();
        const testMap = this.getDataHolder().get('testMap');

        //update the item state
        if (params.itemState) {
          updateStatePromise = this.itemStore.update(itemIdentifier, 'itemState', params.itemState);
        }

        //check if we have already the item for the action we are going to perform
        this.getItemFromStore = navigationHelper.isMovingToNextItem(action, params) && this.hasNextItem(itemIdentifier) || navigationHelper.isMovingToPreviousItem(action, params) && this.hasPreviousItem(itemIdentifier) || navigationHelper.isJumpingToItem(action, params) && this.hasItem(mapHelper.getItemIdentifier(testMap, params.ref));

        //If item action is move to another item ensure the next request will start the timer
        if (navigationHelper.isMovingToNextItem(action, params) || navigationHelper.isMovingToPreviousItem(action, params) || navigationHelper.isJumpingToItem(action, params)) {
          params.start = true;
        }
        return updateStatePromise.then(() => {
          return this.requestNetworkThenOffline(this.configStorage.getItemActionUrl(itemIdentifier, action), action, _.merge({
            itemDefinition: itemIdentifier
          }, params), deferred);
        });
      }
    }, qtiServiceProxy);

    return proxy;

});
