Source: version/VersionList.js

// import dependencies
import semver from 'semver'
import sort from 'semver-sort'
import {fetch} from '../utils/contextuals'
import VersionEntry from './VersionEntry'
import {Modules} from '../constants'

/**
 * Load a version list for the specified environment and module, then interact with it such
 * as adding a new {@link VersionEntry}, or grabbing the latest {@link VersionEntry}.
 */
class VersionList {
  constructor ({host, environment, module}) {
    this._host = host
    this._environment = environment
    this._module = module

    if ([undefined, null].includes(this._host)) {
      throw new Error('host must be specified')
    } else if ([undefined, null].includes(this._environment)) {
      throw new Error('environment must be specified')
    } else if ([undefined, null].includes(this._module)) {
      throw new Error('module must be specified')
    }
    this._versions = []

    // load asynchronously
    this._loaded = false
  }

  async _loadRemoteVersions (url, fetchOpts = {}) {
    try {
      const remoteVersionContents = await fetch(url || `https://${this._host}/version.json`, fetchOpts);
      const remoteVersionJson = await remoteVersionContents.json();

      // map the versions to an array, then sort
      const sortedVersionStrings = sort.desc(remoteVersionJson.map(v => (v.semver || semver.coerce(v.version).version)));

      // map back to an array of version entries
      this._versions = sortedVersionStrings.map(v => {
        const remoteVersion = remoteVersionJson.find(rv => {
          return semver.coerce(rv.semver || rv.version).version === v;
        })

        return new VersionEntry({
          ...remoteVersion,
          downloadUrl: remoteVersion.downloadUrl || `https://${this._host}/${this._module === Modules.ELECTRON ? 'win32-' : ''}v${semver.coerce(remoteVersion.version).version}.zip`
        })
      })
    } catch (err) {
      if (err.toString().includes('ENOTFOUND')) {
        throw new Error(`Failed to retrieve ${this._module} ${this._environment} version list: https://${this._host}/version.json`)
      } else {
        throw err;
      }
    }

    // map back to original format, but now sorted
    return this._versions;
  }

  /**
   * Load the remote {@link VersionList} from the CDN and return the sorted {@link VersionEntry} instances.
   * @returns {Promise<VersionList>}
   */
  async load (url, fetchOpts = {}) {
    await this._loadRemoteVersions(url, fetchOpts);
    this._loaded = true;
    return this;
  }

  /**
   * Return the module the {@link VersionList} was loaded for.
   * @returns {string}
   */
  getModule () {
    return this._module;
  }

  /**
   * Return the environment the {@link VersionList} was loaded for.
   * @returns {string}
   */
  getEnvironment () {
    return this._environment;
  }

  /**
   * Return the current {@link VersionList} (including any modifications made to it).
   * @returns {VersionEntry[]}
   */
  getVersions () {
    if (!this._loaded) {
      throw new Error('VersionList not loaded yet!');
    }
    return this._versions;
  }

  /**
   * Return the {@link VersionEntry} matching the one specified according to the findFunc param.
   * @param findFunc
   * @returns {VersionEntry}
   */
  find (findFunc) {
    return this._versions.find(findFunc);
  }

  /**
   * Verify the target {@link VersionEntry} is allowed, or return the next best version.
   * @param {VersionEntry} targetVersion
   * @param {Boolean} allowOverride
   * @returns {VersionEntry}
   */
  verifyOrNext (targetVersion, allowOverride) {
    if (!this._loaded) {
      throw new Error('VersionList not loaded yet!');
    }

    if (!targetVersion.semver) {
      throw new Error('New entry is missing property: semver')
    } else if (!targetVersion.hash) {
      throw new Error('New entry is missing property: hash')
    } else if (!targetVersion.origin) {
      throw new Error('New entry is missing property: origin')
    }

    // get the most recent remote version
    const current = this.latest() || targetVersion;

    // should we bump the minor if overrides aren't allowed?
    if (allowOverride !== true && (
      semver.eq(targetVersion.semver, current.semver) || // target version is equal to current version (check if we should bump)
      semver.lt(targetVersion.semver, current.semver) // target version is less than (check if we should bump)
    )) {
      // remote version matches target version (version override)
      const nextMinor = semver.inc(current.semver, 'minor');

      // for sandbox, auto-bump to the next minor version
      targetVersion.setVersion(nextMinor)
      console.debug(`Bumped${this._environment} version to ${targetVersion.semver}...`)
    } else if (semver.major(targetVersion.semver) === semver.major(current.semver) &&
      semver.minor(targetVersion.semver) === semver.minor(current.semver)) {
      // major and minor matches, but patch doesn't (patch update)
      // no action, approve the update
      console.debug('Patch update verified against current version list:', targetVersion.toJson())
    } else if (semver.major(targetVersion.semver) === semver.major(current.semver)) {
      // major matches, but minor and/or patch do not (minor update)
      // no action, approve the update
      console.debug('Minor update verified against current version list:', targetVersion.toJson())
    } else if (semver.major(targetVersion.semver) > semver.major(current.semver)) {
      // target major exceeds the current one (major update)
      throw new Error('Major version updates are not yet supported!')
    }

    return targetVersion;
  }

  /**
   * Add a new {@link VersionEntry} to the {@link VersionList}.
   * @param {VersionEntry} entry
   * @param opts
   * @returns {VersionList}
   */
  add (entry, opts = {matchOrigin: true, matchSemver: true, matchMajorMinor: true}) {
    if (!this._loaded) {
      console.warn('VersionList is not loaded yet, proceeding to add new version anyways')
    }

    if (!entry.semver) {
      throw new Error('New entry is missing property: semver')
    } else if (!entry.version) {
      throw new Error('New entry is missing property: version')
    } else if (!entry.hash) {
      throw new Error('New entry is missing property: hash')
    }

    // create a new array from the remote versions
    let versions = this._versions

    // check if the new entry is a patch, which needs to be appended to a minor entry
    if (semver.patch(entry.semver) > 0) {
      // check if we have a minor version
      const minor = versions.find(v => semver.eq(v.semver || semver.coerce(v.version).version, `${semver.major(entry.semver)}.${semver.minor(entry.semver)}.0`))

      if (minor) {
        // append the entry patch to the end of the patches
        const patches = minor.patches || []
        const existingPatchIndex = patches.findIndex(v => (v.semver || semver.coerce(v.version).version) === entry.semver)

        // remove patches array from the patch itself
        entry.setPatches(undefined)

        // see if we need to override a patch
        if (~existingPatchIndex) {
          console.debug(`Overriding existing ${this._environment} patch entry:`, versions[existingPatchIndex].toJson())
          patches[existingPatchIndex] = entry
        } else {
          console.debug(`Added new ${this._environment} patch entry:`, entry.toJson());
          minor.addPatch(entry)
        }

        console.info(`Updated minor entry:`, minor.toJson())
      } else {
        throw new Error(`Failed to append patch to parent for entry: ${entry.semver}`)
      }
    } else {
      // existing entry
      const existingIndex = versions.findIndex(v => entry.isMutationOf(v, opts));

      if (~existingIndex) {
        // replace existing, we are overriding it
        console.debug(`Overriding existing ${this._environment} minor/major entry:`, versions[existingIndex].toJson())
        versions[existingIndex] = entry
      } else {
        // add to front of array (minor or major versions)
        versions.unshift(entry)
      }
      console.debug(`New ${this._environment} minor/major entry:`, entry.toJson())
    }

    // map the versions to an array, then sort
    const sortedVersionStrings = sort.desc(versions.map(v => (v.semver || semver.coerce(v.version).version)));

    // map back to an array of version entries
    this._versions = sortedVersionStrings.map(v => {
      return versions.find(rv => {
        return semver.coerce(rv.semver || rv.version).version === v;
      })
    })

    return this
  }

  /**
   * Remove a {@link VersionEntry} of the specified properties from the version list.
   * Supports {@link VersionEntry} instances and semver strings.
   * @param entry {string|VersionEntry}
   */
  remove (entry) {
    this._versions = this._versions.filter(e => {
      return entry instanceof VersionEntry
        ? !entry.isEqual(e)
        : (entry?.semver || entry) !== e.semver
    })
    return this
  }

  /**
   * Return the semver string to be used for the next version.
   * @param release
   * @returns {string}
   */
  next (release = 'minor') {
    const latest = this.latest();
    return semver.inc(latest.semver, release);
  }

  /**
   * Return the semver string for the version directly preceding the latest.
   * @param release
   * @returns {string}
   */
  previous (release = 'minor') {
    if (this._versions.length > 1) {
      return this._versions[1];
    }
    const latest = this.latest();
    if (release === 'major') {
      return semver.coerce(`${semver.major(latest.semver) - 1}.0.0`).version
    } else if (release === 'minor') {
      return semver.coerce(`${semver.major(latest.semver)}.${semver.minor(latest.semver) - 1}.${semver.patch(latest.semver)}`).version
    } else if (release === 'patch') {
      return semver.coerce(`${semver.major(latest.semver)}.${semver.minor(latest.semver)}.${semver.patch(latest.semver) - 1}`)
    } else {
      throw new Error(`Unsupported release type for decrementing semver: ${release}`)
    }
  }

  /**
   * Return the latest {@link VersionEntry}.
   * @returns {VersionEntry}
   */
  latest () {
    return this._versions[0];
  }

  /**
   * Removes all but the most recent 2 versions from the version list and returns
   * an array of {@link VersionEntry}s removed.
   * @returns {VersionEntry[]}
   */
  getAndRemoveArchivableVersions = () => {
    // check if there are more than 2 versions
    if (this._versions.length > 2) {
      // remove versions starting from index 2 and return the removed elements
      return this._versions.splice(2);
    }

    // if there are only two or fewer versions, return an empty array
    return []
  }

  /**
   * Convert the {@link VersionList} and it's array of {@link VersionEntry}s to a serializable object.
   */
  toJson () {
    return this._versions.map(entry => entry.toJson())
  }
}

export default VersionList;