const hash = require('./hash.js')
const semver = require('semver')
const semverOpt = { includePrerelease: true, loose: true }
const getDepSpec = require('./get-dep-spec.js')

// any fields that we don't want in the cache need to be hidden
const _source = Symbol('source')
const _packument = Symbol('packument')
const _versionVulnMemo = Symbol('versionVulnMemo')
const _updated = Symbol('updated')
const _options = Symbol('options')
const _specVulnMemo = Symbol('specVulnMemo')
const _testVersion = Symbol('testVersion')
const _testVersions = Symbol('testVersions')
const _calculateRange = Symbol('calculateRange')
const _markVulnerable = Symbol('markVulnerable')
const _testSpec = Symbol('testSpec')

class Advisory {
  constructor (name, source, options = {}) {
    this.source = source.id
    this[_source] = source
    this[_options] = options
    this.name = name
    if (!source.name) {
      source.name = name
    }

    this.dependency = source.name

    if (this.type === 'advisory') {
      this.title = source.title
      this.url = source.url
    } else {
      this.title = `Depends on vulnerable versions of ${source.name}`
      this.url = null
    }

    this.severity = source.severity || 'high'
    this.versions = []
    this.vulnerableVersions = []
    this.cwe = source.cwe
    this.cvss = source.cvss

    // advisories have the range, metavulns do not
    // if an advisory doesn't specify range, assume all are vulnerable
    this.range = this.type === 'advisory' ? source.vulnerable_versions || '*'
      : null

    this.id = hash(this)

    this[_packument] = null
    // memoized list of which versions are vulnerable
    this[_versionVulnMemo] = new Map()
    // memoized list of which dependency specs are vulnerable
    this[_specVulnMemo] = new Map()
    this[_updated] = false
  }

  // true if we updated from what we had in cache
  get updated () {
    return this[_updated]
  }

  get type () {
    return this.dependency === this.name ? 'advisory' : 'metavuln'
  }

  get packument () {
    return this[_packument]
  }

  // load up the data from a cache entry and a fetched packument
  load (cached, packument) {
    // basic data integrity gutcheck
    if (!cached || typeof cached !== 'object') {
      throw new TypeError('invalid cached data, expected object')
    }

    if (!packument || typeof packument !== 'object') {
      throw new TypeError('invalid packument data, expected object')
    }

    if (cached.id && cached.id !== this.id) {
      throw Object.assign(new Error('loading from incorrect cache entry'), {
        expected: this.id,
        actual: cached.id,
      })
    }
    if (packument.name !== this.name) {
      throw Object.assign(new Error('loading from incorrect packument'), {
        expected: this.name,
        actual: packument.name,
      })
    }
    if (this[_packument]) {
      throw new Error('advisory object already loaded')
    }

    // if we have a range from the initialization, and the cached
    // data has a *different* range, then we know we have to recalc.
    // just don't use the cached data, so we will definitely not match later
    if (!this.range || cached.range && cached.range === this.range) {
      Object.assign(this, cached)
    }

    this[_packument] = packument

    const pakuVersions = Object.keys(packument.versions || {})
    const allVersions = new Set([...pakuVersions, ...this.versions])
    const versionsAdded = []
    const versionsRemoved = []
    for (const v of allVersions) {
      if (!this.versions.includes(v)) {
        versionsAdded.push(v)
        this.versions.push(v)
      } else if (!pakuVersions.includes(v)) {
        versionsRemoved.push(v)
      }
    }

    // strip out any removed versions from our lists, and sort by semver
    this.versions = semver.sort(this.versions.filter(v =>
      !versionsRemoved.includes(v)), semverOpt)

    // if no changes, then just return what we got from cache
    // versions added or removed always means we changed
    // otherwise, advisories change if the range changes, and
    // metavulns change if the source was updated
    const unchanged = this.type === 'advisory'
      ? this.range && this.range === cached.range
      : !this[_source].updated

    // if the underlying source changed, by an advisory updating the
    // range, or a source advisory being updated, then we have to re-check
    // otherwise, only recheck the new ones.
    this.vulnerableVersions = !unchanged ? []
      : semver.sort(this.vulnerableVersions.filter(v =>
        !versionsRemoved.includes(v)), semverOpt)

    if (unchanged && !versionsAdded.length && !versionsRemoved.length) {
      // nothing added or removed, nothing to do here.  use the cached copy.
      return this
    }

    this[_updated] = true

    // test any versions newly added
    if (!unchanged || versionsAdded.length) {
      this[_testVersions](unchanged ? versionsAdded : this.versions)
    }
    this.vulnerableVersions = semver.sort(this.vulnerableVersions, semverOpt)

    // metavulns have to calculate their range, since cache is invalidated
    // advisories just get their range from the advisory above
    if (this.type === 'metavuln') {
      this[_calculateRange]()
    }

    return this
  }

  [_calculateRange] () {
    // calling semver.simplifyRange with a massive list of versions, and those
    // versions all concatenated with `||` is a geometric CPU explosion!
    // we can try to be a *little* smarter up front by doing x-y for all
    // contiguous version sets in the list
    const ranges = []
    this.versions = semver.sort(this.versions, semverOpt)
    this.vulnerableVersions = semver.sort(this.vulnerableVersions, semverOpt)
    for (let v = 0, vulnVer = 0; v < this.versions.length; v++) {
      // figure out the vulnerable subrange
      const vr = [this.versions[v]]
      while (v < this.versions.length) {
        if (this.versions[v] !== this.vulnerableVersions[vulnVer]) {
          // we don't test prerelease versions, so just skip past it
          if (/-/.test(this.versions[v])) {
            v++
            continue
          }
          break
        }
        if (vr.length > 1) {
          vr[1] = this.versions[v]
        } else {
          vr.push(this.versions[v])
        }
        v++
        vulnVer++
      }
      // it'll either be just the first version, which means no overlap,
      // or the start and end versions, which might be the same version
      if (vr.length > 1) {
        const tail = this.versions[this.versions.length - 1]
        ranges.push(vr[1] === tail ? `>=${vr[0]}`
          : vr[0] === vr[1] ? vr[0]
          : vr.join(' - '))
      }
    }
    const metavuln = ranges.join(' || ').trim()
    this.range = !metavuln ? '<0.0.0-0'
      : semver.simplifyRange(this.versions, metavuln, semverOpt)
  }

  // returns true if marked as vulnerable, false if ok
  // spec is a dependency specifier, for metavuln cases
  // where the version might not be in the packument.  if
  // we have the packument and spec is not provided, then
  // we use the dependency version from the manifest.
  testVersion (version, spec = null) {
    const sv = String(version)
    if (this[_versionVulnMemo].has(sv)) {
      return this[_versionVulnMemo].get(sv)
    }

    const result = this[_testVersion](version, spec)
    if (result) {
      this[_markVulnerable](version)
    }
    this[_versionVulnMemo].set(sv, !!result)
    return result
  }

  [_markVulnerable] (version) {
    const sv = String(version)
    if (!this.vulnerableVersions.includes(sv)) {
      this.vulnerableVersions.push(sv)
    }
  }

  [_testVersion] (version, spec) {
    const sv = String(version)
    if (this.vulnerableVersions.includes(sv)) {
      return true
    }

    if (this.type === 'advisory') {
      // advisory, just test range
      return semver.satisfies(version, this.range, semverOpt)
    }

    // check the dependency of this version on the vulnerable dep
    // if we got a version that's not in the packument, fall back on
    // the spec provided, if possible.
    const mani = this[_packument]?.versions?.[version] || {
      dependencies: {
        [this.dependency]: spec,
      },
    }

    if (!spec) {
      spec = getDepSpec(mani, this.dependency)
    }

    // no dep, no vuln
    if (spec === null) {
      return false
    }

    if (!semver.validRange(spec, semverOpt)) {
      // not a semver range, nothing we can hope to do about it
      return true
    }

    const bd = mani.bundleDependencies
    const bundled = bd && bd.includes(this[_source].name)
    // XXX if bundled, then semver.intersects() means vulnerable
    // else, pick a manifest and see if it can't be avoided
    // try to pick a version of the dep that isn't vulnerable
    const avoid = this[_source].range

    if (bundled) {
      return semver.intersects(spec, avoid, semverOpt)
    }

    return this[_source].testSpec(spec)
  }

  testSpec (spec) {
    // testing all the versions is a bit costly, and the spec tends to stay
    // consistent across multiple versions, so memoize this as well, in case
    // we're testing lots of versions.
    const memo = this[_specVulnMemo]
    if (memo.has(spec)) {
      return memo.get(spec)
    }

    const res = this[_testSpec](spec)
    memo.set(spec, res)
    return res
  }

  [_testSpec] (spec) {
    for (const v of this.versions) {
      const satisfies = semver.satisfies(v, spec, semverOpt)
      if (!satisfies) {
        continue
      }
      if (!this.testVersion(v)) {
        return false
      }
    }
    // either vulnerable, or not installable because nothing satisfied
    // either way, best avoided.
    return true
  }

  [_testVersions] (versions) {
    if (!versions.length) {
      return
    }

    // set of lists of versions
    const versionSets = new Set()
    versions = semver.sort(versions.map(v => semver.parse(v, semverOpt)))

    // start out with the versions grouped by major and minor
    let last = versions[0].major + '.' + versions[0].minor
    let list = []
    versionSets.add(list)
    for (const v of versions) {
      const k = v.major + '.' + v.minor
      if (k !== last) {
        last = k
        list = []
        versionSets.add(list)
      }
      list.push(v)
    }

    for (const set of versionSets) {
      // it's common to have version lists like:
      // 1.0.0
      // 1.0.1-alpha.0
      // 1.0.1-alpha.1
      // ...
      // 1.0.1-alpha.999
      // 1.0.1
      // 1.0.2-alpha.0
      // ...
      // 1.0.2-alpha.99
      // 1.0.2
      // with a huge number of prerelease versions that are not installable
      // anyway.
      // If mid has a prerelease tag, and set[0] does not, then walk it
      // back until we hit a non-prerelease version
      // If mid has a prerelease tag, and set[set.length-1] does not,
      // then walk it forward until we hit a version without a prerelease tag
      // Similarly, if the head/tail is a prerelease, but there is a non-pr
      // version in the set, then start there instead.
      let h = 0
      const origHeadVuln = this.testVersion(set[h])
      while (h < set.length && /-/.test(String(set[h]))) {
        h++
      }

      // don't filter out the whole list!  they might all be pr's
      if (h === set.length) {
        h = 0
      } else if (origHeadVuln) {
        // if the original was vulnerable, assume so are all of these
        for (let hh = 0; hh < h; hh++) {
          this[_markVulnerable](set[hh])
        }
      }

      let t = set.length - 1
      const origTailVuln = this.testVersion(set[t])
      while (t > h && /-/.test(String(set[t]))) {
        t--
      }

      // don't filter out the whole list!  might all be pr's
      if (t === h) {
        t = set.length - 1
      } else if (origTailVuln) {
        // if original tail was vulnerable, assume these are as well
        for (let tt = set.length - 1; tt > t; tt--) {
          this[_markVulnerable](set[tt])
        }
      }

      const headVuln = h === 0 ? origHeadVuln
        : this.testVersion(set[h])

      const tailVuln = t === set.length - 1 ? origTailVuln
        : this.testVersion(set[t])

      // if head and tail both vulnerable, whole list is thrown out
      if (headVuln && tailVuln) {
        for (let v = h; v < t; v++) {
          this[_markVulnerable](set[v])
        }
        continue
      }

      // if length is 2 or 1, then we marked them all already
      if (t < h + 2) {
        continue
      }

      const mid = Math.floor(set.length / 2)
      const pre = set.slice(0, mid)
      const post = set.slice(mid)

      // if the parent list wasn't prereleases, then drop pr tags
      // from end of the pre list, and beginning of the post list,
      // marking as vulnerable if the midpoint item we picked is.
      if (!/-/.test(String(pre[0]))) {
        const midVuln = this.testVersion(pre[pre.length - 1])
        while (/-/.test(String(pre[pre.length - 1]))) {
          const v = pre.pop()
          if (midVuln) {
            this[_markVulnerable](v)
          }
        }
      }

      if (!/-/.test(String(post[post.length - 1]))) {
        const midVuln = this.testVersion(post[0])
        while (/-/.test(String(post[0]))) {
          const v = post.shift()
          if (midVuln) {
            this[_markVulnerable](v)
          }
        }
      }

      versionSets.add(pre)
      versionSets.add(post)
    }
  }
}

module.exports = Advisory
