function(
    $q,
    DB
  ) {
    'ngInject';
    'use strict';
    var lineage = lineageFactory($q,DB());

    var get = function(id) {
      return lineage.fetchLineageById(id)
        .then(function(docs) {
          if (!docs.length) {
            var err = new Error(`Document not found: ${id}`);
            err.code = 404;
            throw err;
          }
          return docs;
        });
    };

    var hydrate = function(docs) {
      return lineage.fetchContacts(docs)
        .then(function(contacts) {
          lineage.fillContactsInDocs(docs, contacts);
          return docs;
        });
    };

    return {
      /**
       * Fetch a contact and its lineage by the given uuid. Returns a
       * contact model, or if options.merge is true the doc with the
       * lineage inline.
       */
      contact: function(id, options) {
        options = options || {};
        return get(id)
          .then(function(docs) {
            return hydrate(docs);
          })
          .then(function(docs) {
            // the first row is the contact
            var doc = docs.shift();
            // everything else is the lineage
            var result = {
              _id: id,
              lineage: docs
            };
            if (options.merge) {
              result.doc = lineage.fillParentsInDocs(doc, docs);
            } else {
              result.doc = doc;
            }
            return result;
          });
      },

      /**
       * Fetch a contact and its lineage by the given uuid. Returns a
       * report model.
       */
      report: function(id, options) {
        options = options || {};
        return get(id)
          .then(function(docs) {
            return hydrate(docs);
          })
          .then(function(docs) {
            // the first row is the report
            var doc = docs.shift();
            // the second row is the report's contact
            var contact = docs.shift();
            // everything else is the lineage
            if (options.merge) {
              lineage.fillParentsInDocs(doc.contact, docs);
            }
            return {
              _id: id,
              doc: doc,
              contact: contact,
              lineage: docs
            };
          });
      },
      reportSubjects: function(ids) {
        return lineage.fetchLineageByIds(ids)
          .then(function(docsList) {
            return docsList.map(function(docs){
              return {
                _id: docs[0]._id,
                doc: docs.shift(),
                lineage: docs
              };
            });
          });
      }
    };

  }
  .factory('FormatDataRecord', function(
    $log,
    $q,
    $translate,
    DB,
    FormatDate,
    Language,
    Settings
  ) {
    'ngInject';
    'use strict';

    var lineage = lineageFactory($q, DB());
    const patientFields = ['patient_id', 'patient_uuid', 'patient_name'];

    var getRegistrations = function(patientId) {
      var options = {
        key: patientId,
        include_docs: true,
      };
      return DB()
        .query('medic-client/registered_patients', options)
        .then(function(result) {
          return result.rows.map(function(row) {
            return row.doc;
          });
        });
    };

    var getPatient = function(patientId) {
      var options = { key: ['shortcode', patientId] };
      return DB()
        .query('medic-client/contacts_by_reference', options)
        .then(function(result) {
          if (!result.rows.length) {
            return;
          }
          if (result.rows.length > 1) {
            $log.warn(
              'More than one patient person document for shortcode "' +
                patientId +
                '"'
            );
          }
          return lineage.fetchHydratedDoc(result.rows[0].id);
        });
    };

    var fieldsToHtml = function(
      settings,
      keys,
      labels,
      data_record,
      def,
      locale
    ) {
      if (!def && data_record && data_record.form) {
        def = getForm(settings, data_record.form);
      }

      if (_.isString(def)) {
        def = getForm(settings, def);
      }

      var fields = {
        headers: [],
        data: [],
      };

      var data = _.extend({}, data_record, data_record.fields);

      _.each(keys, function(key) {
        if (_.isArray(key)) {
          fields.headers.push({ head: titleize(key[0]) });
          fields.data.push(
            _.extend(fieldsToHtml(key[1], labels, data[key[0]], def, locale), {
              isArray: true,
            })
          );
        } else {
          var label = labels.shift();
          fields.headers.push({ head: getMessage(settings, label) });
          if (def && def[key]) {
            def = def[key];
          }
          fields.data.push({
            isArray: false,
            value: prettyVal(settings, data, key, def, locale),
            label: label,
            hasUrl: patientFields.includes(key)
          });
        }
      });

      return fields;
    };

    /*
     * Get an array of keys from the form.  If dot notation is used it will be an
     * array of arrays.
     *
     * @param Object def - form definition
     *
     * @return Array  - form field keys based on forms definition
     */
    var getFormKeys = function(def) {
      var keys = {};

      var getKeys = function(key, hash) {
        if (key.length > 1) {
          var tmp = key.shift();
          if (!hash[tmp]) {
            hash[tmp] = {};
          }
          getKeys(key, hash[tmp]);
        } else {
          hash[key[0]] = '';
        }
      };

      var hashToArray = function(hash) {
        var array = [];

        _.each(hash, function(value, key) {
          if (typeof value === 'string') {
            array.push(key);
          } else {
            array.push([key, hashToArray(hash[key])]);
          }
        });

        return array;
      };

      if (def) {
        Object.keys(def.fields).forEach(function(key) {
          getKeys(key.split('.'), keys);
        });
      }

      return hashToArray(keys);
    };

    var translateKey = function(settings, key, field, locale) {
      var label;
      if (field) {
        label = getMessage(
          settings,
          field.labels && field.labels.short,
          locale
        );
      } else {
        label = translate(settings, key, locale);
      }
      // still haven't found a proper label; then titleize
      if (key === label) {
        return titleize(key);
      } else {
        return label;
      }
    };

    // returns the deepest array from `key`
    var unrollKey = function(array) {
      var target = [].concat(array),
        root = [];

      while (_.isArray(_.last(target))) {
        root.push(_.first(target));
        target = _.last(target);
      }

      return _.map(target, function(item) {
        return root.concat([item]).join('.');
      });
    };

    /**
     * Return a title-case version of the supplied string.
     * @name titleize(str)
     * @param str The string to transform.
     * @returns {String}
     */
    var titleize = function(s) {
      return s
        .trim()
        .toLowerCase()
        .replace(/([a-z\d])([A-Z]+)/g, '$1_$2')
        .replace(/[-\s]+/g, '_')
        .replace(/_/g, ' ')
        .replace(/(?:^|\s|-)\S/g, function(c) {
          return c.toUpperCase();
        });
    };

    var formatDateField = function(date, field) {
      if (!date) {
        return;
      }
      var formatted;
      var relative;
      if (_.contains(['child_birth_date', 'birth_date'], field)) {
        formatted = FormatDate.date(date);
        relative = FormatDate.relative(date, { withoutTime: true });
      } else {
        formatted = FormatDate.datetime(date);
        relative = FormatDate.relative(date);
      }
      return formatted + '(' + relative + ')';
    };

    /*
     * @param {Object} data_record - typically a data record or portion (hash)
     * @param {String} key - key for field
     * @param {Object} def - form or field definition
     */
    var prettyVal = function(settings, data_record, key, def, locale) {
      if (
        !data_record ||
        _.isUndefined(key) ||
        _.isUndefined(data_record[key])
      ) {
        return;
      }

      var val = data_record[key];

      if (!def) {
        return val;
      }

      if (def.fields && def.fields[key]) {
        def = def.fields[key];
      }

      if (def.type === 'boolean') {
        return val === true ? 'True' : 'False';
      }
      if (def.type === 'date') {
        return formatDateField(data_record[key], key);
      }
      if (def.type === 'integer') {
        // use list value for month
        if (def.validate && def.validate.is_numeric_month) {
          if (def.list) {
            for (var i in def.list) {
              if (def.list.hasOwnProperty(i)) {
                var item = def.list[i];
                if (item[0] === val) {
                  return translate(settings, item[1], locale);
                }
              }
            }
          }
        }
      }
      return val;
    };

    var translate = function(settings, key, locale, ctx, skipInterpolation) {
      if (_.isObject(key)) {
        return getMessage(settings, key, locale) || key;
      }
      var interpolation = skipInterpolation ? 'no-interpolation' : null;
      // NB: The 5th parameter must be explicitely null to disable sanitization.
      // The result will be sanitized by angular when it's rendered, so using
      // the default sanitization would result in double encoding.
      // Issue: medic/medic#4618
      return $translate.instant(key, ctx, interpolation, locale, null);
    };

    /*
     * With some forms like ORPT (patient registration), we add additional data to
     * it based on other form submissions.  Form data from other reports is used to
     * create these fields and it is useful to show these new fields in the data
     * records screen/render even though they are not defined in the form.
     */
    var includeNonFormFields = function(settings, doc, form_keys, locale) {
      var fields = [
        'mother_outcome',
        'child_birth_outcome',
        'child_birth_weight',
        'child_birth_date',
        'expected_date',
        'birth_date',
        'patient_id',
      ];

      var dateFields = ['child_birth_date', 'expected_date', 'birth_date'];

      _.each(fields, function(field) {
        var label = translate(settings, field, locale),
          value = doc[field];

        // Only include the property if we find it on the doc and not as a form
        // key since then it would be duplicated.
        if (!value || form_keys.indexOf(field) !== -1) {
          return;
        }

        if (_.contains(dateFields, field)) {
          value = formatDateField(value, field);
        }

        doc.fields.data.unshift({
          label: label,
          value: value,
          isArray: false,
          generated: true,
          hasUrl: patientFields.includes(field)
        });

        doc.fields.headers.unshift({
          head: label,
        });
      });
    };

    var getGroupName = function(task) {
      if (task.group) {
        return task.type + ':' + task.group;
      }
      return task.type;
    };

    var getGroupDisplayName = function(settings, task, language) {
      if (task.translation_key) {
        return translate(settings, task.translation_key, language, {
          group: task.group,
        });
      }
      return getGroupName(task);
    };

    /*
     * Fetch labels from translation strings or jsonform object, maintaining order
     * in the returned array.
     *
     * @param Array keys - keys we want to resolve labels for
     * @param String form - form code string
     * @param String locale - locale string, e.g. 'en', 'fr', 'en-gb'
     *
     * @return Array  - form field labels based on forms definition.
     *
     * @api private
     */
    var getLabels = function(settings, keys, form, locale) {
      var def = getForm(settings, form),
        fields = def && def.fields;

      return _.reduce(
        keys,
        function(memo, key) {
          var field = fields && fields[key];

          if (_.isString(key)) {
            memo.push(translateKey(settings, key, field, locale));
          } else if (_.isArray(key)) {
            _.each(unrollKey(key), function(key) {
              var field = fields && fields[key];
              memo.push(translateKey(settings, key, field, locale));
            });
          }

          return memo;
        },
        []
      );
    };

    var getForm = function(settings, code) {
      return settings.forms && settings.forms[code];
    };

    var getMessage = function(settings, value, locale) {
      function _findTranslation(value, locale) {
        if (value.translations) {
          var translation = _.findWhere(value.translations, { locale: locale });
          return translation && translation.content;
        } else {
          // fallback to old translation definition to support
          // backwards compatibility with existing forms
          return value[locale];
        }
      }

      if (!_.isObject(value)) {
        return value;
      }

      var test = false;
      if (locale === 'test') {
        test = true;
        locale = 'en';
      }

      var result =
        // 0) does it have a translation_key
        (value.translation_key &&
          translate(settings, value.translation_key, locale)) ||
        // 1) Look for the requested locale
        _findTranslation(value, locale) ||
        // 2) Look for the default
        value.default ||
        // 3) Look for the English value
        _findTranslation(value, 'en') ||
        // 4) Look for the first translation
        (value.translations &&
          value.translations[0] &&
          value.translations[0].content) ||
        // 5) Look for the first value
        value[_.first(_.keys(value))];

      if (test) {
        result = '-' + result + '-';
      }

      return result;
    };

    const getFields = function(doc, results, values, labelPrefix, depth) {
      if (depth > 3) {
        depth = 3;
      }
      Object.keys(values).forEach(function(key) {
        const value = values[key];
        const label = labelPrefix + '.' + key;
        if (_.isObject(value)) {
          results.push({
            label: label,
            depth: depth
          });
          getFields(doc, results, value, label, depth + 1);
        } else {
          const result = {
            label: label,
            value: value,
            depth: depth,
            hasUrl: patientFields.includes(key)
          };

          const filePath = 'user-file/' + label.split('.').slice(1).join('/');
          if (doc &&
              doc._attachments &&
              doc._attachments[filePath] &&
              doc._attachments[filePath].content_type &&
              doc._attachments[filePath].content_type.startsWith('image/')) {
            result.imagePath = filePath;
          }

          results.push(result);
        }
      });
      return results;
    };

    const getDisplayFields = function(doc) {
      // calculate fields to display
      if (!doc.fields) {
        return [];
      }
      const label = 'report.' + doc.form;
      const fields = getFields(doc, [], doc.fields, label, 0);
      const hide = doc.hidden_fields || [];
      hide.push('inputs');
      return _.reject(fields, function(field) {
        return _.some(hide, function(h) {
          const hiddenLabel = label + '.' + h;
          return hiddenLabel === field.label || field.label.indexOf(hiddenLabel + '.') === 0;
        });
      });
    };

    const formatXmlFields = function(doc) {
      doc.fields = getDisplayFields(doc);
    };

    const formatJsonFields = function(doc, settings, language) {
      if (!doc.form) {
        return;
      }
      const keys = getFormKeys(getForm(settings, doc.form));
      const labels = getLabels(settings, keys, doc.form, language);
      doc.fields = fieldsToHtml(
        settings,
        keys,
        labels,
        doc,
        language
      );
      includeNonFormFields(settings, doc, keys, language);
    };

    const formatScheduledTasks = function(doc, settings, language, context) {
      doc.scheduled_tasks_by_group = [];
      const groups = {};
      doc.scheduled_tasks.forEach(function(task) {
        // avoid crash if item is falsey
        if (!task) {
          return;
        }

        const copy = _.clone(task);
        const content = {
          translationKey: task.message_key,
          message: task.message,
        };

        if (!copy.messages) {
          // backwards compatibility
          copy.messages = messages.generate(
            settings,
            _.partial(translate, settings, _, _, null, true),
            doc,
            content,
            task.recipient,
            context
          );

          if (messages.hasError(copy.messages)) {
            copy.error = true;
          }
        }

        // timestamp is used for sorting in the frontend
        if (task.timestamp) {
          copy.timestamp = task.timestamp;
        } else if (task.due) {
          copy.timestamp = task.due;
        }

        // translation key used to identify translatable messages
        if (task.message_key) {
          copy.message_key = task.message_key;
        }

        // setup scheduled groups

        const groupName = getGroupName(task);
        let group = groups[groupName];
        if (!group) {
          const displayName = getGroupDisplayName(settings, task, language);
          groups[groupName] = group = {
            group: groupName,
            name: displayName,
            type: task.type,
            number: task.group,
            rows: [],
          };
        }
        group.rows.push(copy);
      });
      Object.keys(groups).forEach(function(key) {
        doc.scheduled_tasks_by_group.push(groups[key]);
      });
    };

    /*
     * Prepare outgoing messages for render. Reduce messages to organize by
     * properties: sent_by, from, state and message.  This helps for easier
     * display especially in the case of bulk sms.
     *
     * messages = [
     *    {
     *       recipients: [
     *          {
     *              to: '+123',
     *              facility: <facility>,
     *              timestamp: <timestamp>,
     *              uuid: <uuid>,
     *          },
     *          ...
     *        ],
     *        sent_by: 'admin',
     *        from: '+998',
     *        state: 'sent',
     *        message: 'good morning'
     *    }
     *  ]
     */
    const formatOutgoingMessages = function(doc) {
      var outgoing_messages = [];
      var outgoing_messages_recipients = [];
      doc.tasks.forEach(function(task) {
        task.messages.forEach(function(msg) {
          var recipient = {
            to: msg.to,
            facility: msg.facility,
            timestamp: task.timestamp,
            uuid: msg.uuid,
          };
          var done = false;
          // append recipient to existing
          outgoing_messages.forEach(function(m) {
            if (
              msg.message === m.message &&
              msg.sent_by === m.sent_by &&
              msg.from === m.from &&
              task.state === m.state
            ) {
              m.recipients.push(recipient);
              outgoing_messages_recipients.push(recipient);
              done = true;
            }
          });
          // create new entry
          if (!done) {
            outgoing_messages.push({
              recipients: [recipient],
              sent_by: msg.sent_by,
              from: msg.from,
              state: task.state,
              message: msg.message,
            });
            outgoing_messages_recipients.push(recipient);
          }
        });
      });
      doc.outgoing_messages = outgoing_messages;
      doc.outgoing_messages_recipients = outgoing_messages_recipients;
    };

    /*
     * Take data record document and return nice formated JSON object.
     */
    var makeDataRecordReadable = function(doc, settings, language, context) {
      var formatted = _.clone(doc);

      if (formatted.content_type === 'xml') {
        formatXmlFields(formatted);
      } else {
        formatJsonFields(formatted, settings, language);
      }

      if (formatted.scheduled_tasks) {
        formatScheduledTasks(formatted, settings, language, context);
      }

      if (formatted.kujua_message) {
        formatOutgoingMessages(formatted);
      }

      return formatted;
    };

    return function(doc) {
      var promises = [Settings(), Language()];
      var patientId = doc.patient_id || (doc.fields && doc.fields.patient_id);
      if (doc.scheduled_tasks && patientId) {
        promises.push(getPatient(patientId));
        promises.push(getRegistrations(patientId));
      }
      return $q.all(promises).then(function(results) {
        var settings = results[0];
        var language = results[1];
        var context = {};
        if (results.length === 4) {
          context.patient = results[2];
          context.registrations = results[3].filter(function(registration) {
            return registrationUtils.isValidRegistration(
              registration,
              settings
            );
          });
        }
        return makeDataRecordReadable(doc, settings, language, context);
      });
    };
  });