// export SHR specification content as a hierarchy in JSON format
// Author: John Gibson
// Derived from export SHR specification content as a hierarchy in JSON format by Greg Quinn

const bunyan = require('bunyan');
const {Identifier, IdentifiableValue, RefValue, ChoiceValue, TBD, IncompleteValue, ValueSetConstraint, IncludesCodeConstraint, IncludesTypeConstraint, CodeConstraint, CardConstraint, TypeConstraint, INHERITED, OVERRIDDEN, DataElement, Namespace, DataElementSpecifications, Specifications, BooleanConstraint, MODELS_INFO, PrimitiveIdentifier, PRIMITIVE_NS} = require('shr-models');

const CODE = new PrimitiveIdentifier('code');

var rootLogger = bunyan.createLogger({name: 'shr-json-schema-export'});
var logger = rootLogger;
function setLogger(bunyanLogger) {
  rootLogger = logger = bunyanLogger;
}

/**
 * Converts a group of specifications into JSON Schema.
 * @param {Specifications} expSpecifications - a fully expanded Specifications object.
 * @param {string} baseSchemaURL - the root URL for the schema identifier.
 * @param {string} baseTypeURL - the root URL for the EntryType field.
 * @param {boolean=} flat - if true then the generated schema will not be hierarchical. Defaults to false.
 * @return {Object.<string, Object>} A mapping of schema ids to JSON Schema definitions.
 */
function exportToJSONSchema(expSpecifications, baseSchemaURL, baseTypeURL, flat = false) {
  const namespaceResults = {};
  const endOfTypeURL = baseTypeURL[baseTypeURL.length - 1];
  if (endOfTypeURL !== '#' && endOfTypeURL !== '/') {
    baseTypeURL += '/';
  }
  for (const ns of expSpecifications.namespaces.all) {
    const lastLogger = logger;
    logger = logger.child({ shrId: ns.namespace});
    try {
      logger.debug('Exporting namespace.');
      if (flat) {
        const { schemaId, schema } = flatNamespaceToSchema(ns, expSpecifications.dataElements, baseSchemaURL, baseTypeURL);
        namespaceResults[schemaId] = schema;
      } else {
        const { schemaId, schema } = namespaceToSchema(ns, expSpecifications.dataElements, baseSchemaURL, baseTypeURL);
        namespaceResults[schemaId] = schema;
      }
      logger.debug('Finished exporting namespace.');
    } finally {
      logger = lastLogger;
    }
  }

  return namespaceResults;
}

/**
 * Converts a namespace into a JSON Schema.
 * @param {Namespace} ns - the namespace of the schema.
 * @param {DataElementSpecifications} dataElementsSpecs - the elements in the namespace.
 * @param {string} baseSchemaURL - the root URL for the schema identifier.
 * @param {string} baseTypeURL - the root URL for the EntryType field.
 * @return {{schemaId: string, schema: Object}} The schema id and the JSON Schema definition.
 */
function namespaceToSchema(ns, dataElementsSpecs, baseSchemaURL, baseTypeURL) {
  const dataElements = dataElementsSpecs.byNamespace(ns.namespace);
  const schemaId = `${baseSchemaURL}/${namespaceToURLPathSegment(ns.namespace)}`;
  let schema = {
    $schema: 'http://json-schema.org/draft-04/schema#',
    id: schemaId,
    title: "TODO: Figure out what the title should be.",
    definitions: {}
  };
  const entryRef = makeRef(new Identifier('shr.base', 'Entry'), ns, baseSchemaURL);
  if (ns.description) {
    schema.description = ns.description;
  }
  const nonEntryEntryTypeField = {
    $ref: makeRef(new Identifier('shr.base', 'EntryType'), ns, baseSchemaURL)
  };

  const defs = dataElements.sort(function(l,r) {return l.identifier.name.localeCompare(r.identifier.name);});
  const entryRefs = [];
  for (const def of defs) {
    const lastLogger = logger;
    logger = logger.child({ shrId: def.identifier.fqn});
    try {
      logger.debug('Exporting element');
      let schemaDef = {
        type: 'object',
        properties: {}
      };
      let wholeDef = schemaDef;
      const tbdParentDescriptions = [];
      let requiredProperties = [];
      let needsEntryType = false;
      if (def.isEntry || def.basedOn.length) {
        wholeDef = { allOf: [] };
        let hasEntryParent = false;
        for (const supertypeId of def.basedOn) {
          if (supertypeId instanceof TBD) {
            if (supertypeId.text) {
              tbdParentDescriptions.push(supertypeId.text);
            } else {
              tbdParentDescriptions.push('TBD');
            }
          } else {
            const parent = dataElementsSpecs.findByIdentifier(supertypeId);
            if (!parent) {
              logger.error('Could not find definition for %s which is a supertype of %s', supertypeId, def);
            } else {
              hasEntryParent = hasEntryParent || parent.isEntry;
            }
            wholeDef.allOf.push({ $ref: makeRef(supertypeId, ns, baseSchemaURL)});
          }
        }
        if (def.isEntry && (!hasEntryParent)) {
          wholeDef.allOf.splice(0, 0, { $ref: entryRef });
        }
        wholeDef.allOf.push(schemaDef);
      } else {
        needsEntryType = true;
      }

      const tbdFieldDescriptions = [];
      if (def.value) {
        if (def.value.inheritance !== INHERITED) {
          let { value, required, tbd } = convertDefinition(def.value, dataElementsSpecs, ns, baseSchemaURL, baseTypeURL);
          if (required) {
            requiredProperties.push('Value');
          }
          schemaDef.properties.Value = value;
          if (tbd) {
            schemaDef.properties.Value.description = def.value.text ? ('TBD: ' + def.value.text) : tbdValueToString(def.value);
          }
        }
      }
      if (def.fields.length) {
        const fieldNameMap = {};
        const clashingNames = {};
        for (const field of def.fields) {
          if (!(field instanceof TBD)) {
            if (!isValidField(field)) {
              continue;
            } else if (field.inheritance === INHERITED) {
              if (fieldNameMap[field.identifier.name]) {
                logger.error(`ERROR: clashing property names: %s and %s ERROR_CODE: 12038`, fieldNameMap[field.identifier.name].fqn, field.identifier.fqn);
                clashingNames[field.identifier.name] = true;
              } else {
                fieldNameMap[field.identifier.name] = field.identifier;
              }
              continue;
            }

            if (fieldNameMap[field.identifier.name]) {
              logger.error(`ERROR: clashing property names: %s and %s ERROR_CODE: 12038`, fieldNameMap[field.identifier.name].fqn, field.identifier.fqn);
              clashingNames[field.identifier.name] = true;
              continue;
            } else {
              fieldNameMap[field.identifier.name] = field.identifier;
            }
          }
          const card = field.effectiveCard;
          if (card && card.isZeroedOut) {
            continue;
          }
          let {value, required, tbd} = convertDefinition(field, dataElementsSpecs, ns, baseSchemaURL, baseTypeURL);
          if (tbd) {
            tbdFieldDescriptions.push(tbdValueToString(field));
            continue;
          }

          if (field.identifier.fqn === 'shr.base.EntryType') {
            needsEntryType = false;
          }

          schemaDef.properties[field.identifier.name] = value;
          if (required) {
            requiredProperties.push(field.identifier.name);
          }
        }

        for (const clashingName in clashingNames) {
          delete schemaDef.properties[clashingName];
        }
        requiredProperties = requiredProperties.filter(propName => !(propName in clashingNames));
      } else if (!def.value) {
        schemaDef.type = 'object';
        schemaDef.description = 'Empty DataElement?';
      }
      let descriptionList = [];
      if (def.description) {
        descriptionList.push(def.description);
      }
      if (def.concepts.length) {
        wholeDef.concepts = def.concepts.map((concept) => makeConceptEntry(concept));
      }
      if (tbdParentDescriptions.length) {
        tbdParentDescriptions[0] = 'TBD Parents: ' + tbdParentDescriptions[0];
        descriptionList = descriptionList.concat(tbdParentDescriptions);
      }
      if (tbdFieldDescriptions.length) {
        tbdFieldDescriptions[0] = 'TBD Fields: ' + tbdFieldDescriptions[0];
        descriptionList = descriptionList.concat(tbdFieldDescriptions);
      }
      if (descriptionList.length) {
        wholeDef.description = descriptionList.join('\n');
      }
      if (needsEntryType) {
        schemaDef.properties['EntryType'] = nonEntryEntryTypeField;
        if (def.identifier.fqn !== 'shr.base.EntryType') {
          requiredProperties.push('EntryType');
        }
      }

      if (requiredProperties.length) {
        schemaDef.required = requiredProperties;
      }

      schema.definitions[def.identifier.name] = wholeDef;
      if (def.isEntry && (!def.isAbstract)) {
        entryRefs.push({ $ref: makeRef(def.identifier, ns, baseSchemaURL)});
      }
    } finally {
      logger = lastLogger;
    }
  }

  if (entryRefs.length) {
    schema.type = 'object';
    schema.anyOf = entryRefs;
  }
  return { schemaId, schema };
}

/**
 * Converts a namespace into a flat JSON Schema.
 * @param {Namespace} ns - the namespace of the schema.
 * @param {DataElementSpecifications} dataElementsSpecs - the elements in the namespace.
 * @param {string} baseSchemaURL - the root URL for the schema identifier.
 * @param {string} baseTypeURL - the root URL for the EntryType field.
 * @return {{schemaId: string, schema: Object}} The schema id and the JSON Schema definition.
 */
function flatNamespaceToSchema(ns, dataElementsSpecs, baseSchemaURL, baseTypeURL) {
  const dataElements = dataElementsSpecs.byNamespace(ns.namespace);
  const schemaId = `${baseSchemaURL}/${namespaceToURLPathSegment(ns.namespace)}`;
  let schema = {
    $schema: 'http://json-schema.org/draft-04/schema#',
    id: schemaId,
    title: "TODO: Figure out what the title should be.",
    definitions: {}
  };
  const expandedEntry = makeExpandedEntryDefinitions(ns, baseSchemaURL);
  if (ns.description) {
    schema.description = ns.description;
  }

  const defs = dataElements.sort(function(l,r) {return l.identifier.name.localeCompare(r.identifier.name);});
  const entryRefs = [];
  for (const def of defs) {
    let schemaDef = {
      type: 'object',
      properties: {}
    };
    let wholeDef = schemaDef;
    const tbdParentDescriptions = [];
    let requiredProperties = [];
    if (def.isEntry) {
      requiredProperties = expandedEntry.required.slice();
    }

    const tbdFieldDescriptions = [];
    if (def.value) {
      let { value, required, tbd } = convertDefinition(def.value, dataElementsSpecs, ns, baseSchemaURL, baseTypeURL);
      if (required) {
        requiredProperties.push('Value');
      }
      schemaDef.properties.Value = value;
      if (tbd) {
        schemaDef.properties.Value.description = def.value.text ? ('TBD: ' + def.value.text) : tbdValueToString(def.value);
      }
    }
    if (def.fields.length) {
      for (const field of def.fields) {
        if (!(field instanceof TBD) && !isValidField(field)) {
          continue;
        }
        const card = field.effectiveCard;
        if (card && card.isZeroedOut) {
          continue;
        }
        let {value, required, tbd} = convertDefinition(field, dataElementsSpecs, ns, baseSchemaURL, baseTypeURL);
        if (tbd) {
          tbdFieldDescriptions.push(tbdValueToString(field));
          continue;
        }

        const fieldName = field.identifier.name;
        schemaDef.properties[fieldName] = value;
        if (required && (requiredProperties.indexOf(fieldName) === -1)) {
          requiredProperties.push(fieldName);
        }
      }
      if (def.isEntry) {
        for (const name in expandedEntry.properties) {
          if (!(name in schemaDef.properties)) {
            schemaDef.properties[name] = expandedEntry.properties[name];
          }
        }
      }
    } else if (!def.value) {
      schemaDef.type = 'object';
      schemaDef.description = 'Empty DataElement?';
    }
    let descriptionList = [];
    if (def.description) {
      descriptionList.push(def.description);
    }
    if (def.concepts.length) {
      wholeDef.concepts = def.concepts.map((concept) => makeConceptEntry(concept));
    }
    if (tbdParentDescriptions.length) {
      tbdParentDescriptions[0] = 'TBD Parents: ' + tbdParentDescriptions[0];
      descriptionList = descriptionList.concat(tbdParentDescriptions);
    }
    if (tbdFieldDescriptions.length) {
      tbdFieldDescriptions[0] = 'TBD Fields: ' + tbdFieldDescriptions[0];
      descriptionList = descriptionList.concat(tbdFieldDescriptions);
    }
    if (descriptionList.length) {
      wholeDef.description = descriptionList.join('\n');
    }
    if (requiredProperties.length) {
      schemaDef.required = requiredProperties;
    }

    schema.definitions[def.identifier.name] = wholeDef;
    if (def.isEntry && (!def.isAbstract)) {
      entryRefs.push({ $ref:  makeRef(def.identifier, ns, baseSchemaURL)});
    }
  }

  if (entryRefs.length) {
    schema.type = 'object';
    schema.anyOf = entryRefs;
  }
  return { schemaId, schema };
}

function namespaceToURLPathSegment(namespace) {
  return namespace.replace(/\./g, '/');
}

function isValidField(field) {
  if (field instanceof ChoiceValue) {
    logger.error('Ignoring field defined as a choice', field.toString());
    return false;
  }
  if (!(field.identifier)) {
    logger.error('Ignoring name-less field: ', field.toString());
    return false;
  }
  if (field.identifier.name === 'Value') {
    logger.error('Ignoring restricted field name: Value', field.toString());
    return false;
  }
  return true;
}

function convertDefinition(valueDef, dataElementsSpecs, enclosingNamespace, baseSchemaURL, baseTypeURL) {
  let listValue = null;
  let value = {};
  const fullDef = valueDef.identifier ? dataElementsSpecs.findByIdentifier(valueDef.identifier) : null;
  const card = valueDef.effectiveCard;
  let required = false;
  let isList = false;
  let valueIsPrimitive = false;
  if (card) {
    isList = card.isList;
    if (!isList && fullDef && fullDef.value) {
      const cardConstraints = fullDef.value.constraintsFilter.own.card.constraints;
      isList = cardConstraints.some((oneCard) => oneCard.isList);
    }
    if (isList) {
      listValue = { type: 'array' };
      if (card.min != null) {
        listValue.minItems = card.min;
        if (card.min) {
          required = true;
        }
      }
      if (card.max) {
        listValue.maxItems = card.max;
      }
      listValue.items = value;
    } else if (card.min) {
      required = true;
    }
  }

  if (valueDef instanceof ChoiceValue) {
    const refOptions = [];
    const normalOptions = [];
    for (const option of valueDef.options) {
      if (option.effectiveCard && (!option.effectiveCard.isExactlyOne)) {
        logger.error('Choices with options with cardinalities that are not exactly one are illegal "%s". Ignoring option %s.', valueDef.toString(), option.toString());
      } else if (option instanceof RefValue) {
        refOptions.push(option);
      } else {
        normalOptions.push(option);
      }
    }
    value.anyOf = [];
    if (refOptions.length) {
      value.anyOf.push(makeShrRefObject(refOptions, baseTypeURL));
    }
    for (const option of normalOptions) {
      const { value: childValue } = convertDefinition(option, dataElementsSpecs, enclosingNamespace, baseSchemaURL, baseTypeURL);
      value.anyOf.push(childValue);
    }
    if (value.anyOf.length == 1) {
      const single = value.anyOf[0];
      delete value.anyOf;
      for (const ent in single) {
        value[ent] = single[ent];
      }
    }
  } else if (valueDef instanceof RefValue) {
    // TODO: What should the value of EntryType be? The schema URL may not be portable across data types.
    makeShrRefObject([valueDef], baseTypeURL, value);
  } else if (valueDef instanceof IdentifiableValue) {
    const id = valueDef.effectiveIdentifier;
    if (id.isPrimitive) {
      valueIsPrimitive = true;
      makePrimitiveObject(id, value);
    } else {
      const typeRef = makeRef(valueDef.identifier, enclosingNamespace, baseSchemaURL);
      if (valueDef.inheritance !== OVERRIDDEN) {
        value.allOf = [{ $ref: typeRef}];
      }
    }
  } else if (valueDef instanceof TBD) {
    let ret = value;
    if (listValue) {
      delete listValue.items;
      ret = listValue;
    }
    return {value: ret, required: required, tbd: true};
  } else if (valueDef instanceof IncompleteValue) {
    logger.error('Unsupported Incomplete');
  } else {
    logger.error('Unknown type for value "%s"', valueDef.constructor.name);
  }

  const constraintStructure = { $self: []};
  const includesCodeLists = {};
  for (const constraint of valueDef.constraints) {
    let {path: constraintPath, target: constraintTarget } = extractConstraintPath(constraint, valueDef, dataElementsSpecs);
    if (!constraintPath) {
      continue;
    } else if (!constraintTarget) {
      // Cardinality constraints without a path are not useful (except if you're really a list, we'll handle that later).
      if (constraint instanceof CardConstraint) {
        continue;
      } else {
        constraintTarget = valueDef;
      }
    }

    let currentStruct = constraintStructure;
    for (const path of constraintPath) {
      if (!currentStruct[path]) {
        currentStruct[path] = { $self: []};
      }
      currentStruct = currentStruct[path];
    }
    currentStruct.$self.push({ constraint, constraintPath, constraintTarget });
  }

  let allOf = [{properties: {}}];
  if (Object.keys(value).length) {
    allOf.push(value);
  }
  function pruneExpandedStructure (currentAllOf) {
    for (let i = currentAllOf.length - 1; i >= 0; i -= 1) {
      const allOfEntry = currentAllOf[i];
      if (allOfEntry.constraints && (!allOfEntry.constraints.length)) {
        delete allOfEntry.constraints;
      }
      if (allOfEntry.properties) {
        if (!Object.keys(allOfEntry.properties).length) {
          delete allOfEntry.properties;
        } else {
          for (const path in allOfEntry.properties) {
            if (allOfEntry.properties[path].allOf) {
              pruneExpandedStructure(allOfEntry.properties[path].allOf);
              switch (allOfEntry.properties[path].allOf.length) {
              case 1:
                allOfEntry.properties[path] = allOfEntry.properties[path].allOf[0];
                break;
              case 0:
                delete allOfEntry.properties[path].allOf;
                break;
              }
            }
            if (!Object.keys(allOfEntry.properties[path]).length) {
              delete allOfEntry.properties[path];
            }
          }
          if (!Object.keys(allOfEntry.properties).length) {
            delete allOfEntry.properties;
          }
        }
      }
      if (allOfEntry.allOf && allOfEntry.allOf.length === 1) {
        for (const key in allOfEntry.allOf[0]) {
          if (key !== 'allOf') {
            allOfEntry[key] = allOfEntry.allOf[0][key];
          }
        }
        delete allOfEntry.allOf;
      }
      if (!Object.keys(allOfEntry).length) {
        currentAllOf.splice(i, 1);
      }
    }
  }

  if (valueDef.constraints && valueDef.constraints.length) {
    function constraintDfs (node, currentAllOf, parentAllOf = null) {
      for (const path in node) {
        if (path !== '$self') {
          if (!currentAllOf[0].properties[path]) {
            currentAllOf[0].properties[path] = {
              allOf: [
                { properties: {} }
              ]
            };
          }
          constraintDfs(node[path], currentAllOf[0].properties[path].allOf, currentAllOf);
        } else {
          currentAllOf[0].constraints = [];
          let includesConstraints = null;
          let includesCodeConstraints = null;
          for (const constraintInfo of node.$self) {
            if (constraintInfo.constraint instanceof TypeConstraint) {
              if (constraintInfo.constraintTarget instanceof RefValue) {
                const refid = constraintInfo.constraint.isA;
                if (isOrWasAList(constraintInfo.constraintTarget)) {
                  currentAllOf.push({ items: { refType: [`${baseTypeURL}${namespaceToURLPathSegment(refid.namespace)}/${refid.name}`]}});
                } else {
                  currentAllOf.push({ refType: [`${baseTypeURL}${namespaceToURLPathSegment(refid.namespace)}/${refid.name}`]});
                }
              } else if (constraintInfo.constraintTarget instanceof IdentifiableValue) {
                let schemaConstraint = null;
                if (constraintInfo.constraint.isA.isPrimitive) {
                  schemaConstraint = makePrimitiveObject(constraintInfo.constraint.isA);
                } else {
                  schemaConstraint = {$ref: makeRef(constraintInfo.constraint.isA, enclosingNamespace, baseSchemaURL)};
                }
                if (isOrWasAList(constraintInfo.constraintTarget)) {
                  currentAllOf.push({ items: schemaConstraint });
                } else {
                  currentAllOf.push(schemaConstraint);
                }
              } else {
                logger.error('Internal error unexpected constraint target: %s for constraint %s', constraintInfo.constraintTarget.toString(), constraintInfo.constraint.toString());
              }
            } else if (constraintInfo.constraint instanceof IncludesTypeConstraint) {
              if (!includesConstraints) {
                includesConstraints = {refs: [], types: [], min: 0, max: 0};
              }
              includesConstraints.min += constraintInfo.constraint.card.min;
              if (includesConstraints.max !== null) {
                if (constraintInfo.constraint.card.isMaxUnbounded) {
                  includesConstraints.max = null;
                } else {
                  includesConstraints.max += constraintInfo.constraint.card.max;
                }
              }

              if (constraintInfo.constraint.isA instanceof RefValue) {
                includesConstraints.refs.push(constraintInfo.constraint);
              } else {
                includesConstraints.types.push(constraintInfo.constraint);
              }
            } else if (constraintInfo.constraint instanceof IncludesCodeConstraint) {
              if (!includesCodeConstraints) {
                includesCodeConstraints = [];
              }
              includesCodeConstraints.push(constraintInfo.constraint);
            } else if (constraintInfo.constraint instanceof ValueSetConstraint) {
              if (currentAllOf[0].valueSet) {
                logger.error(`Multiple valueset constraints found on a single element %s.`, constraintInfo.constraint);
                continue;
              }
              currentAllOf[0].valueSet = {
                uri: constraintInfo.constraint.valueSet,
                strength: constraintInfo.constraint.bindingStrength
              };
            } else if (constraintInfo.constraint instanceof CodeConstraint) {
              // Maybe TODO: For entry elements this can have some level of enforcement by ANDing the exact contents of the EntryType field with an enum for this value/field.
              if (currentAllOf[0].code) {
                logger.error(`Multiple code constraints found on a single element %s.`, constraintInfo.constraint);
                continue;
              }
              currentAllOf[0].code = makeConceptEntry(constraintInfo.constraint.code);
            } else if (constraintInfo.constraint instanceof BooleanConstraint) {
              currentAllOf.push({ enum: [constraintInfo.constraint.value]});
            } else if (constraintInfo.constraint instanceof CardConstraint) {
              // TODO: 0..0
              if (isOrWasAList(constraintInfo.constraintTarget)) {
                const arrayDef = {
                  type: 'array',
                  minItems: constraintInfo.constraint.card.min,
                };
                if (constraintInfo.constraint.card.max) {
                  arrayDef.maxItems = constraintInfo.constraint.card.max;
                }
                currentAllOf.push(arrayDef);
                if (parentAllOf && constraintInfo.constraint.card.min) {
                  parentAllOf.push({ required: [constraintInfo.constraintPath[constraintInfo.constraintPath.length - 1]] });
                }
              } else {
                if (parentAllOf && constraintInfo.constraint.card.min) {
                  parentAllOf.push({ required: [constraintInfo.constraintPath[constraintInfo.constraintPath.length - 1]] });
                }
              }
            } else {
              currentAllOf[0].constraints.push(constraintInfo.constraint);
            }
          }

          if (includesConstraints) {
            currentAllOf[0].includesTypes = [];
            const includesTypesArrayDef = {
              type: 'array',
              minItems: includesConstraints.min,
              items: { anyOf: [] }
            };
            currentAllOf.push(includesTypesArrayDef);
            if (includesConstraints.max !== null) {
              includesTypesArrayDef.maxItems = includesConstraints.max;
            }
            if (includesConstraints.refs.length) {
              includesTypesArrayDef.items.anyOf.push(makeShrRefObject(includesConstraints.refs.map((ref) => ref.isA), baseTypeURL));
              for (const ref of includesConstraints.refs) {
                const includesType = {
                  items: `ref(${makeShrDefinitionURL(ref.isA, baseSchemaURL)})`,
                  minItems: ref.card.min
                };
                if (!ref.card.isMaxUnbounded) {
                  includesType.maxItems = ref.card.max;
                }
                currentAllOf[0].includesTypes.push(includesType);
              }
            }
            for (const val of includesConstraints.types) {
              includesTypesArrayDef.items.anyOf.push({ $ref: makeRef(val.isA, enclosingNamespace, baseSchemaURL) });
              const includesType = {
                items: `${baseTypeURL}${namespaceToURLPathSegment(val.isA.namespace)}/${val.isA.name}`,
                minItems: val.card.min
              };
              if (!val.card.isMaxUnbounded) {
                includesType.maxItems = val.card.max;
              }
              currentAllOf[0].includesTypes.push(includesType);
            }
          }
          if (includesCodeConstraints) {
            currentAllOf[0].includesCodes = includesCodeConstraints.map((it) => makeConceptEntry(it.code));
          }
        }
      }
    }
    constraintDfs(constraintStructure, allOf);
    if (isList) {
      for (const includesConstraintDef of allOf) {
        if (includesConstraintDef.type === 'array') {
          if ((listValue.minItems !== null) && (listValue.minItems !== undefined)) {
            listValue.minItems = Math.max(listValue.minItems, includesConstraintDef.minItems);
          } else {
            listValue.minItems = includesConstraintDef.minItems;
          }
          // TODO: Support 0..0
          if (listValue.maxItems == null) {
            listValue.maxItems = includesConstraintDef.maxItems;
          } else {
            if (includesConstraintDef.maxItems != null) {
              listValue.maxItems = Math.min( listValue.maxItems, includesConstraintDef.maxItems);
            }
          }
          listValue.includesTypes = allOf[0].includesTypes;
          delete allOf[0].includesTypes;

          allOf.push(includesConstraintDef.items);
          delete includesConstraintDef.items;
          delete includesConstraintDef.maxItems;
          delete includesConstraintDef.minItems;
          delete includesConstraintDef.type;
          break;
        }
      }
    }

    if (valueIsPrimitive) {
      pruneExpandedStructure(allOf);
      if (value !== allOf[0]) {
        for (const prop in allOf[0]) {
          value[prop] = allOf[0][prop];
          delete allOf[0][prop];
        }
      }
    }
  }
  pruneExpandedStructure(allOf);
  function sanityCheckFinalStructure (currentAllOf) {
    for (let i = currentAllOf.length - 1; i >= 0; i -= 1) {
      const allOfEntry = currentAllOf[i];
      if (allOfEntry.constraints) {
        for (const constraint of allOfEntry.constraints) {
          logger.error('Internal error: unhandled constraint %s', constraint.toString());
        }
      }
      if (allOfEntry.properties) {
        for (const path in allOfEntry.properties) {
          if (allOfEntry.properties[path].allOf) {
            sanityCheckFinalStructure(allOfEntry.properties[path].allOf);
          } else {
            sanityCheckFinalStructure([allOfEntry.properties[path]]);
          }
        }
      }
    }
  }
  sanityCheckFinalStructure(allOf);

  if (allOf.length) {
    const allOfDef = allOf.length === 1 ? allOf[0] : { allOf: allOf };
    if (listValue) {
      listValue.items = allOfDef;
    } else {
      value = allOfDef;
    }
  }

  if (Object.keys(includesCodeLists).length) {
    value.codes = includesCodeLists;
  }

  return {value: listValue ? listValue : value, required, tbd: false};
}

function tbdValueToString(tbd) {
  if (tbd.text) {
    return tbd.text;
  } else {
    const card = tbd.effectiveCard;
    if (card) {
      return 'TBD with cardinality ' + card;
    } else {
      return 'TBD';
    }
  }
}

/**
 * Create a JSON Schema reference to the specified type.
 *
 * @param {Identifier} id - the target type.
 * @param {Namespace} enclosingNamespace - the current namespace that is being evaluated.
 * @param {string} baseSchemaURL - the root URL for the schema identifier
 * @returns {string} - a JSON Schema reference to the target type.
 */
function makeRef(id, enclosingNamespace, baseSchemaURL) {
  if (id.namespace === enclosingNamespace.namespace) {
    return '#/definitions/' + id.name;
  } else {
    return makeShrDefinitionURL(id, baseSchemaURL);
  }
}

function makeShrRefObject(refs, baseTypeURL, target = {}) {
  target.type = 'object';
  target.properties = {
    _ShrId: { type: 'string' },
    _EntryId: { type: 'string' },
    _EntryType: { type: 'string' }
  };
  target.required = ['_ShrId', '_EntryType', '_EntryId'];
  target.refType = refs.map((ref) => `${baseTypeURL}${namespaceToURLPathSegment(ref.identifier.namespace)}/${ref.identifier.name}`);
  return target;
}

function makeShrDefinitionURL(id, baseSchemaURL) {
  return `${baseSchemaURL}/${namespaceToURLPathSegment(id.namespace)}#/definitions/${id.name}`;
}

function makePrimitiveObject(id, target = {}) {
  switch (id.name) {
  case 'boolean':
  case 'string':
  case 'integer':
    target.type = id.name;
    break;
  case 'unsignedInt':
    target.type = 'integer';
    target.minimum = 0;
    break;
  case 'positiveInt':
    target.type = 'integer';
    target.minimum = 1;
    break;
  case 'decimal':
    target.type = 'number';
    break;
  case 'uri':
    target.type = 'string';
    target.format = 'uri';
    break;
  case 'base64Binary':
    target.type = 'string';
    break;
  case 'dateTime':
    target.type = 'string';
    target.format = 'date-time';
    break;
  case 'instant':
  case 'date':
  case 'time':
    target.type = 'string';
    break;
  case 'code':
    target.type = 'object';
    target.properties = {
      code: { type: 'string' },
      codeSystem: { type: 'string', format: 'uri' },
      displayText: { type: 'string' }
    };
    target.required = ['code', 'codeSystem'];
    break;
  case 'oid':
  case 'id':
  case 'markdown':
  case 'xhtml':
    target.type = 'string';
    break;
  }

  return target;
}

/**
 * Translates a constraint path into a valid path for the JSON Schema.
 * @param {Constraint} constraint - the constraint.
 * @param {IdentifiableValue} valueDef - the identifiable value that contains the constraint.
 * @param {DataElementSpecifications} dataElementSpecs - the elements in the namespace.
 * @return {{path: (Array<string>|undefined), target: (Value|undefined)}} - The target of the constraint and the extracted path (qualified if necessary). Both properties will be null if there was an error.
 */
function extractConstraintPath(constraint, valueDef, dataElementSpecs) {
  if (constraint.onValue) {
    return extractUnnormalizedConstraintPath(constraint, valueDef, dataElementSpecs);
  } else if (constraint.path.length > 0 && constraint.path[constraint.path.length-1].isValueKeyWord) {
    // Essentially the same as above, when onValue is never checked again, so just chop it off
    // treat it like above.  TODO: Determine if this is really the right approach.
    const simpleConstraint = constraint.clone();
    simpleConstraint.path = simpleConstraint.path.slice(0, simpleConstraint.path.length-1);
    return extractUnnormalizedConstraintPath(simpleConstraint, valueDef, dataElementSpecs);
  }

  if (!constraint.hasPath()) {
    return { path: [] };
  }

  let currentDef = dataElementSpecs.findByIdentifier(valueDef.effectiveIdentifier);
  const normalizedPath = [];
  let target = null;
  for (let i = 0; i < constraint.path.length; i += 1) {
    const pathId = constraint.path[i];
    target = null;
    if (pathId.namespace === PRIMITIVE_NS) {
      if (i !== constraint.path.length - 1) {
        logger.error('Encountered a constraint path containing a primitive %s at index %d that was not the leaf: %s', pathId, i, constraint.toString());
        return {};
      }
      if (!currentDef.value) {
        logger.error('Encountered a constraint path with a primitive leaf %s on an element that lacked a value: %s', pathId, constraint.toString());
        return {};
      }
      if (currentDef.value instanceof ChoiceValue) {
        target = findOptionInChoice(currentDef.value, pathId, dataElementSpecs);
        if (!target) {
          logger.error('Encountered a constraint path with a primitive leaf %s on an element with a mismatched value: %s on valueDef %s', pathId, constraint.toString(), valueDef.toString());
          return {};
        }
      } else if (!pathId.equals(currentDef.value.identifier)) {
        logger.error('Encountered a constraint path with a primitive leaf %s on an element with a mismatched value: %s on valueDef %s', pathId, constraint.toString(), valueDef.toString());
        return {};
      } else {
        target = currentDef.value;
      }
      normalizedPath.push('Value');
    } else {
      const newDef = dataElementSpecs.findByIdentifier(pathId);
      if (!newDef) {
        logger.error('Cannot resolve element definition for %s on constraint %s. ERROR_CODE:12029', pathId, constraint.toString());
        return {};
      }
      // See if the current definition has a value of the specified type.
      if (currentDef.value) {
        if (currentDef.value instanceof ChoiceValue) {
          target = findOptionInChoice(currentDef.value, pathId, dataElementSpecs);
        } else if (pathId.equals(currentDef.value.identifier) || checkHasBaseType(currentDef.value.identifier, pathId, dataElementSpecs)) {
          target = currentDef.value;
          normalizedPath.push('Value');
        }
      }

      if (!target) {
        if (!currentDef.fields || !currentDef.fields.length) {
          logger.error('Element %s lacked any fields or a value that matched %s as part of constraint %s', currentDef.identifier.fqn, pathId, constraint.toString());
          return {};
        } else {
          target = currentDef.fields.find((field) => pathId.equals(field.identifier));
          if (!target) {
            // It's possible that the field is actually defined as an "includes type" constraint on a list.
            // In this case, do nothing, because right now there isn't a valid way to represent further constraints
            // on includesType elements in the schema.
            if (valueDef.constraintsFilter.includesType.constraints.some(c => c.isA.equals(pathId))) {
              logger.warn('Cannot enforce constraint %s on Element %s since %s refers to an type introduced by an "includesType" constraint', constraint.toString(), currentDef.identifier.fqn, pathId);
            } else {
              logger.error('Element %s lacked a field or a value that matched %s as part of constraint %s', currentDef.identifier.fqn, pathId, constraint.toString());
            }
            return {};
          }
          normalizedPath.push(pathId.name);
        }
      }
      currentDef = newDef;
    }
  }

  return {path:normalizedPath, target};
}

function extractUnnormalizedConstraintPath(constraint, valueDef, dataElementSpecs) {
  let currentDef = dataElementSpecs.findByIdentifier(valueDef.effectiveIdentifier);
  const normalizedPath = [];
  const len = constraint.hasPath() ? constraint.path.length : 0;
  for (let i = 0; i < len; i += 1) {
    const pathId = constraint.path[i];
    if (pathId.namespace === PRIMITIVE_NS) {
      logger.error('Encountered an unnormalized constraint path containing a primitive %s at index %d: %s', pathId, i, constraint.toString());
      return {};
    }
    const newDef = dataElementSpecs.findByIdentifier(pathId);
    if (!newDef) {
      logger.error('Cannot resolve element definition for %s on constraint %s. ERROR_CODE:12029', pathId, constraint.toString());
      return {};
    }
    let found = false;
    // See if the current definition has a value of the specified type.
    if (currentDef.value) {
      if (currentDef.value instanceof ChoiceValue) {
        for (const choice of currentDef.value.aggregateOptions) {
          if (pathId.equals(choice.identifier) || checkHasBaseType(choice.identifier, pathId, dataElementSpecs)) {
            found = true;
            break;
          }
        }
      } else if (pathId.equals(currentDef.value.identifier) || checkHasBaseType(currentDef.value.identifier, pathId, dataElementSpecs)) {
        normalizedPath.push('Value');
        found = true;
      }
    }

    if (!found) {
      if (!currentDef.fields || !currentDef.fields.length) {
        logger.error('Element %s lacked any fields or a value that matched %s as part of constraint %s', currentDef.identifier.fqn, pathId, constraint.toString());
        return {};
      } else {
        const found = currentDef.fields.some((field) => pathId.equals(field.identifier));
        if (!found) {
          // It's possible that the field is actually defined as an "includes type" constraint on a list.
          // In this case, do nothing, because right now there isn't a valid way to represent includesType
          // constraints in the schema.
          if (valueDef.constraintsFilter.includesType.constraints.some(c => c.isA.equals(pathId))) {
            // TODO: Eventually, somehow, support includesType constraints
          } else {
            logger.error('Element %s lacked a field or a value that matched %s as part of constraint %s', currentDef.identifier.fqn, pathId, constraint.toString());
          }
          return {};
        }
        normalizedPath.push(pathId.name);
      }
    }
    currentDef = newDef;
  }

  if (!currentDef.value) {
    logger.error('Target of an unnormalized constraint: %s does not have a value. Constraint is: %s', currentDef.identifier.fqn, constraint.toString());
    return {};
  } else if (!(currentDef.value instanceof ChoiceValue)) {
    logger.error('Constraint should not be on the value (except for choices): %s in an expanded object model "%s". Ignoring constraint.', currentDef.identifier.fqn, constraint.toString());
    return {};
  }
  let target = currentDef.value;
  if ((constraint instanceof TypeConstraint) || (constraint instanceof IncludesTypeConstraint)) {
    target = findOptionInChoice(currentDef.value, constraint.isA, dataElementSpecs);
    if (!target) {
      logger.error('Target of an unnormalized constraint: %s was a choice value that did not have a valid option: %s', constraint.toString(), currentDef.identifier.fqn);
      return {};
    }
  }

  normalizedPath.push('Value');

  return {path:normalizedPath, target};
}

function makeExpandedEntryDefinitions(enclosingNamespace, baseSchemaURL) {
  const properties = {};
  for (const name of ['ShrId', 'EntryId', 'EntryType', 'FocalSubject', 'SubjectIsThirdPartyFlag', 'Narrative', 'Informant', 'Author', 'AssociatedEncounter', 'OriginalCreationDate', 'LastUpdateDate', 'Language']) {
    properties[name] = { $ref: makeRef(new Identifier('shr.base', name), enclosingNamespace, baseSchemaURL) };
  }
  properties.Version = { $ref: makeRef(new Identifier('shr.core', 'Version'), enclosingNamespace, baseSchemaURL) };
  return { properties, required: [
    'shr.base.ShrId',
    'shr.base.EntryId',
    'shr.base.EntryType',
    'shr.base.FocalSubject',
    'shr.base.OriginalCreationDate',
    'shr.base.LastUpdateDate'
  ]};
}

/**
 * Converts a concept into a code entry for the schema. (Codes are also represented as Concepts in the object model.)
 *
 * @param {Concept|TBD} concept - The concept to convert.
 * @return {{code: string, codeSystem: string, displayText: (string|undefined)}} The converted object. Display is optional.
 */
function makeConceptEntry(concept) {
  if (concept instanceof TBD) {
    const ret = { code: 'TBD', codeSystem: 'urn:tbd' };
    if (concept.text) {
      ret.displayText = concept.text;
    }
    return ret;
  } else {
    const ret = { code: concept.code, codeSystem: concept.system };
    if (concept.display) {
      ret.displayText = concept.display;
    }
    return ret;
  }
}

/**
 * Determine if a value or one of its ancestors is or was a list.
 *
 * @param {Value} value - the value to test.
 * @return {boolean} True if the value or any of its ancestors ever had a cardinality greater than 1.
 */
function isOrWasAList(value) {
  if (value.card.isList) {
    return true;
  }
  const cardConstraints = value.constraintsFilter.own.card.constraints;
  return cardConstraints.some((oneCard) => oneCard.isList);
}

/**
 * Searches the aggregate options of a choice for the specified option.
 *
 * @param {ChoiceValue} choice - the choice to evaluate.
 * @param {Identifier} optionId - the identifier to find in the choice.
 * @param {DataElementSpecifications} dataElementSpecs - The available DataElement specs.
 * @returns {Value?} The first option in the choice that matches the specified optionId.
 */
function findOptionInChoice(choice, optionId, dataElementSpecs) {
  // First look for a direct match
  for (const option of choice.aggregateOptions) {
    if (optionId.equals(option.identifier)) {
      return option;
    }
  }
  // Then look for a match on one of the selected options's base types
  // E.g., if choice has Quantity but selected option is IntegerQuantity
  for (const option of choice.aggregateOptions) {
    if (checkHasBaseType(optionId, option.identifier, dataElementSpecs)) {
      return option;
    }
  }
  return null;
}

// stealing from shr-expand
/**
 * Determine if a type supports a code constraint.
 *
 * @param {Identifier} identifier - The identifier of the type to check.
 * @param {DataElementSpecifications} dataElementSpecs - The available DataElement specs.
 * @return {boolean} Whether or not the given type supports a code constraint.
 */
function supportsCodeConstraint(identifier, dataElementSpecs) {
  if (CODE.equals(identifier) || checkHasBaseType(identifier, new Identifier('shr.core', 'Coding'), dataElementSpecs)
      || checkHasBaseType(identifier, new Identifier('shr.core', 'CodeableConcept'), dataElementSpecs)) {
    return true;
  }
  const element = dataElementSpecs.findByIdentifier(identifier);
  if (element.value) {
    if (element.value instanceof IdentifiableValue) {
      return CODE.equals(element.value.identifier) || checkHasBaseType(element.value.identifier, new Identifier('shr.core', 'Coding'), dataElementSpecs)
          || checkHasBaseType(element.value.identifier, new Identifier('shr.core', 'CodeableConcept'), dataElementSpecs);
    } else if (element.value instanceof ChoiceValue) {
      for (const value of element.value.aggregateOptions) {
        if (value instanceof IdentifiableValue) {
          if (CODE.equals(value.identifier) || checkHasBaseType(value.identifier, new Identifier('shr.core', 'Coding'), dataElementSpecs)
              || checkHasBaseType(value.identifier, new Identifier('shr.core', 'CodeableConcept'), dataElementSpecs)) {
            return true;
          }
        }
      }
    }
  }
  return false;
}

function checkHasBaseType(identifier, baseIdentifier, dataElementSpecs) {
  if (typeof identifier === 'undefined' || typeof baseIdentifier === 'undefined') {
    return false;
  }
  const basedOns = getRecursiveBasedOns(identifier, dataElementSpecs);
  return basedOns.some(id => id.equals(baseIdentifier));
}

function getRecursiveBasedOns(identifier, dataElementSpecs, alreadyProcessed = []) {
  // If it's primitive or we've already processed this one, don't go further (avoid circular dependencies)
  if (identifier.isPrimitive || alreadyProcessed.some(id => id.equals(identifier))) {
    return alreadyProcessed;
  }

  // We haven't processed it, so look it up
  const element = dataElementSpecs.findByIdentifier(identifier);
  if (typeof element === 'undefined') {
    logger.error('Cannot resolve element definition for %s. ERROR_CODE:12029', identifier.fqn);
    return alreadyProcessed;
  }
  // Add it to the already processed list (again, to avoid circular dependencies)
  alreadyProcessed.push(identifier);
  // Now recursively get the BasedOns for each of the BasedOns
  for (const basedOn of element.basedOn) {
    alreadyProcessed = getRecursiveBasedOns(basedOn, dataElementSpecs, alreadyProcessed);
  }

  return alreadyProcessed;
}
// done stealing from shr-expand

module.exports = {exportToJSONSchema, setLogger, MODELS_INFO };


const action = require('../utils/createAction')
const createError = require('../utils/createError')

const getExpired = async (payload, ident, dispatch) => {
  const {
    service,
    type,
    endpoint,
    msFromNow = 0
  } = payload

  const timestamp = Date.now() + msFromNow
  const isodate = new Date(timestamp).toISOString()
  const payloadGet = { service, type, endpoint, onlyMappedValues: true, timestamp, isodate }

  return dispatch(action('GET', payloadGet, { ident }))
}

const deleteExpired = async (response, service, ident, dispatch) => {
  if (response.status !== 'ok' || !Array.isArray(response.data)) {
    return createError(`Could not get items from service '${service}'. Reason: ${response.status} ${response.error}`, 'noaction')
  }
  if (response.data.length === 0) {
    return createError(`No items to expire from service '${service}'`, 'noaction')
  }

  const data = response.data.map((item) =>
    ({ id: item.id, type: item.type }))

  return dispatch(action(
    'DELETE',
    { service, data },
    { queue: true, ident }
  ))
}

/**
 * Action to delete expired items.
 *
 * The given `endpoint` is used to retrieve expired items from the `service`, and
 * may use the paramters `timestamp` or `isodate`, which represents the current
 * time plus the microseconds in `msFromNow`, the former as microseconds since
 * January 1, 1970, the latter as an ISO formatted date and time string.
 *
 * The items are mapped and typed, so the `type` param should be set to one
 * or more types expected from the `endpoint`, and may be a string or an array
 * of strings.
 * @param {Object} payload - Action payload (service, type, endpoint, and msFromNow)
 * @param {Object} resources - Dispatch and queue functions
 * @returns {Object} Response object
 */
async function expire ({ payload, meta = {} }, { dispatch }) {
  const { service } = payload
  const { ident } = meta

  if (!service) {
    return createError(`Can't delete expired without a specified service`)
  }
  if (!payload.endpoint) {
    return createError(`Can't delete expired from service '${service}' without an endpoint`)
  }
  if (!payload.type) {
    return createError(`Can't delete expired from service '${service}' without one or more specified types`)
  }

  const response = await getExpired(payload, ident, dispatch)

  return deleteExpired(response, service, ident, dispatch)
}

module.exports = expire


var SocialCalc = require('socialcalc')
  , SpreadsheetColumn = require('spreadsheet-column')
  , column = new SpreadsheetColumn

const operationsList = [Set, InsertRow, DeleteRow, InsertCol, DeleteCol, Name, Merge, Unmerge]
const operationsHash = operationsList.reduce(function(obj, op) {obj[(new op).type] = op; return obj},{})
const scInstance = new SocialCalc.SpreadsheetControl()

exports.create = function() {
  SocialCalc.ResetSheet(scInstance.sheet)
  var newScSnapshot = SocialCalc.CreateSheetSave(scInstance.sheet)
  return newScSnapshot
}

exports.apply = function(scSnapshot, ops) {
  // load snapshot into our global SocialCalc instance
  scInstance.sheet.ParseSheetSave(scSnapshot)

  // Turn ops into a command string
  var cmds = unpackOps(ops)
  .map((op) => op.serialize())
  .filter((op) => !!op)
  .forEach((cmd) => {
    var error = SocialCalc.ExecuteSheetCommand(scInstance.sheet, new SocialCalc.Parse(cmd), /*saveundo:*/false)
    if(error) throw new Error(error)
  })

  var newScSnapshot = SocialCalc.CreateSheetSave(scInstance.sheet)

  return newScSnapshot
}

exports.transform = function(ops1, ops2, side) {
  return unpackOps(ops1).map(function(op1) {
    unpackOps(ops2).forEach(function(op2) {
      op1.transformAgainst(op2, ('left'==side))
    })
    return op1
  })
}

exports.transformCursor = function(cursor, ops/*, isOwnOp*/) { // isOwnOp is not supported for now, its purpose eludes me
  var cursorOp
  if (!~cursor.indexOf(':')) {
    cursorOp = new Set(cursor, null, null)
    unpackOps(ops).forEach((op) => cursorOp = cursorOp.transformAgainst(op))
    return cursorOp.hasEffect? cursorOp.target : null
  }else{
    // If the cursor contains a : it's a range, e.g. A1:C3
    return transformRange(cursor, unpackOps(ops))
  }
}

exports.deserializeEdit = function(cmds) {
  return cmds.split('\n')
  .reduce((ops, cmd) => {
    var op
    operationsList.some((Operation) => op = Operation.parse(cmd))
    if(op) op.forEach(op => ops.push(op)) // If nothing recognizes this, we filter it out.
    return ops
  }, [])
}

exports.serializeEdit = function(ops) {
  return unpackOps(ops).map((op) => op.serialize()).join('\n')
}

function unpackOps (ops) {
  return ops
  .map((op) => operationsHash[op.type].hydrate(op))
}

/**
----------------------------
OPERATIONS
----------------------------
*/
// These are SocialCalcs commands in the original format (we parse and serialize operations from/to this format):
//
//    set sheet attributename value (plus lastcol and lastrow)
//    set 22 attributename value
//    set B attributename value
//    set A1 attributename value1 value2... (see each attribute in code for details)
//    set A1:B5 attributename value1 value2...
//    erase/copy/cut/paste/fillright/filldown A1:B5 all/formulas/format
//    loadclipboard save-encoded-clipboard-data
//    clearclipboard
//    merge C3:F3
//    unmerge C3
//    insertcol/insertrow C5
//    deletecol/deleterow C5:E7
//    movepaste/moveinsert A1:B5 A8 all/formulas/format (if insert, destination must be in same rows or columns or else paste done)
//    sort cr1:cr2 col1 up/down col2 up/down col3 up/down
//    name define NAME definition
//    name desc NAME description
//    name delete NAME
//    recalc
//    redisplay
//    changedrendervalues
//    startcmdextension extension rest-of-command
//    sendemail ??? eddy ???

/**
All Operations implement the same Interface:

Operation#transformAgainst(op, side) : Operation // transforms this op against the passed one and returns the result (will never mutate in-place). `side` is for tie-breaking.
Operation#serialize() : string // Returns the corresponding SocialCalc command (without newline)
Operation.parse(cmd:String) : Array<Operation>|false // Checks if the SocialCalc command is equivalent to the Operation type, if so: returns the corresponding operation(s), else it returns false.
Operation.hydrade(obj) : Operation // turns a plain object into an Operation instance
*/

/**
 * Set operation
 */
function Set(target, attribute, value) {
  this.type = 'Set'
  this.target = target
  this.attribute = attribute
  this.value = value
  this.hasEffect = true // Can become effectless upon transformation
}

Set.hydrate = function(obj) {
  return new Set(obj.target, obj.attribute, obj.value)
}

Set.prototype.transformAgainst = function(op2, side) {
  if(op2 instanceof Set) {
    if(op2.target !== this.target) return this
    if(side) {
      var obj = new Set(this.target, this.attribute, this.value)
      obj.hasEffect = false
      return obj
    }else return this
  }else if(op2 instanceof InsertRow && this.target !== 'sheet') {
    var otherRow = parseCell(op2.newRow)[1]
    // If this target is a cell
    if (this.target.match(/[a-z]+[0-9]+/i)) {
      var myCell = parseCell(this.target)
        , thisRow = myCell[1]
      if (otherRow <= thisRow) return new Set(column.fromInt(myCell[0])+(thisRow+1), this.attribute, this.value)
      else return this
    }else
    // this target is a row
    if (parseInt(this.target) !== NaN) {
      var thisRow = parseInt(this.target)
      if (otherRow <= thisRow) return new Set(thisRow+1, this.attribute, this.value)
      else return this
    }
    // if this target is a column
    else return this
  }else
  if (op2 instanceof DeleteRow && this.target !== 'sheet'){
    var otherRow = parseCell(op2.row)[1]
    // If this target is a cell
    if (this.target.match(/[a-z]+[0-9]+/i)) {
      var myCell = parseCell(this.target)
        , thisRow = myCell[1]
      if (otherCol < thisCol) return new Set(column.fromInt(myCell[0])+(thisRow-1), this.attribute, this.value)
      else if (otherRow === thisRow) {
        var obj = new Set(this.target, this.attribute, this.value)
        obj.hasEffect = false
        return obj
      }
      else return this
    }else
    // this target is a row
    if (parseInt(this.target) === NaN) {
      var thisRow = this.target
      if (otherRow <= thisRow) return new Set(String(thisRow-1), this.attribute, this.value)
      else if (otherCol === thisCol) {
        var obj = new Set(this.target, this.attribute, this.value)
        obj.hasEffect = false
        return obj
      }
      else return this
    }
    // if this target is a row
    else return this
  }
  if (op2 instanceof InsertCol && this.target !== 'sheet') {
    var otherCol = parseCell(op2.newCol)[0]
    // If this target is a cell
    if (this.target.match(/[a-z]+[0-9]+/i)) {
      var myCell = parseCell(this.target)
        , thisCol = myCell[0]
      if (otherCol <= thisCol) return new Set(column.fromInt(thisCol+1)+myCell[1], this.attribute, this.value)
      else return this
    }else
    // this target is a col
    if (parseInt(this.target) === NaN) {
      var thisCol = column.fromStr(this.target)
      if (otherCol <= thisCol) return new Set(column.fromInt(thisCol+1), this.attribute, this.value)
      else return this
    }
    // if this target is a row
    else return this
  }else
  if (op2 instanceof DeleteCol && this.target !== 'sheet'){
    var otherCol = parseCell(op2.col)[0]
    // If this target is a cell
    if (this.target.match(/[a-z]+[0-9]+/i)) {
      var myCell = parseCell(this.target)
        , thisCol = myCell[0]
      if (otherCol < thisCol) return new Set(column.fromInt(thisCol-1)+myCell[1], this.attribute, this.value)
      else if (otherCol === thisCol) {
        var obj = new Set(this.target, this.attribute, this.value)
        obj.hasEffect = false
        return obj
      }
      else return this
    }else
    // this target is a col
    if (parseInt(this.target) === NaN) {
      var thisCol = column.fromStr(this.target)
      if (otherCol <= thisCol) return new Set(column.fromInt(thisCol-1), this.attribute, this.value)
      else if (otherCol === thisCol) {
        var obj = new Set(this.target, this.attribute, this.value)
        obj.hasEffect = false
        return obj
      }
      else return this
    }
    // if this target is a row
    else return this
  }
  return this
}

Set.prototype.serialize = function() {
  if(!this.hasEffect) return ''
  return 'set '+this.target+' '+this.attribute+' '+this.value
}

Set.parse = function(cmdstr) {
  if(0 !== cmdstr.indexOf('set')) return
  var parts = cmdstr.split(' ')
    , cmd = parts[0]
    , target = parts[1]
    , attr = parts[2]
    , value = cmdstr.substr(cmd.length+1+target.length+1+attr.length+1)

  // if this a range?
  if(~target.indexOf(':')) {
    return resolveRange(target).map((target) => new Set(target, attr, value))
  }else {
    return [new Set(target, attr, value)]
  }
}

/**
 * InsertRow operation
 */
function InsertRow(newRow) {
  this.type = 'InsertRow'
  this.newRow = newRow
}

InsertRow.hydrate = function(obj) {
  return new InsertRow(obj.newRow)
}

InsertRow.prototype.transformAgainst = function(op, left) {
  if(op instanceof InsertRow) {
    var otherCell = parseCell(op.newRow)
     , myCell = parseCell(this.newRow)
    if (otherCell[1] < myCell[1]) {
      return new InsertRow(column.fromInt(myCell[0])+(myCell[1]+1))
    }else if (otherCell[1] === myCell[1]) {
      if(left) return new InsertRow(column.fromInt(myCell[0])+(myCell[1]+1))
      else return this
    }else{
      return this
    }
  }
  else
  if (op instanceof DeleteRow) {
    var otherRow = parseCell(op.row)[1]
      , mycell = parseCell(this.newRow)
    if (otherRow < myCell[1]) {
      return new DeleteCol(column.fromInt(myCell[0])+(myCell[1]-1))
    }else {
      return this
    }
  }
  return this
}

InsertRow.parse = function(cmd) {
  if(0 !== cmd.indexOf('insertrow ')) return false
  return [new InsertRow(cmd.substr('insertrow '.length))]
}

InsertRow.prototype.serialize = function() {
  return 'insertrow '+this.newRow
}

/**
 * DeleteRow operation
 */
function DeleteRow(row) {
  this.type = 'DeleteRow'
  this.row = row
}

DeleteRow.hydrate = function(obj) {
  return new DeleteRow(obj.row)
}

DeleteRow.prototype.transformAgainst = function(op, left) {
  if(op instanceof InsertRow) {
    var otherCell = parseCell(op.newRow)
     , myCell = parseCell(this.row)

    if (otherCell[1] === myCell[1]) {
      if(left) return new InsertCol(column.fromInt(myCell[0])+(myCell[1]+1))
      else return this
    }
    else if (otherCell[1] < myCell[1]) {
      return new InsertCol(column.fromInt(myCell[0])+(myCell[1]+1))
    }else{
      return this
    }
  }else
  if (op instanceof DeleteRow) {
    var otherRow = parseCell(op.row)[1]
      , mycell = parseCell(this.row)
    if (otherRow === myCell[1]) {
      // tie! break it!
      // If both happen to delete the same row, one wins, the other becomes a noop
      if (left) return new DeleteRow(null)
      else return this
    }
    else if (otherRow < myCell[1]) {
      return new DeleteCol(column.fromInt(myCell[0])+(myCell[1]-1))
    }else {
      return this
    }
  }
  return this
}

DeleteRow.parse = function(cmd) {
  if(0 !== cmd.indexOf('deleterow ')) return false
  var val = cmd.substr('deletecol '.length)
  if (~val.indexOf(':')) return resolveRowRange(val).map(cell => new DeleteRow(cell))
  return [new DeleteCol(val)]
}

DeleteRow.prototype.serialize = function() {
  if (!this.row) return ''
  return 'deleterow '+this.row
}

/**
 * InsertCol operation
 */
function InsertCol(newCol) {
  this.type = 'InsertCol'
  this.newCol = newCol
}

InsertCol.hydrate = function(obj) {
  return new InsertCol(obj.newCol)
}

InsertCol.prototype.transformAgainst = function(op, left) {
  if(op instanceof InsertCol) {
    var otherCell = parseCell(op.newRow)
     , myCell = parseCell(this.newRow)
    if (otherCell[0] === myCell[0]) {
      if(left) return new InsertCol(column.fromInt(myCell[0]+1)+myCell[1])
      else return this
    }
    else if (otherCell[0] < myCell[0]) {
      return new InsertCol(column.fromInt(myCell[0]+1)+myCell[1])
    }else{
      return this
    }
  }
  else if (op instanceof DeleteCol) {
    var otherCol = parseCell(op.col)[0]
      , mycell = parseCell(this.newCol)
    if (otherCol < myCell[0]) {
      return new InsertCol(column.fromInt(myCell[0]-1)+myCell[1])
    }else {
      return this
    }
  }
  return this
}

InsertCol.parse = function(cmd) {
  if(0 !== cmd.indexOf('insertcol ')) return false
  return [new InsertCol(cmd.substr('insertcol '.length))]
}

InsertCol.prototype.serialize = function() {
  return 'insertcol '+this.newCol
}

/**
 * DeleteCol operation
 */
function DeleteCol(col) {
  this.type = 'DeleteCol'
  this.col = col
}

DeleteCol.hydrate = function(obj) {
  return new DeleteCol(obj.col)
}

DeleteCol.prototype.transformAgainst = function(op, left) {
  if(op instanceof InsertCol) {
    var otherCell = parseCell(op.newRow)
     , myCell = parseCell(this.newRow)

    if (otherCell[0] === myCell[0]) {
      if(left) return new InsertCol(column.fromInt(myCell[0]+1)+myCell[1])
      else return this
    }
    else if (otherCell[0] < myCell[0]) {
      return new InsertCol(column.fromInt(myCell[0]+1)+myCell[1])
    }else{
      return this
    }
  }else
  if (op instanceof DeleteCol) {
    var otherCol = parseCell(op.col)[0]
      , mycell = parseCell(this.col)
    if (otherCol === myCell[0]) {
      // tie! break it!
      if (left) return new DeleteCol(null)
      else return this
    }
    else if (otherCol < myCell[0]) { // This now only catches 'less' (we caught equal already^^)
      return new DeleteCol(column.fromInt(myCell[0]-1)+myCell[1])
    }else {
      return this
    }
  }
  return this
}

DeleteCol.parse = function(cmd) {
  if(0 !== cmd.indexOf('deletecol ')) return false
  var val = cmd.substr('deletecol '.length)
  if (~val.indexOf(':')) return resolveColRange(val).map(cell => new DeleteCol(cell))
  return [new DeleteCol(val)]
}

DeleteCol.prototype.serialize = function() {
  if (!this.col) return ''
  return 'deletecol '+this.col
}

/**
 * Merge operation
 */
function Merge(target) {
  this.type = 'Merge'
  this.target = target
}

Merge.hydrate = function(op) {
  return new Merge(op.target)
}

Merge.prototype.serialize = function() {
  if (!this.target) return ''
  return 'merge '+this.target
}

Merge.parse = function(cmdstr) {
  if(cmdstr.indexOf('merge ') !== 0) return false
  var target = cmdstr.substr('merge '.length)
  return [new Merge(target)]
}

Merge.prototype.transformAgainst = function(op) {
  // We can reuse the selection transformation here
  var newRange = transformRange(this.target, [op])
  if (!~newRange.indexOf(':')) return new Merge(null) // if it'S not a range anymore, this will become a noop.
  return new Merge(newRange)
}

/**
 * Unmerge operation
 */
function Unmerge(target) {
  this.type = 'Unmerge'
  this.target = target
}

Unmerge.hydrate = function(op) {
  return new Merge(op.target)
}

Unmerge.prototype.serialize = function() {
  if (!this.target) return ''
  return 'unmerge '+this.target
}

Unmerge.parse = function(cmdstr) {
  if(cmdstr.indexOf('unmerge ') !== 0) return false
  var target = cmdstr.substr('unmerge '.length)
  return [new Unmerge(target)]
}

Unmerge.prototype.transformAgainst = function(op) {
  // We can reuse the selection transformation here
  var newRange = transformRange(this.target, [op])
  return new Unmerge(newRange)
}

/**
 * Name operation
 */
function Name(name, action, val) {
  this.type = 'Name'
  this.name = name
  this.action = action
  this.value = val
}

Name.hydrate = function(op) {
  return new Name(op.name, op.action, op.value)
}

Name.prototype.serialize = function() {
  if (!this.name) return ''
  return 'name '+this.action+' '+this.name+' '+this.value
}

Name.parse = function(cmdstr) {
  if(cmdstr.indexOf('name ') !== 0) return false
  var cmd = cmdstr.substr('name '.length).split(' ')
  return new Name(cmd[1], cmd[0], cmd[2])
}

Name.prototype.transformAgainst = function(op, left) {
  if (op instanceof Name && op.name === this.name) {
    // def, def => tie
    // def, desc => -
    // def, del => tie
    // desc, def => -
    // desc, desc => tie
    // desc, del => tie
    // del, def => tie
    // del, desc => tie
    // del, del => noop
    if ('define' === this.action) {
      if (op.action === 'define' || op.action === 'delete') { // def, def => tie; def, del => tie
        if (left) return this
        else return new Name(null, this.action, this.value)
      }
      else return this
    }
    else if ('desc' === this.action) {
      if (op.action === 'desc' || op.action === 'delete') { // desc, desc => tie; desc, del => tie
        if (left) return this
        else return new Name(null, this.action, this.value)
      }
      else return this
    }
    else if ('delete' === this.action) {
      if (op.action === 'delete') { // del, del => noop
        return new Name(null, this.action, this.value)
      }
      else { // del, def => tie; del, desc => tie
        if (left) return this
        return new Name(null, this.action, this.value)
      }
    }
  }
  return this
}

/**
 * Utility functions
 */

function parseCell(cell) {
  var match = cell.match(/([a-z]+)([0-9]+)/i)
  if(!match) throw new Error('invalid cell id '+cell)
  return [column.fromStr(match[1]), parseInt(match[2])]
}

function resolveRange(range) {
  if(!range.indexOf(':')) throw new Error('not a range.')
  var parts = range.split(':')
    , start = parseCell(parts[0])
    , end = parseCell(parts[1])
  var cells = []
  for (var i=start[0]; i<=end[0]; i++) {
    for (var j=start[1]; j <= end[1]; j++) {
      cells.push(column.fromInt(i)+j)
    }
  }
  return cells
}

function resolveColRange(range) {
  if(!range.indexOf(':')) throw new Error('not a range.')
  var parts = range.split(':')
    , start = parseCell(parts[0])
    , end = parseCell(parts[1])
  var cells = []
  for (var i=start[0]; i<=end[0]; i++) {
    cells.push(column.fromInt(i)+start[1])
  }
  cells.reverse()
  return cells
}
function resolveRowRange(range) {
  if(!range.indexOf(':')) throw new Error('not a range.')
  var parts = range.split(':')
    , start = parseCell(parts[0])
    , end = parseCell(parts[1])
    , col = column.fromInt(start[0])
  var cells = []
  for (var i=start[1]; i<=end[1]; i++) {
    cells.push(col+i)
  }
  cells.reverse()
  return cells
}

/**
 * Transforms a range against an array of ops
 */
function transformRange(range, ops) {
  var rangeComps = range.split(':')
    , newRange
  var start = rangeComps[0]
  ops.forEach(op => start = transformRangeAnchor(start, op, /*isStart:*/true))
  var end = rangeComps[1]
  ops.forEach(op => end = transformRangeAnchor(end, op, /*isStart:*/false))
  if (start === end) return start
  return start+':'+end
}

/**
 * Transforms a range anchor, taking into account whether it's the start or end
 */
function transformRangeAnchor(target, op, isStart) {
  var thisCell = parseCell(target)
  if (op instanceof InsertCol) {
    var otherCell = parseCell(op.newCol)
    if (otherCell[0] <= thisCell[0]) return column.fromInt(thisCell[0]+1)+thisCell[1]
  }
  else if (op instanceof DeleteCol) {
    var otherCell = parseCell(op.col)
    if (otherCell[0] < thisCell[0]) return column.fromInt(thisCell[0]-1)+thisCell[1]
    if (otherCell[0] === thisCell[0]) {
      // Spreadsheet selection is different from text selection:
      // While text selection ends in the first *not* selected char ( "foo| |bar" => 3,4)
      // ... spreadsheet selection ends in the last selected cell. Thus we need to
      // differentiate between start and end. Shame on those who didn't think about this!
      if (!isStart) return column.fromInt(thisCell[0]-1)+thisCell[1]     }
  }
  else if (op instanceof InsertRow) {
    var otherCell = parseCell(op.newRow)
    if (otherCell[1] <= thisCell[1]) return column.fromInt(thisCell[0])+(thisCell[1]+1)
  }
  else if (op instanceof DeleteRow) {
    var otherCell = parseCell(op.col)
    if (otherCell[1] < thisCell[1]) return column.fromInt(thisCell[0])+(thisCell[1]-1)
    if (otherCell[1] === thisCell[1]) {
      if (!isStart) return column.fromInt(thisCell[0])+(thisCell[1]-1)
    }
  }
  // If nothing has returned already then this anchor doesn't change
  return target
}

exports.compose = function(ops1, ops2) {
  return ops1.concat(ops2)
}


const matchValue = (match, value) =>
  (Array.isArray(match)) ? match.includes(value) : (match === value)

const hasParam = (params, param) => params && params[param] !== undefined

const matchId = (endpoint, { payload }) =>
  !payload.endpoint || endpoint.id === payload.endpoint

const matchType = ({ match = {} }, { payload }) =>
  !match.type || matchValue(match.type, payload.type)

const matchScope = ({ match = {} }, { payload }) =>
  !match.scope ||
  matchValue(match.scope, payload.id
    ? (Array.isArray(payload.id) ? 'members' : 'member') : 'collection')

const matchAction = ({ match = {} }, { type }) =>
  !match.action || matchValue(match.action, type)

const matchParams = ({ match = {} }, { payload }) =>
  !match.params ||
  Object.keys(match.params)
    .every((param) => !match.params[param] || hasParam(payload, param))

const matchFilters = (
  { match = {} },
  { payload: { data, ...params }, meta }
) =>
  !match.filters ||
  match.filters.every((filter) => filter({ data, params, meta }))

/**
 * Return the first matching endpoint from an array of endpoints that has
 * already been sortert with higher specificity first. Type should match before
 * scope, which should match before action, but the order here is taken care of
 * by the required sorting.
 *
 * @param {Object[]} endpoints - Array of endpoint objects
 * @param {Object} action - Action object to match agains
 * @returns {Object} Matching endpoint
 */
function matchEndpoint (endpoints) {
  return ({ type, payload, meta }) => endpoints
    .find((endpoint) =>
      matchId(endpoint, { type, payload }) &&
      matchType(endpoint, { type, payload }) &&
      matchScope(endpoint, { type, payload }) &&
      matchAction(endpoint, { type, payload }) &&
      matchParams(endpoint, { type, payload }) &&
      matchFilters(endpoint, { type, payload, meta }))
}

module.exports = matchEndpoint


/**
 * Create an action object.
 * @param {string} type - The action type
 * @param {Object} payload - The payload of the action
 * @param {Object} props - Other action properties
 * @returns {Object} An action object
 */
function createAction (type, payload = {}, meta) {
  if (!type) {
    return null
  }

  const action = { type, payload }
  if (meta) {
    action.meta = meta
  }
  return action
}

module.exports = createAction


const { doAuth, getScheme } = require('./authorizeScheme')
const authorizeItems = require('./authorizeItems')

const authItems = (request, schemas) => ({
  ...request,
  ...authorizeItems(request, schemas)
})

const authItemsAndWrap = (request, access, schemas) => {
  const nextRequest = authItems({ ...request, access }, schemas)

  if (nextRequest.access.status === 'refused') {
    return {
      request: nextRequest,
      response: { status: 'noaccess', error: 'Request not allowed', access: nextRequest.access }
    }
  }
  return { request: nextRequest }
}

/**
 * Authorize the request according to the setting on the relevant schema.
 * Sets the access object with `status` property specifying whether access
 * is `granted` or `refused`, and returns the request.
 *
 * The access object also includes the `ident` and the  `scheme`used for
 * authentication.
 *
 * @param {Object} request - The request object to authorize
 * @param {Object} schemas - An object with all schemas that may be needed
 * @returns {Object} The request object with updated access object
 */
function authorizeRequest ({ schemas }) {
  return ({ request }) => {
    const { access = {}, params = {}, action } = request

    const { ident = null } = access

    if (ident && ident.root) {
      return authItemsAndWrap(request, { status: 'granted', ident, scheme: 'root' }, schemas)
    }
    if (!params.type) {
      return authItemsAndWrap(request, { status: 'granted', ident, scheme: null }, schemas)
    }

    const requireAuth = !!request.auth
    const schema = schemas[params.type]
    const scheme = getScheme(schema, action)
    const status = (doAuth(scheme, ident, requireAuth)) ? 'granted' : 'refused'

    return authItemsAndWrap(request, { status, ident, scheme }, schemas)
  }
}

module.exports = authorizeRequest


const getPluralType = (type, schemas) =>
  (schemas[type] && schemas[type].plural) || (type && `${type}s`)

/**
 * Complete missing props and allow only expected props on the request object.
 * @param {Object} request - The request object to complete
 * @returns {Object} The completed request object
 */
function requestFromAction (
  { type: action, payload, meta = {} },
  { endpoint, schemas = {} } = {}
) {
  const { data, ...params } = payload
  const { ident = null } = meta
  const typePlural = getPluralType(params.type, schemas)

  return {
    action,
    params,
    data,
    endpoint: (endpoint && endpoint.options) || null,
    access: { ident },
    meta: {
      typePlural
    }
  }
}

module.exports = requestFromAction


/**
 * Get service from type or service id.
 * @param {Object} schemas - The schemas
 * @param {Object} services - The services
 * @returns {function} Function to retrieve service from type and service id
 */
function getService (schemas, services) {
  return (type, service) => {
    if (!service && schemas[type]) {
      service = schemas[type].service
    }
    return services[service] || null
  }
}

module.exports = getService


const debug = require('debug')('great')
const { flatten } = require('ramda')
const action = require('../utils/createAction')
const createError = require('../utils/createError')

const makeErrorString = (results) => results
  .map((result, index) => (result.status === 'ok') ? null : `[${index}]: ${result.error}`)
  .filter(Boolean)
  .join('\n')

const setUpdatedParams = (dispatch, ident) => async (params) => {
  const { status, data, error } = await dispatch(
    action('GET_META', { service: params.service, keys: 'lastSyncedAt' }, { ident })
  )

  if (status === 'ok' && data && data.meta && data.meta.lastSyncedAt) {
    return {
      ...params,
      updatedAfter: data.meta.lastSyncedAt
    }
  } else {
    debug('SYNC: Could not get meta for service %s. Error: %s %s', params.service, status, error)
  }
  return params
}

const generateParamsWithUpdatedDates = async (params, dispatch, ident, updatedAfter, updatedUntil) => {
  if (updatedAfter || updatedUntil) {
    return params.map((params) => ({
      ...params,
      updatedAfter: new Date(updatedAfter),
      updatedUntil: new Date(updatedUntil)
    }))
  } else {
    return Promise.all(params.map(setUpdatedParams(dispatch, ident)))
  }
}

const paramsFromStringOrObject = (params) =>
  (typeof params === 'string') ? { service: params } : params

const generateFromParams = async (
  { retrieve, from, updatedAfter, updatedUntil },
  { ident },
  dispatch
) => {
  const fromParams = [].concat(from).map(paramsFromStringOrObject)
  if (retrieve === 'updated') {
    return generateParamsWithUpdatedDates(fromParams, dispatch, ident, updatedAfter, updatedUntil)
  } else {
    return fromParams
  }
}

// TODO: Updated dates from the first fromParams are always used on toParams.
// When updatedAfter is fetched from meta on different services, the earliest
// should be used.
const generateToParams = ({ to, type }, fromParams) => {
  const { updatedAfter, updatedUntil } = fromParams[0]
  return {
    ...((typeof to === 'string') ? { service: to } : to),
    type,
    updatedAfter,
    updatedUntil
  }
}

const filterDataOnUpdatedDates = (data, updatedAfter, updatedUntil) =>
  (Array.isArray(data) && data.length > 0 && (updatedAfter || updatedUntil))
    ? data.filter(isWithinUpdateWindow(updatedAfter, updatedUntil))
    : data

const isWithinUpdateWindow = (updatedAfter, updatedUntil) => (item) =>
  item.attributes.updatedAt &&
  (!updatedAfter || item.attributes.updatedAt > updatedAfter) &&
  (!updatedUntil || item.attributes.updatedAt <= updatedUntil)

const getFromService = (dispatch, type, { project, ident }) => async (fromParams) => {
  const response = await dispatch(action('GET', { type, ...fromParams }, { project, ident }))
  if (response.status !== 'ok') {
    return createError(`Could not get items from service '${fromParams.service}'. Reason: ${response.status} ${response.error}`)
  }

  return {
    status: 'ok',
    data: filterDataOnUpdatedDates(response.data, fromParams.updatedAfter, fromParams.updatedUntil)
  }
}

const createSetMetas = (fromParams, lastSyncedAt, ident, dispatch) => fromParams
  .reduce((services, params) =>
    (params.service && !services.includes(params.service))
      ? [ ...services, params.service ] : services,
  [])
  .map((service) =>
    dispatch(action('SET_META', { service, meta: { lastSyncedAt } }, { ident })))

/**
 * Action to sync from one service to another.
 *
 * `retrieve` indicates which items to retrieve. The default is `all`, which
 * will retrieve all items from the `get` endpoint. Set `retrieve` to `updated`
 * to retrieve only items that are updated after the  `lastSyncedAt` date for
 * the `from` service. This is done by passing the `lastSyncedAt` date as a
 * parameter named `updatedAfter` to the `get` endpoint, and by actively
 * filter away any items received with `updatedAt` earlier than `lastSyncedAt`.
 *
 * The `lastSyncedAt` metadata will be set on the `from` service when items
 * are retrieved and updated.
 *
 * @param {Object} payload - Action payload (from, to, type, and retrieve)
 * @param {Object} resources - Dispatch function
 * @returns {Promise} Promise of the action result
 */
async function sync ({ payload, meta = {} }, { dispatch }) {
  debug('Action: SYNC')
  const fromParams = await generateFromParams(payload, meta, dispatch)
  const toParams = generateToParams(payload, fromParams)

  const lastSyncedAt = new Date()

  const results = await Promise.all(
    fromParams.map(getFromService(dispatch, payload.type, meta))
  )

  if (results.some((result) => result.status !== 'ok')) {
    return (results.length === 1) ? results[0] : createError(makeErrorString(results))
  }

  const data = flatten(results.map((result) => result.data)).filter(Boolean)

  if (data.length === 0 && payload.syncNoData !== true) {
    return createError(`No items to update from service '${fromParams[0].service}'`, 'noaction')
  }

  return Promise.all([
    ...createSetMetas(fromParams, lastSyncedAt, meta.ident, dispatch),
    dispatch(action('SET', { data, ...toParams }, { ...meta, queue: true }))
  ])
    .then((responses) => {
      return { status: 'ok', data: responses }
    })
}

module.exports = sync


require('colors');

var glob = require('glob'),
    gutil = require('gulp-util'),
    path = require('path'),
    util = require('util');

/**
 * Task api that configures and loads gulp tasks.
 *
 * @param {!Gulp} gulp
 * @param {String=} cwd
 * @param {Object=} config
 */
var Taskman = function (gulp, cwd, config) {
  if (util.isNullOrUndefined(gulp)) {
    throw 'gulp must be defined';
  }

  /**
   * Configuration options that will be injected into every gulp task.
   *
   * @type {Object}
   */
  this._config = config || {},

  /**
   * Current working directory that path calculations should be relative to.
   *
   * @type {String}
   */
  this._cwd = cwd || __dirname;

  /**
   * Actual path of this file relative to the defined working directory.
   *
   * @type {String}
   */
  this._root = path.relative(this._cwd, __dirname);

  /**
   * Gulp instance. All tasks get injected the same gulp instances.
   *
   * @type {Gulp}
   */
  this._gulp = gulp;
};

/**
 * Taskman factory.
 *
 * @param {!Gulp} gulp
 * @param {String=} cwd
 * @param {Object=} config
 * @return {!TaskMan}
 */
Taskman.create = function (gulp, cwd, config) {
  return new Taskman(gulp, cwd, config);
};

/**
 * Loads a task or tasks, injecting a configuration.
 *
 * @throws Error if path is not a string.
 *
 * @param  {!String} path
 * @return {!TaskMan}
 */
Taskman.prototype.load = function (path) {
  if (!util.isString(path)) {
    throw new Error('path must be a string.');
  }

  gutil.log('Scanning ' + path.magenta + ' for tasks.');

  glob.sync(path, {
    cwd: this._cwd
  }).forEach(this._load.bind(this));

  return this;
};

/**
 * Loads a single task from a fully quilified path.
 *
 * @param  {!String} task
 */
Taskman.prototype._load = function (task) {
  gutil.log('Found task ' + task.magenta);

  var module = require('./' + path.relative(this._root, task));

  if (typeof module === 'function') {
    module(this._gulp, this._config);
  } else {
    gutil.log('Could not mount task '.red + task.red);
  }
};

module.exports = Taskman;


'use strict'
const ansi = require('ansi-styles')

const options = {
    'color_space': {alias: ['colorSpace']},
    'space_color': {alias: ['spaceColor']},
    'gap': {}
}

/**
 * black, white and gray (buggy?) are not included
 */
const Pony = function() {
  this._colors = [
    'red',
    'green',
    'yellow',
    'blue',
    'magenta',
    'cyan',
    'white',
    'black'
  ]

  this._colors_num = this._colors.length

  this._backgrounds = []

  for (var i = 0; i < this._colors_num; i++) {
    this._backgrounds[i] = 'bg'+this._colors[i].charAt(0).toUpperCase()+this._colors[i].slice(1)
  }

  this._skip = ['black', 'white', 'bgBlack', 'bgWhite']
  this._skip_num = this._skip.length - 1

  this._next = 0
  this._prev = -1

  this.options = {color_space: false, gap: 1, space_color: null}

  this.wrapper = {
    bg: this.ponyfy(true),
    r: this.ponyfy(),
    add: this.addorskip('add'),
    skip: this.addorskip('skip'),
    options: (opts) => {
      for (let i in options) {
        if (opts[i]) {
          this.options[i] = opts[i]
          continue
        }

        for (let j in opts) {
          if (options[i].alias && ~options[i].alias.indexOf(j))  {
            this.options[i] = opts[j]
            break
          }
        }
      }

      return this.wrapper
    },
    colors: () => this.colors(),
    reset: () => {
      this._skip = ['black', 'white', 'bgBlack', 'bgWhite']
      return this.wrapper
    },
    _colors: this._colors,
    _backgrounds: this._backgrounds
  }

  return this.wrapper

}

Pony.prototype = {
  top: function(color) {

    let index = this._colors.indexOf(color)

    if (~index) {
      this._colors.splice(index, 1)
      this._colors.unshift(color)
    }

    index = this._backgrounds.indexOf(color)
    if (~index) {
      this._backgrounds.splice(index, 1)
      this._backgrounds.unshift(color)
    }

    return this
  },
  colors: function(bg) {
    let res = []
    let items = bg ? this._backgrounds : this._colors
    let l = items.length

    for (let i = 0; i < l; i++) {
      if (this._skip.indexOf(items[i]) === -1)
        res.push(items[i])
    }

    return res;
  },
  allowed: function(color) {
    return this._colors.indexOf(color) !== -1 || this._backgrounds.indexOf(color) !== -1
  },
  addorskip: function(which, wrapper) {
    return (...colors) => {
      colors = colors.length == 1 ? colors[0] : colors
      colors = 'string' == typeof colors ? colors.split(' ') : colors
      colors = colors instanceof Array ? colors : [colors]
      let l = colors.length

       while(l--) {

        if (this.allowed(colors[l])) {
          let index = this._skip.indexOf(colors[l])

          if (index === -1 && which === 'skip') {
            this._skip_num++
            this._skip[this._skip_num] = colors[l]
          }
          else if (~index && which === 'add') {
            this.top(colors[l])
            this._skip.splice(index, 1)
            this._skip_num--
          }
        } else {
          throw new Error('Color '+ colors[l] +' is not recognized')
        }
      }

      return this.wrapper
    }
  },
  //wrapper to pass bg
  ponyfy: function(bg) {
    bg = bg ? bg : false

    return (...args) => {
      return this.output(args.join(' '), this.colors(bg))
    }
  },
  nextColor: function(s, colors) {
    let l = colors.length  - 1

    if (this._prev >= l) {
      this._prev = 0, this._next = 1

      s = ansi[colors[0]].open + s + ansi[colors[0]].close
    } else {
      s = ansi[colors[this._next]].open + s + ansi[colors[this._next]].close

      this._next++
      this._prev++
    }

    this._current_gap++

    return s
  },
  currentColor: function(s, colors) {
    let l = colors.length  - 1

    this._current_gap++

    if (this._prev >= l) {
      return ansi[colors[0]].open + s + ansi[colors[0]].close
    } else {
      return ansi[colors[this._next]].open + s + ansi[colors[this._next]].close
    }

  },
  colorSpace: function(s, color) {
    if (!this.allowed(color))
      throw new Error('Color '+ colors[l] +' is not recognized')

    return ansi[color].open + s + ansi[color].close
  },
  output: function(input, colors) {
    let l = input.length
    let output = ''
    let i = 0
    let gap = this.options.gap

    this._current_gap = 1

    for (i; i < l; i++) {
      this._current_gap = this._current_gap > gap ? 1 : this._current_gap
      let s = input.charAt(i)

      if (s == '\n') {
        output += s
      } else if (s == ' ' && !this.options.color_space) {
        output += s
      } else {

        if (s == ' ' && this.options.space_color != null)
          output += this.colorSpace(s, this.options.space_color)
        else
          output += this._current_gap == gap ? this.nextColor(s, colors) : this.currentColor(s, colors)
      }
    }

    //reset state
    this._next = 0, this._prev = -1

    return output
  }
}

module.exports = new Pony()


var insert = require('insert-module-globals'),
    utility = require('./utility');

/**
 * Hanging identifier resolver.
 *
 * @param  {Options=} options
 * @return {!Function(string)}
 */
var inserter = function (options) {
  var transform = function (file) {
    return insert(file, options || {});
  };

  return transform;
};

/**
 * Babel external helper module injector.
 *
 * This object will configure browserify to accept a streaming source and then
 * resolve any relevant compile time bindings to the built module. The module
 * can later by referenced by name later down the browserify pipeline and modified
 * further.
 *
 * @param {!Browserify} browserify
 * @param {!String} name
 * @param {!Readable} source
 */
var ModuleInjector = function (browserify, name, source) {
  if (utility.isNullOrUndefined(browserify)) {
    throw 'browserify must be defined.';
  }

  if (!utility.isNonEmptyString(name)) {
    throw 'name must be defined.';
  }

  if (utility.isNullOrUndefined(source)) {
    throw 'source must be defined.';
  }

  this._browserify = browserify;
  this._name = name;
  this._source = source;
  this._hasModule = false;
  this._hasResolver = false;
};

/**
 * Applies hooks.
 */
ModuleInjector.prototype.apply = function () {
  this._injectModule();
  this._addResolver();
};

/**
 * Adds the compile time module resolver.
 */
ModuleInjector.prototype._addResolver = function () {
  var self = this;

  if (this._hasResolver === false) {
    this._browserify.transform(this._createResolver(), {global: true});
  }

  this._hasResolver = true;
};

/**
 * Creates a resolver transform that can resolve hanging module identifiers.
 *
 * @return {!Function(string)}
 */
ModuleInjector.prototype._createResolver = function () {
  var babelHelpers = 'require("' + this._name + '")';

  return inserter({
    vars: {
      babelHelpers: function () {
        return babelHelpers;
      }
    }
  });
};

/**
 * Injects the compile time module into the browserify pipeline.
 */
ModuleInjector.prototype._injectModule = function () {
  if (this._hasModule === false) {
    this._browserify.require(this._source, {file: this._name, expose: this._name});
    this._browserify.exclude(this._name);
  }

  this._hasModule = true;
};

module.exports = ModuleInjector;


'use strict';

/**
 * Module dependencies.
 */
var util = require('util'),
    OAuth2Strategy = require('passport-oauth2'),
    InternalOAuthError = require('passport-oauth2').InternalOAuthError;

/**
 * `Strategy` constructor.
 *
 * The EVE SeAT authentication strategy authenticates requests by delegating to
 * EVE SeAT using the OAuth 2.0 protocol.
 *
 * Applications must supply a `verify` callback which accepts an `accessToken`,
 * `refreshToken` and service-specific `profile`, and then calls the `done`
 * callback supplying a `user`, which should be set to `false` if the
 * credentials are not valid.  If an exception occured, `err` should be set.
 *
 * Options:
 *   - `clientID`      your SeAT application's app key found in the App Console
 *   - `clientSecret`  your SeAT application's app secret
 *   - `baseURI`       URI to which SeAT is hosted
 *   - `callbackURL`   URL to which SeAT will redirect the user after granting authorization
 *   - `scope`         Scopes to grant access to. Optional, but should be set to `character.profile,character.roles,email`
 *
 * Examples:
 *
 *     passport.use(new EveSeatStrategy({
 *         clientID: 'yourAppKey',
 *         clientSecret: 'yourAppSecret'
 *         baseURI: 'https://www.example.net',
 *         callbackURL: 'https://www.example.net/oauth2/callback',
 *         scope: 'character.profile,character.roles,email'
 *       },
 *       function(accessToken, refreshToken, profile, done) {
 *         User.findOrCreate(..., function (err, user) {
 *           done(err, user);
 *         });
 *       }
 *     ));
 *
 * @param {Object} options
 * @param {Function} verify
 * @api public
 */
function Strategy(options, verify) {
  options = options || {};

  if (!options.baseURI) {
    throw new TypeError('EveSeatStrategy requires a baseURI option');
  }

  // Remove trailing slash if provided
  options.baseURI = options.baseURI.replace(/\/$/, '');

  options.authorizationURL = options.baseURI + '/oauth2/authorize';
  options.tokenURL = options.baseURI + '/oauth2/token';

  this._userProfileURL = options.baseURI + '/oauth2/profile';

  OAuth2Strategy.call(this, options, verify);
  this.name = 'eveseat';
}

/**
 * Inherit from `OAuth2Strategy`.
 */
util.inherits(Strategy, OAuth2Strategy);

/**
 * Retrieve user profile from EVE SeAT.
 *
 * This function constructs a normalized profile, with the following properties:
 *
 *   - `provider`           always set to `eveseat`
 *   - `email`              the users's email address
 *   - `accountCreateDate`  the users's account created date
 *   - `accountActive`      the users's account status
 *   - `characterID`        the characters's Character ID
 *   - `characterName`      the characters's Character Name
 *   - `characterPortrait`  the characters's Portrait URL
 *   - `corporationID`      the characters's Corporation ID
 *   - `corporationName`    the characters's Corporation Name
 *   - `allianceID`         the characters's Alliance ID
 *   - `allianceName`       the characters's Alliance Name
 *   - `allianceTicker`     the characters's Alliance Ticker
 *   - `roles`              SeAT roles associated with this user and/or character
 *
 * @param {String} accessToken
 * @param {Function} done
 * @api protected
 */
Strategy.prototype.userProfile = function(accessToken, done) {
  this._oauth2.get(this._userProfileURL, accessToken, function (err, body) {
    if(err) {
      return done(new InternalOAuthError('Failed to parse user profile', err));
    }

    var json;
    try {
      json = JSON.parse(body);
    } catch (ex) {
      return done(new Error('Failed to parse user profile'));
    }

    var profile = {
      'provider': 'eveseat',
      'accountCreateDate': Date.parse(json.accountCreateDate),
      'accountActive': json.accountActive,
      'email': json.email,
      'characterID': json.characterID,
      'characterName': json.characterName,
      'characterPortrait': json.characterPortrait,
      'corporationID': json.corporationID,
      'corporationName': json.corporationName,
      'allianceID': json.allianceID,
      'allianceName': json.allianceName,
      'allianceTicker': json.allianceTicker,
      'roles': json.roles
    };

    profile._raw = body;
    profile._json = json;

    done(null, profile);
  });
};

/**
 * Expose `Strategy`.
 */
module.exports = Strategy;


const { groupBy, prop } = require('ramda')

const groupByType = groupBy(prop('type'))

const mapItems = (data, request, mapping, target) =>
  (mapping) ? mapping.toService({ ...request, data }, target) : target

const mapDataPerType = (typeArrays, request, mappings) => Object.keys(typeArrays).reduce(
  (target, type) => mapItems(typeArrays[type], request, mappings[type], target),
  undefined
)

const mapData = (data, request, mappings) => (data)
  ? (Array.isArray(data))
    ? mapDataPerType(groupByType(data), request, mappings)
    : mapItems(data, request, mappings[data.type])
  : undefined

const applyEndpointMapper = (data, request, requestMapper) => (requestMapper)
  ? requestMapper({ ...request, data })
  : data

/**
* Map the data coming _from_ the service. Everything is handled by the mappings,
* but this method make sure that the right types are mapped.
*
* @param {Object} data - The data to map
* @param {Object} mappings - The mappings to map with
* @returns {Object} Mapped data
 */
function mapToService () {
  return ({ request, requestMapper, mappings }) => {
    const data = mapData(request.data, request, mappings)
    return {
      ...request,
      data: applyEndpointMapper(data, request, requestMapper)
    }
  }
}

module.exports = mapToService