Source: resource.js

export const NO_RESOURCEFUL_ENDPOINT_ERROR = `
No resourceful endpoint tied to this resource.
You should use resourcefulEndpoint.newResource(),
or store this current resource with resourcefulEndpoint.store(resource).
`;

function isIterable(obj) {
  /* istanbul ignore next */
  if (obj == null) {
    return false;
  }
  return typeof obj[Symbol.iterator] === 'function';
}

/**
 * Validation error values
 * @readonly
 * @enum {number}
 */
const ValidationError = {
  /** No validation error */
  NONE: 0,
  /** The field to validate does not exist in the descriptor */
  NO_FIELD: 1,
  /** A required field was omitted */
  REQUIRED_FIELD: 2,
  /** A value didn't match the restricted values for this field */
  NOT_ALLOWED: 3,
  /** The value doesn't match the pattern allowed */
  INVALID_VALUE: 4,
  /** The Map value doesn't match the pattern allowed for either its keys or values */
  INVALID_MAP: 5
};

export { ValidationError };

/**
 * This class should not be used directly, but should instead be obtained
 * from {@link ResourcefulEndpoint} methods
 *
 * @example
 * const service = await client.service('metadata');
 * const contents = service.resourcefulEndpoint('contents');
 *
 * // Get an existing resource:
 * const content = await contents.readOne('some:ref');
 *
 * // Create a new Resource in metadata/contents:
 * const newContent = contents.newResource({
 *  title: 'something',
 *  synopsis: 'a really long synopsis'
 * });
 *
 * // `service`, `content` and `newContent` are used throughtout the rest of the examples
 *
 * @private
 */
class Resource {
  /**
   * @param {Object} rawData - the raw json response from Sequoia
   * @param {ResourcefulEndpoint} resourcefulEndpoint - the {@link ResourcefulEndpoint}
   * that this Resource is a member of
   *
   * @returns {Resource}
   */
  constructor(rawData, resourcefulEndpoint) {
    this.rawData = rawData;
    this.resourcefulEndpoint = resourcefulEndpoint;

    Object.assign(this, rawData);

    this.indirectlyLinkedResources = [];
    this.errors = [];
  }

  /**
   * Get a JSON representation of this Resources's keys/values
   *
   * If this Resource is tied to a {@link ResourcefulEndpoint} it will take the
   * keys and default values from the endpoint's descriptor. Otherwise it will
   * return which ever keys/values have been populated.
   *
   * @returns {Object}
   */
  toJSON() {
    let json = null;

    if (this.getResourceFields()) {
      json = {};

      Object.keys(this.getResourceFields()).forEach((key) => {
        json[key] = this.hasOwnProperty(key)
          ? this[key]
          : this.getResourceFields()[key].default;
      });
    } else {
      json = this.rawData;
    }

    return json;
  }

  /**
   * Get a stringified version of this {@link Resource} that is suitable
   * for saving to sequoia.
   *
   * Simply wraps the JSON of the resource as an array in the <pluralName>[] property
   *
   * @example
   *
   * const resource = new Resource({
   *   ref: 'test:testcontent',
   *   name: 'testcontent',
   *   title: 'A test resource'
   * }, contents);
   *
   * resource.serialise();
   *
   * // Returns:
   * '{"contents":[{"ref":"test:testcontent","name":"testcontent","title":"A test resource"}]}'
   *
   * @returns {string}
   */
  serialise() {
    return JSON.stringify(
      {
        [this.resourcefulEndpoint.pluralName]: [this.toJSON()]
      },
      null,
      '  '
    );
  }

  getResourceFields() {
    if (this.resourcefulEndpoint && this.resourcefulEndpoint.resourceful) {
      return this.resourcefulEndpoint.resourceful.fields;
    }

    return null;
  }

  /**
   * Flush (save) Resources to the backend. Performs the actual write of
   * data to sequoia for [save()]{@link Resource#save}
   *
   * @see {@link Resource#save}
   * @private
   *
   * @returns {Promise} - the `fetch` Promise
   */
  flush() {
    if (this.is_new === true) {
      return this.resourcefulEndpoint.store(this);
    }

    return this.resourcefulEndpoint.update(this);
  }

  /**
   * Save any [links]{@link Resource#link} that have been applied or resolve
   * immediately if there are none
   *
   * @private
   *
   * @returns {Promise}
   */
  saveIndirectLinks() {
    if (this.indirectlyLinkedResources.length === 0) {
      return Promise.resolve();
    }

    return Promise.all(this.indirectlyLinkedResources.map(link => link.save()));
  }

  /**
   * Save this Resource to the sequoia backend.
   *
   * Any [links]{@link Resource#link} that have also been applied in will also be saved at this point.
   *
   * @see {@link Resource#link}
   * @see {@link Resource#saveIndirectLinks}
   * @see {@link Resource#flush}
   *
   * @returns {Promise}
   */
  save() {
    if (this.resourcefulEndpoint) {
      const clearLinks = (resource) => {
        this.indirectlyLinkedResources = [];

        return resource;
      };

      // TODO: this should know whether this is a `new_resource` and `flush()` it first.
      // Should it also know the order? If only indirect links/relationships have been
      // added, don't bother `flush()ing` if the Resource exists already?
      return this.saveIndirectLinks()
        .then(() => this.flush())
        .then(clearLinks);
    }

    return Promise.reject(NO_RESOURCEFUL_ENDPOINT_ERROR);
  }

  /**
   * Destroy (DELETE) this Resource.
   *
   * @see {@link ResourcefulEndpoint#destroy}
   *
   * @returns {Promise}
   */
  destroy() {
    if (this.resourcefulEndpoint) {
      return this.resourcefulEndpoint.destroy(this);
    }

    return Promise.reject(NO_RESOURCEFUL_ENDPOINT_ERROR);
  }

  /**
   * @typedef {Object} Validation
   * @property {ValidationError} code - Error code
   * @property {string} message - What was invalid
   * @property {Object} typeInfo - The full typeInfo from the descriptor
   * @property {boolean} valid - Whether the field is valid or not
   */

  /**
   * Validate a specific field in the Resource.
   *
   * @param {string} field - the field to validate e.g. 'ratings'
   *
   * @returns {Validation}
   */
  validateField(field) {
    let code = ValidationError.NONE;
    let message = '';
    let typeInformation;
    let valid = false;

    if (!(field in this.getResourceFields())) {
      message = `${field} does not exist`;
      code = ValidationError.NO_FIELD;
    } else {
      const currentField = this.getResourceFields()[field];
      const { typeInfo, allowedValueMappings } = currentField;
      typeInformation = typeInfo;

      if (this[field] === undefined || this[field] === null) {
        if (currentField.required) {
          message = `${field} is required`;
          code = ValidationError.REQUIRED_FIELD;
        }
      } else if (currentField.readOnly === true) {
        // Skip readonly fields
        code = ValidationError.NONE;
      } else if (allowedValueMappings) {
        const allowedValues = Object.values(allowedValueMappings);
        if (!allowedValues.includes(this[field])) {
          message = `${field} is not one of ${allowedValues.join(', ')}`;
          code = ValidationError.NOT_ALLOWED;
        }
      } else if (typeInfo) {
        if ('pattern' in typeInfo) {
          // Simple value
          if (!new RegExp(typeInfo.pattern).test(this[field])) {
            message = `${field} does not match ${typeInfo.pattern}`;
            code = ValidationError.INVALID_VALUE;
          }
        } else if ('keys' in typeInfo) {
          // Map
          const re = new RegExp(typeInfo.keys.pattern);

          Object.keys(this[field]).forEach((key) => {
            if (!re.test(key)) {
              message += `${field}.${key} does not match ${
                typeInfo.keys.pattern
              }. `;
              code = ValidationError.INVALID_MAP;
            } else if ('values' in typeInfo) {
              const allowedValues = typeInfo.values[key].values;

              if (!allowedValues.includes(this[field][key])) {
                message += `${field}.${key} is not one of ${allowedValues.join(
                  ', '
                )}. `;
                code = ValidationError.INVALID_MAP;
              }
            }
          });
        }
      }
    }

    if (code === 0) {
      valid = true;
    }

    return {
      code,
      field,
      message,
      typeInfo: typeInformation,
      valid
    };
  }

  /**
   * Validate the resource.
   *
   * This method uses {@link Resource#validateField} on each field in the descriptor
   * and adds any invalid fields to the {@link Resource.errors} array for later querying.
   *
   * @example
   * contents.validate().catch((resource) => {
   *   // Show resource.errors[]
   * }).then((resource) => {
   *   if (!resource.errors.length) {
   *     return resource.save().then(() => {
   *       // do something on successfully saving
   *     });
   *   }
   *
   *   return Promise.reject(resource);
   * }).catch((resource) => {
   *   // Or show resource.errors[] here (or errors from save()ing)
   * });
   *
   * @see {@link Resource#validateField}
   *
   * @returns {Promise}
   */
  validate() {
    this.errors = [];

    Object.keys(this.getResourceFields()).forEach((field) => {
      const validation = this.validateField(field);

      if (!validation.valid) {
        this.errors.push(validation);
      }
    });

    if (this.errors.length) {
      return Promise.reject(new Error(this));
    }

    return Promise.resolve(this);
  }

  /**
   * Get the relationship info from the descriptor. When passing in
   * a Resource, the relationship will be inferred from its
   * {@link ResourcefulEndpoint#pluralName}. Pass a string to be explicit.
   * Note, when using a String value, this uses the key name in the descriptor's
   * relationship info, not the resourceType.
   *
   * @param {string|Resource} resource - A `string` or {@link Resource} to get relationship information for
   *
   * @see {@link Resource#linkResource}
   * @see {@link ResourcefulEndpoint#relationshipFor}
   *
   * @private
   *
   * @returns {undefined}
   */
  getRelationshipFor(resource) {
    if (typeof resource === 'string') {
      const { relationships } = this.resourcefulEndpoint;

      if (!(resource in relationships)) {
        throw new Error(`Relationship '${resource}' does not exist`);
      }

      return relationships[resource];
    }

    return this.resourcefulEndpoint.relationshipFor(
      resource.resourcefulEndpoint.pluralName
    );
  }

  /**
   * Add a resource ref to the appropriate relationship so it is linked
   * to this Resource.
   *
   * @param {string} ref - the Resource's `ref` to link
   * @param {Object} relationship - The relationship from the descriptor obtained from {@link Resource#getRelationshipFor}
   * @param {Resource?} resource - Specify this when linking an indirect relationship.
   * Defaults to the current {@link Resource} instance
   *
   * @see {@link Resource#getRelationshipFor}
   * @private
   *
   */
  linkRefToResource(ref, relationship, resource = this) {
    const { fieldNamePath } = relationship;

    // TODO: this will eventually be on the descriptor, but working
    // round it for now with the sequoia convention
    relationship.array = fieldNamePath.endsWith('Refs');

    if (relationship.array === true) {
      const existingRelationships = resource[fieldNamePath];

      if (!Array.isArray(existingRelationships)) {
        resource[fieldNamePath] = [ref];
      } else if (!existingRelationships.includes(ref)) {
        resource[fieldNamePath].push(ref);
      }
    } else {
      resource[fieldNamePath] = ref;
    }
  }

  /**
   * Link a {@link Resource} with this Resource
   *
   * If the link is `direct`, simply update *this* Reource with the relationship info.
   * If the link is `indirect`, add a potential link (not yet saved) to the
   * `indirectlLinkedResources` array to be flush when calling [save()]{@link Resource#save} later.
   *
   * @param {Resource} resource - the Resource to link
   * @param {string} as - Override the relationship type for this link.
   *
   * @see {@link Resource#linkRefToResource}
   *
   * @private
   */
  linkResource(resource, as) {
    const relationship = this.getRelationshipFor(as || resource);

    if (relationship.type === 'direct') {
      this.linkRefToResource(resource.ref, relationship);
    } else if (relationship.type === 'indirect') {
      this.linkRefToResource(
        this.ref,
        resource.getRelationshipFor(as || this),
        resource
      );
      this.indirectlyLinkedResources.push(resource);
    }
  }

  /**
   * Link one or many {@link Resource}s with this Resource
   * When linking an array of resources, if the relationship is not an array type
   * the last member of the array will become the only link. For example,
   * `content.link([...contents], 'parent')` will end up with
   * `resource.parentRef === contents[contents.length - 1].ref`.
   * Due to this, it is expected to only `link` certain resources at a time e.g.
   * `content.link(parentContent).link(assets).link(members, 'members').save()`
   *
   * @param {Resource[]} resource - the Resource (or array or Resources) to link
   * @param {string} as - Override the relationship type for this link.
   *
   * @example
   * const assets = service.resourcefulEndpoint('assets');
   * const asset = await assets.readOne('some:asset-ref');
   *
   * // If it's a known relationship, you don't have to bother providing the
   * // relationship type:
   * content.link(asset).save(); // Link and save
   *
   * // You can link a ResourceCollection (or any iterable):
   * const assets = await assets.browse(); // Get all assets already stored
   * content.link(assets).save(); // Link them all and save
   *
   * // You can provide the relationship type:
   * const relatedContent = await contents.readOne('some:other-ref');
   * content.link(relatedContent).save(); // Will default to the 'parent' relationship
   *
   * content.link(relatedContent, 'members').save(); // Will now be a 'member' of the content (e.g. episodes of a series)
   *
   * @returns {Resource} - the current Resource instance
   */
  link(resource, as) {
    if (!isIterable(resource)) {
      this.linkResource(resource, as);
    } else {
      resource.forEach(r => this.linkResource(r, as));
    }

    return this;
  }

  /**
   * Query where the current resource has linked items of a particular
   * relationship kind.
   *
   * @param {string} relationship - The name of the relationship in the descriptor
   * e.g. 'assets', 'categories', 'parent'
   *
   * @example
   * content.hasLinked('assets');
   * content.hasLinked('categories');
   *
   * @returns {boolean}
   */
  hasLinked(relationship) {
    return (
      this.linked
      && this.linked[relationship]
      && this.linked[relationship].length > 0
    );
  }

  // Convenience methods:
  // TODO: these should be rolled into an optional mixin
  /* istanbul ignore next */
  getLinkedAssetOfType(type) {
    if (this.hasLinked('assets')) {
      return this.linked.assets.filter(item => item.type === type);
    }

    return [];
  }

  /* istanbul ignore next */
  get categories() {
    if (this.hasLinked('categories')) {
      return this.linked.categories;
    }

    return null;
  }

  /* istanbul ignore next */
  get images() {
    return this.getLinkedAssetOfType('image');
  }

  /* istanbul ignore next */
  get videos() {
    return this.getLinkedAssetOfType('video');
  }

  /* istanbul ignore next */
  get trailers() {
    return this.videos.filter((item) => {
      const { tags } = item;

      if (tags) {
        return (
          item.tags.includes('trailerondemand')
          || item.tags.includes('usage:trailer')
        );
      }

      return true;
    });
  }

  /* istanbul ignore next */
  get mainVideos() {
    return this.videos.filter((item) => {
      const { tags } = item;

      if (tags) {
        return (
          !item.tags.includes('trailerondemand')
          && !item.tags.includes('usage:trailer')
        );
      }

      return true;
    });
  }

  /* istanbul ignore next */
  primaryBoxArt() {
    return this.images.find((item) => {
      const { tags } = item;

      if (tags) {
        return (
          item.tags.includes('portrait') || item.tags.includes('usage:boxart')
        );
      }

      return false;
    });
  }

  /* istanbul ignore next */
  primaryStill() {
    return this.images.find(
      item => item.tags
        && (item.tags.includes('usage:still') || item.tags.includes('landscape'))
    );
  }

  /* istanbul ignore next */
  trailer() {
    return (
      this.trailers.find(
        item => item.tags && item.tags.includes('console:primary')
      ) || this.trailers[0]
    );
  }

  /* istanbul ignore next */
  mainVideo(format) {
    return this.mainVideos.find(item => item.fileFormat === format);
  }
}

export default Resource;