define(['lodash'], function (_) { 'use strict';

    _ = _ && Object.prototype.hasOwnProperty.call(_, 'default') ? _['default'] : _;

    /**
     * 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) 2016 (original work) Open Assessment Technologies SA ;
     */

    /**
     * @typedef {Object} itemStats
     * @property {Number} questions - the number of questions items
     * @property {Number} answered - the number of answered questions
     * @property {Number} flagged - the number of items flagged for review
     * @property {Number} viewed - the number of viewed items
     * @property {Number} total - the total number of items
     * @property {Number} questionsViewed - the number of viewed questions
     */

    /**
     * Gets an empty stats record
     * @returns {itemStats}
     */
    function getEmptyStats() {
      return {
        questions: 0,
        answered: 0,
        flagged: 0,
        viewed: 0,
        total: 0,
        questionsViewed: 0
      };
    }

    /**
     * Defines a helper that provides extractors for an assessment test map
     */
    var map = {
      /**
       * Gets the jumps table
       * @param {Object} map - The assessment test map
       * @returns {Object}
       */
      getJumps: function getJumps(map) {
        return map && map.jumps;
      },
      /**
       * Gets the parts table
       * @param {Object} map - The assessment test map
       * @returns {Object}
       */
      getParts: function getParts(map) {
        return map && map.parts;
      },
      /**
       * Get sections table
       * @param {Object} map - The assessment test map
       * @returns {Object} the sections
       */
      getSections: function getSections(map) {
        var parts = this.getParts(map),
          result = {};
        _.forEach(parts, function (part) {
          var sections = part.sections;
          if (sections) {
            _.forEach(sections, function (section) {
              result[section.id] = section;
            });
          }
        });
        return result;
      },
      /**
       * Get active item from the test map
       * @param {Object} mapWithActive - The assessment test map which has active part/section/item marked
       * @returns {Object} the active item
       */
      getActiveItem: function getActiveItem(mapWithActive) {
        var parts = this.getParts(mapWithActive),
          result = {};
        _.forEach(parts, function (part) {
          var sections = part.sections;
          if (sections) {
            _.forEach(sections, function (section) {
              if (section.active) {
                const items = section.items;
                _.forEach(items, function (item) {
                  if (item.active) {
                    result = item;
                  }
                });
              }
            });
          }
        });
        return result;
      },
      /**
       * Return the list of remaining sections.
       * @param {Object} map - The assessment test map
       * @param {String} sectionId - The next sections will be gathered once this sectionId has been reached
       * @returns {Object} the next sections
       */
      getNextSections: function getNextSections(map, sectionId) {
        var sections = this.getSections(map),
          result = {},
          canList = false;
        _.forEach(sections, function (section) {
          if (canList) {
            result[section.id] = section;
          }
          if (section.id === sectionId) {
            canList = true;
          }
        });
        return result;
      },
      /**
       * Gets the jump at a particular position
       * @param {Object} map - The assessment test map
       * @param {Number} position - The position of the item
       * @returns {Object}
       */
      getJump: function getJump(map, position) {
        var jumps = this.getJumps(map);
        return jumps && jumps[position];
      },
      /**
       * Gets a test part by its identifier
       * @param {Object} map - The assessment test map
       * @param {String} partName - The identifier of the test part
       * @returns {Object}
       */
      getPart: function getPart(map, partName) {
        var parts = this.getParts(map);
        return parts && parts[partName];
      },
      /**
       * Gets a test section by its identifier
       * @param {Object} map - The assessment test map
       * @param {String} sectionName - The identifier of the test section
       * @returns {Object}
       */
      getSection: function getSection(map, sectionName) {
        var parts = this.getParts(map);
        var section = null;
        _.forEach(parts, function (part) {
          var sections = part.sections;
          if (sections && sections[sectionName]) {
            section = sections[sectionName];
            return false;
          }
        });
        return section;
      },
      /**
       * Gets a test item by its identifier
       * @param {Object} map - The assessment test map
       * @param {String} itemIdentifier - The identifier of the test item
       * @returns {Object}
       */
      getItem(map, itemIdentifier) {
        const jump = _.find(this.getJumps(map), {
          identifier: itemIdentifier
        });
        return this.getItemAt(map, jump && jump.position);
      },
      /**
       * Gets a test item by its identifier
       * @param {Object} map - The assessment test map
       * @param {String} itemIdentifier - The identifier of the test item
       * @returns {String[]} the raw list of categories
       */
      getItemCategories(map, itemIdentifier) {
        const item = this.getItem(map, itemIdentifier);
        if (item && Array.isArray(item.categories)) {
          return item.categories;
        }
        return [];
      },
      /**
       * Check if an item has a category
       * @param {Object} map - The assessment test map
       * @param {String} itemIdentifier - The identifier of the test item
       * @param {String} category - the category to check
       * @param {Boolean} [fuzzyMatch=false] - if true the prefix or the case doesn't matter
       * @returns {String[]} the raw list of categories
       */
      hasItemCategory(map, itemIdentifier, category) {
        let fuzzyMatch = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false;
        const taoPrefix = 'x-tao-option-';
        const categories = this.getItemCategories(map, itemIdentifier);
        if (!category || !category.length) {
          return false;
        }
        const exactMatch = categories.includes(category);
        if (exactMatch) {
          return true;
        }
        if (fuzzyMatch) {
          //check by adding the prefix first
          if (!category.startsWith(taoPrefix) && category.includes(`${taoPrefix}${category}`)) {
            return true;
          }

          //compare without the prefix and any case system
          const normalize = elt => elt.replace(taoPrefix, '').replace(/[-_\s]/g, '').toLowerCase();
          return categories.some(itemCategory => normalize(itemCategory) === normalize(category));
        }
        return false;
      },
      /**
       * Gets the global stats of the assessment test
       * @param {Object} map - The assessment test map
       * @returns {itemStats}
       */
      getTestStats: function getTestStats(map) {
        return map && map.stats;
      },
      /**
       * Gets the stats of the test part containing a particular position
       * @param {Object} map - The assessment test map
       * @param {String} partName - The identifier of the test part
       * @returns {itemStats}
       */
      getPartStats: function getPartStats(map, partName) {
        var part = this.getPart(map, partName);
        return part && part.stats;
      },
      /**
       * Gets the stats of the test section containing a particular position
       * @param {Object} map - The assessment test map
       * @param {String} sectionName - The identifier of the test section
       * @returns {itemStats}
       */
      getSectionStats: function getSectionStats(map, sectionName) {
        var section = this.getSection(map, sectionName);
        return section && section.stats;
      },
      /**
       * Gets the stats related to a particular scope
       * @param {Object} map - The assessment test map
       * @param {Number} position - The current position
       * @param {String} [scope] - The name of the scope. Can be: test, part, section (default: test)
       * @returns {itemStats}
       */
      getScopeStats: function getScopeStats(map, position, scope) {
        var jump = this.getJump(map, position);
        switch (scope) {
          case 'section':
          case 'testSection':
            return this.getSectionStats(map, jump && jump.section);
          case 'part':
          case 'testPart':
            return this.getPartStats(map, jump && jump.part);

          // During calculation stats for this case,
          // we are considiring all unanswered inaccessible items as answered
          case 'testWithoutInaccessibleItems':
            {
              const testStats = this.getTestStats(map);
              const {
                position: currentPartPosition,
                isLinear: isCurrentPartLinear
              } = this.getPart(map, jump && jump.part);
              const parts = Object.values(this.getParts(map)).filter(_ref => {
                let {
                  position: partPosition
                } = _ref;
                return partPosition < currentPartPosition;
              }).sort((a, b) => b - a);

              // Find the neirest part to which test taker can not navigate
              const linearPartIndex = isCurrentPartLinear ? 0 : parts.findIndex(_ref2 => {
                let {
                  isLinear
                } = _ref2;
                return isLinear;
              });
              if (linearPartIndex === -1) {
                return testStats;
              }

              // Calculate all unanswered and flagged questions in inaccessible parts
              const inaccessibleParts = parts.slice(linearPartIndex);
              const countOfInaccessibleUnasweredQestions = inaccessibleParts.reduce((acc, _ref3) => {
                let {
                  stats: {
                    questions,
                    answered
                  }
                } = _ref3;
                return acc + (questions - answered);
              }, 0);
              const countOfInaccessibleFlaggedQestions = inaccessibleParts.reduce((acc, _ref4) => {
                let {
                  stats: {
                    flagged
                  }
                } = _ref4;
                return acc + flagged;
              }, 0);
              return Object.assign({}, testStats, {
                answered: testStats.answered + countOfInaccessibleUnasweredQestions,
                flagged: testStats.flagged - countOfInaccessibleFlaggedQestions
              });
            }
          default:
          case 'test':
            return this.getTestStats(map);
        }
      },
      /**
       * Gets the map of a particular scope from a particular position
       * @param {Object} map - The assessment test map
       * @param {Number} position - The current position
       * @param {String} [scope] - The name of the scope. Can be: test, part, section (default: test)
       * @returns {object} The scoped map
       */
      getScopeMap: function getScopeMap(map, position, scope) {
        // need a clone of the map as we will change some properties
        var scopeMap = _.cloneDeep(map || {});

        // gets the current part and section
        var jump = this.getJump(scopeMap, position);
        var part = this.getPart(scopeMap, jump && jump.part);
        var section = this.getSection(scopeMap, jump && jump.section);

        // reduce the map to the scope part
        if (scope && scope !== 'test') {
          scopeMap.parts = {};
          if (part) {
            scopeMap.parts[jump.part] = part;
          }
        }

        // reduce the map to the scope section
        if (part && (scope === 'section' || scope === 'testSection')) {
          part.sections = {};
          if (section) {
            part.sections[jump.section] = section;
          }
        }

        // update the stats to reflect the scope
        if (section) {
          section.stats = this.computeItemStats(section.items);
        }
        if (part) {
          part.stats = this.computeStats(part.sections);
        }
        scopeMap.stats = this.computeStats(scopeMap.parts);
        return scopeMap;
      },
      /**
       * Gets the map of a particular scope from a current context
       * @param {Object} map - The assessment test map
       * @param {Object} context - The current session context
       * @param {String} [scope] - The name of the scope. Can be: test, part, section (default: test)
       * @returns {object} The scoped map
       */
      getScopeMapFromContext: function getScopeMapFromContext(map, context, scope) {
        // need a clone of the map as we will change some properties
        var scopeMap = _.cloneDeep(map || {});
        var part;
        var section;

        // gets the current part and section
        if (context && context.testPartId) {
          part = this.getPart(scopeMap, context.testPartId);
        }
        if (context && context.sectionId) {
          section = this.getSection(scopeMap, context.sectionId);
        }

        // reduce the map to the scope part
        if (scope && scope !== 'test') {
          scopeMap.parts = {};
          if (part) {
            scopeMap.parts[context.testPartId] = part;
          }
        }

        // reduce the map to the scope section
        if (part && (scope === 'section' || scope === 'testSection')) {
          part.sections = {};
          if (section) {
            part.sections[context.sectionId] = section;
          }
        }

        // update the stats to reflect the scope
        if (section) {
          section.stats = this.computeItemStats(section.items);
        }
        if (part) {
          part.stats = this.computeStats(part.sections);
        }
        scopeMap.stats = this.computeStats(scopeMap.parts);
        return scopeMap;
      },
      /**
       * Gets the test part containing a particular position
       * @param {Object} map - The assessment test map
       * @param {Number} position - The position of the item
       * @returns {Object}
       */
      getItemPart: function getItemPart(map, position) {
        var jump = this.getJump(map, position);
        return this.getPart(map, jump && jump.part);
      },
      /**
       * Gets the test section containing a particular position
       * @param {Object} map - The assessment test map
       * @param {Number} position - The position of the item
       * @returns {Object}
       */
      getItemSection: function getItemSection(map, position) {
        var jump = this.getJump(map, position);
        var part = this.getPart(map, jump && jump.part);
        var sections = part && part.sections;
        return sections && sections[jump && jump.section];
      },
      /**
       * Gets the item located at a particular position
       * @param {Object} map - The assessment test map
       * @param {Number} position - The position of the item
       * @returns {Object}
       */
      getItemAt: function getItemAt(map, position) {
        var jump = this.getJump(map, position);
        var part = this.getPart(map, jump && jump.part);
        var sections = part && part.sections;
        var section = sections && sections[jump && jump.section];
        var items = section && section.items;
        return items && items[jump && jump.identifier];
      },
      /**
       * Gets the identifier of an existing item
       * @param {Object} map - The assessment test map
       * @param {Number|String} position - The position of the item, can already be the identifier
       * @returns {String}
       */
      getItemIdentifier: function getItemIdentifier(map, position) {
        var item;
        if (_.isFinite(position)) {
          item = this.getItemAt(map, position);
        } else {
          item = this.getItem(map, position);
        }
        return item && item.id;
      },
      /**
       * Applies a callback on each item of the provided map
       * @param {Object} map - The assessment test map
       * @param {Function} callback(item, section, part, map) - A callback to apply on each item
       * @returns {Object}
       */
      each: function each(map, callback) {
        if (_.isFunction(callback)) {
          _.forEach(map && map.parts, function (part) {
            _.forEach(part && part.sections, function (section) {
              _.forEach(section && section.items, function (item) {
                callback(item, section, part, map);
              });
            });
          });
        }
        return map;
      },
      /**
       * Update the map stats from a particular item
       * @param {Object} map - The assessment test map
       * @param {Number} position - The position of the item
       * @returns {Object}
       */
      updateItemStats: function updateItemStats(map, position) {
        var jump = this.getJump(map, position);
        var part = this.getPart(map, jump && jump.part);
        var sections = part && part.sections;
        var section = sections && sections[jump && jump.section];
        if (section) {
          section.stats = this.computeItemStats(section.items);
        }
        if (part) {
          part.stats = this.computeStats(part.sections);
        }
        if (map) {
          map.stats = this.computeStats(map.parts);
        }
        return map;
      },
      /**
       * Computes the stats for a list of items
       * @param {Object} items
       * @returns {itemStats}
       */
      computeItemStats: function computeItemStats(items) {
        return _.reduce(items, function accStats(acc, item) {
          if (!item.informational) {
            acc.questions++;
            if (item.answered) {
              acc.answered++;
            }
            if (item.viewed) {
              acc.questionsViewed++;
            }
          }
          if (item.flagged) {
            acc.flagged++;
          }
          if (item.viewed) {
            acc.viewed++;
          }
          acc.total++;
          return acc;
        }, getEmptyStats());
      },
      /**
       * Computes the global stats of a collection of stats
       * @param {Object} collection
       * @returns {itemStats}
       */
      computeStats: function computeStats(collection) {
        return _.reduce(collection, function accStats(acc, item) {
          acc.questions += item.stats.questions;
          acc.answered += item.stats.answered;
          acc.flagged += item.stats.flagged;
          acc.viewed += item.stats.viewed;
          acc.total += item.stats.total;
          acc.questionsViewed += item.stats.questionsViewed;
          return acc;
        }, getEmptyStats());
      },
      /**
       * Patch a testMap with a partial testMap.
       *
       * If the currentMap is null or the scope is test,
       * we just use the partialMap as it is.
       *
       * Indexes, position and stats will be (re)built.
       *
       * @param {Object} currentMap - the map to patch
       * @param {Object} partialMap - the patch
       * @param {String} partialMap.scope - indicate the scope of the patch (test, part or section)
       * @returns {Object} the patched testMap
       * @throws {TypeError} if the partialMap is no a map
       */
      patch: function patch(currentMap, partialMap) {
        var self = this;
        var targetMap;
        if (!_.isPlainObject(partialMap) || !partialMap.parts) {
          throw new TypeError('Invalid test map format');
        }
        if (!currentMap || partialMap.scope === 'test') {
          targetMap = _.cloneDeep(partialMap);
        } else {
          targetMap = _.cloneDeep(currentMap);
          _.forEach(partialMap.parts, function (partialPart, targetPartId) {
            if (partialMap.scope === 'part') {
              //replace the target part
              targetMap.parts[targetPartId] = _.cloneDeep(partialPart);
            }
            if (partialMap.scope === 'section') {
              _.forEach(partialPart.sections, function (partialSection, targetSectionId) {
                //replace the target section
                targetMap.parts[targetPartId].sections[targetSectionId] = _.cloneDeep(partialSection);

                //compte new section stats
                targetMap.parts[targetPartId].sections[targetSectionId].stats = self.computeItemStats(targetMap.parts[targetPartId].sections[targetSectionId].items);
              });
            }
            //compte new/updated part stats
            targetMap.parts[targetPartId].stats = self.computeStats(targetMap.parts[targetPartId].sections);
          });
          //compte updated test stats
          targetMap.stats = this.computeStats(targetMap.parts);
        }

        //the updated map can have a different size than the original
        targetMap = this.reindex(targetMap);
        return targetMap;
      },
      /**
       * Rebuild the indexes, positions of all map parts.
       * Then recreate the jump table.
       *
       * @param {Object} map - the map to reindex
       * @returns {Object} the brand new map
       * @throws {TypeError} if the map is no a map
       */
      reindex: function reindex(map) {
        var offset = 0;
        var offsetPart = 0;
        var offsetSection = 0;
        var lastPartId;
        var lastSectionId;
        if (!_.isPlainObject(map) || !map.parts) {
          throw new TypeError('Invalid test map format');
        }

        //remove the jump table
        map.jumps = [];

        //browse the test map, by position
        _.sortBy(map && map.parts, 'position').forEach(function (part) {
          _.sortBy(part && part.sections, 'position').forEach(function (section) {
            _.sortBy(section && section.items, 'position').forEach(function (item) {
              if (lastPartId !== part.id) {
                offsetPart = 0;
                lastPartId = part.id;
                part.position = offset;
              }
              if (lastSectionId !== section.id) {
                offsetSection = 0;
                lastSectionId = section.id;
                section.position = offset;
              }
              item.position = offset;
              item.index = offsetSection + 1;
              item.positionInPart = offsetPart;
              item.positionInSection = offsetSection;
              map.jumps[offset] = {
                identifier: item.id,
                section: section.id,
                part: part.id,
                position: offset
              };
              offset++;
              offsetSection++;
              offsetPart++;
            });
          });
        });
        return map;
      },
      /**
       * Create the jump table for a test map
       *
       * @param {Object} map - the map
       * @returns {Object} the brand new map with a jump table
       * @throws {TypeError} if the map is no a map
       */
      createJumpTable: function createJumpTable(map) {
        if (!_.isPlainObject(map) || !map.parts) {
          throw new TypeError('Invalid test map format');
        }
        map.jumps = [];
        this.each(map, function (item, section, part) {
          var offset = item.position;
          map.jumps[offset] = {
            identifier: item.id,
            section: section.id,
            part: part.id,
            position: offset
          };
        });
        return map;
      }
    };

    return map;

});
