import assert from 'node:assert'
import fs from 'node:fs'

import fetch from 'node-fetch'

// sent with each new video so PB Vision knows which version of this SDK made
// it. Read lazily and forgivingly: an app bundled with a tool like esbuild may
// not ship this package's package.json, or may ship its own in its place.
let platform
function getPlatform () {
  if (!platform) {
    let version = 'unknown'
    try {
      const pkg = JSON.parse(
        fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
      if (pkg.name === '@pbvision/partner-sdk' && pkg.version) {
        version = pkg.version
      }
    } catch {
      // keep 'unknown'
    }
    platform = { name: 'api', version }
  }
  return platform
}

// the camera settings a recording link may ask the PB Vision app for
const RECORDING_RESOLUTIONS = ['1080p', '4k']
const RECORDING_FPS = [30, 60]

const ENVIRONMENTS = {
  test: {
    apiServer: 'https://api-ko3kowqi6a-uc.a.run.app',
    webApp: 'https://pbv-dev.web.app',
    // development builds of the PB Vision app can't open https links, so test
    // links use the app's own scheme, which store builds open too
    recordLink: 'pbvision://record',
    firebaseConfig: {
      apiKey: 'AIzaSyCV1uh4fM7IFopuZOJ306oVWLV3cKLijFc',
      projectId: 'pbv-dev',
      appId: '1:542837591762:web:06f45c0d7a7e62f25aa70b'
    }
  },
  prod: {
    apiServer: 'https://api-2o2klzx4pa-uc.a.run.app',
    webApp: 'https://pb.vision',
    recordLink: 'https://pb.vision/record',
    firebaseConfig: {
      apiKey: 'AIzaSyCzC8mfo38HtkOR-_Y6xb7Pevp72LkrYfc',
      projectId: 'pbv-prod',
      appId: '1:439056169365:web:8b76be9c7cb7a2a13f5e9c'
    }
  }
}

/** @public */
export class PBVision {
  constructor (apiKey,  { useProdServer = false } = {}) {
    const underscoreIndex = apiKey.lastIndexOf('_')
    assert(apiKey && underscoreIndex !== -1, `invalid API key: ${apiKey}`)

    this.apiKey = apiKey
    this.uid = apiKey.substring(0, underscoreIndex)
    const config = useProdServer ? ENVIRONMENTS.prod : ENVIRONMENTS.test
    this.server = config.apiServer
    this.webAppUrl = config.webApp
    this.recordUrl = config.recordLink
    this.isDev = config === ENVIRONMENTS.test
  }

  /**
   * @typedef {Object} PartnerAccountInfo
   * @property {boolean} is4K whether your partner account may upload video
   *   above 1080p. Without 4K, PB Vision does not analyze such a video.
   */

  /**
   * Tells you what your partner account may do, e.g. whether to offer 4K.
   *
   * @returns {PartnerAccountInfo}
   */
  async getMyPartnerAccountInfo () {
    const resp = await this.__callAPI('about', {})
    const { is4K } = JSON.parse(resp)
    if (is4K) {
      this.__is4K = true
    }
    return { is4K }
  }

  /**
   * Tells PB Vision to make an HTTP POST request your URL after each of your
   * videos is done processing.
   *
   * @param {string} webhookUrl must start with https://
   */
  async setWebhook (webhookUrl) {
    assert(typeof webhookUrl === 'string' && webhookUrl.startsWith('https://'),
      'URL must be a string beginning with https://')
    return this.__callAPI('webhook/set', { url: webhookUrl })
  }

  /**
   * Gets the email addresses (if any) which have editor access to a video your
   * partner account uploaded.
   *
   * @param {string} vid video id
   */
  async getVideoEditors (vid) {
    return this.__callAPI('video/editors/get', { vid })
  }

  /**
   * Sets the email addresses (if any) which have editor access to a video your
   * partner account uploaded.
   *
   * @param {string} vid video id
   * @param {Array<string>} editorEmails a list of 0 to 8 email addresses to
   *   allow edit access to the video (replaces any previous editors list).
   * @param {Array<string>} viewerEmails a list of 0 to 8 email addresses to
   *   allow view access to the video (replaces any previous editors list).
   */
  async setVideoEditors (vid, editorEmails, viewerEmails) {
    return this.__callAPI('video/editors/set', { vid, editorEmails, viewerEmails })
  }

  /**
   * @typedef {Object} VideoUrlToDownloadResponse
   * @property {string} vid the ID of the new video
   * @property {boolean} [hasCredits] for passthrough partners, this field will
   *   be present and indicate whether the first user has any credits available
   */

  /**
   * Tells PB Vision to download the specified video and process it. When
   * processing is complete, your webhook URL will receive a callback.
   *
   * @param {string} videoUrl the publicly available URL of the video
   * @param {VideoMetadata} [metadata]
   * @returns {VideoUrlToDownloadResponse}
   */
  async sendVideoUrlToDownload (videoUrl, { userEmails = [], name, desc, gameStartEpoch, facility, court, fid, playersForTagging } = {}) {
    assert(typeof videoUrl === 'string' && videoUrl.startsWith('http'),
      'URL must be a string beginning with http')
    assert(videoUrl.split('?')[0].endsWith('.mp4'), 'video URL must have the .mp4 extension')
    const resp = await this.__callAPI(
      'add_video_by_url',
      { url: videoUrl, userEmails, name, desc, gameStartEpoch, facility, court, fid, playersForTagging })
    return JSON.parse(resp)
  }

  async __callAPI (path, body) {
    const resp = await fetch(`${this.server}/partner/${path}`, {
      method: 'POST',
      headers: {
        'x-api-key': this.apiKey,
        'content-type': 'application/json'
      },
      compress: true,
      body: JSON.stringify(body)
    })
    const respBody = await resp.text()
    if (resp.ok) {
      return respBody || true
    }
    throw new Error(`PB Vision API ${path} failed (${resp.status}): ${respBody}`)
  }

  /**
   * Information about the Video that can be set prior to it being uploaded.
   * @typedef {Object} VideoMetadata
   * @property {Array<string>} userEmails a list of email addresses of up to 4
   *   players who were playing in the game; they will also be notified when
   *   the video processing is complete (unless they have these notifications
   *   disabled)
   * @property {string} [name] the title of the game (if omitted, we'll use the
   *   time of the game, or if that isn't provided then the time of the upload)
   * @property {string} [desc] a longer description of the game
   * @property {integer} [gameStartEpoch] the epoch at which the game started
   * @property {string} [facility] the facility where the game was recorded (e.g., "Cool Club #3 - Barcelona")
   * @property {string} [court] the court where the game was recorded (e.g., "11A")
   * @property {integer} [fid] the ID of the folder in your partner account's
   *   library to add this video to (see getOrCreateFolder()). A fid that is
   *   not one of your folders is refused.
   * @property {PlayersForTagging} [playersForTagging] the players to tag
   *   automatically once processing completes, each by a name, an email, or
   *   both. Identify players by their role at the start of the first game.
   *   Honored by uploadVideo(), sendVideoUrlToDownload(), makeVideoId(), and
   *   makeRecordingLink().
   */

  /**
   * The players to tag automatically once the video is processed, identified
   * by their role at the start of the first game. The team that serves first
   * supplies `server` (its first server) and `serverPartner`. The other team's
   * first server is `receiver`: they start on the right and receive the first
   * serve. Their partner is `receiverPartner`. Omit both partners for singles.
   * Each email may be given for only one player.
   * @typedef {Object} PlayersForTagging
   * @property {PlayerForTagging} server the player who served the first point
   * @property {PlayerForTagging} receiver the player who received the first point
   * @property {PlayerForTagging} [serverPartner] the server's partner (omit for singles)
   * @property {PlayerForTagging} [receiverPartner] the receiver's partner (omit for singles)
   */

  /**
   * A player to tag, identified by a name, an email, or both. At least one of
   * the two is required.
   * @typedef {Object} PlayerForTagging
   * @property {string} [name] what to call the player: 1 to 64 characters
   *   after trimming. Shown unless the email belongs to a PB Vision account,
   *   in which case that account's own name is shown.
   * @property {string} [email] the player's email address. The player is
   *   emailed that they were tagged. If the address belongs to a PB Vision
   *   account, the game is added to that account's library; if not, the game
   *   joins their library when they sign up with this address.
   */

  /**
   * Allocate a new video ID *without* uploading a file yourself. Useful when the
   * video will be recorded and uploaded by another client. Pass the returned
   * `vid` (and `uid`) to that client so it uploads to this video instead of
   * allocating a new one. To have the game recorded with the PB Vision app,
   * use makeRecordingLink() instead.
   *
   * @param {VideoMetadata} [metadata] the video's metadata, plus fileExt
   * @param {string} [metadata.fileExt="mp4"] the extension the video will be
   *   uploaded with
   * @param {string} [metadata.nonce] 1 to 200 characters you choose, such as
   *   your own ID for the game. Every call with the same nonce gets the same
   *   video, even calls made at the same moment; the first call decides its
   *   metadata. Until the video is uploaded, each call checks that the account
   *   paying for it can still pay; if not, that call gets no video (`hasCredits`
   *   false), and a later call gets the same video once it can. A call that
   *   gets no video doesn't use the nonce up.
   * @returns {{vid: (string|undefined), uid: string, hasCredits: (boolean|undefined)}}
   *   the new video id and the uid it belongs to. `hasCredits` is false, with
   *   no `vid`, when the account paying for the video can't pay for it; for
   *   passthrough partners it is always present.
   */
  async makeVideoId ({ fileExt = 'mp4', userEmails = [], name, desc, gameStartEpoch, facility, court, fid, playersForTagging, nonce } = {}) {
    const resp = await this.__callAPI('make_video_id',
      { platform: getPlatform(), fileExt, userEmails, name, desc, gameStartEpoch, facility, court, fid, playersForTagging, nonce })
    const { hasCredits, vid } = JSON.parse(resp)
    const ret = { vid, uid: this.uid }
    if (hasCredits !== undefined) {
      ret.hasCredits = hasCredits
    }
    return ret
  }

  /**
   * The video's details and camera settings for makeRecordingLink(). The
   * details are the same as in VideoMetadata, except that there is no
   * gameStartEpoch: the game's time comes from its recording.
   * @typedef {Object} RecordingLinkOptions
   * @property {Array<string>} [userEmails] the email addresses of up to 4
   *   players in the game (see VideoMetadata)
   * @property {string} [name] the title of the game
   * @property {string} [desc] a longer description of the game
   * @property {string} [facility] the facility where the game is played
   * @property {string} [court] the court where the game is played
   * @property {integer} [fid] the ID of the folder in your partner account's
   *   library to add the video to (see getOrCreateFolder())
   * @property {PlayersForTagging} [playersForTagging] the players to tag
   *   automatically once processing completes
   * @property {string} [nonce] identifies the game, e.g. your own ID for it:
   *   every call with the same nonce returns the same video and link (see
   *   makeVideoId())
   * @property {string} [resolution='1080p'] the resolution to record at:
   *   '1080p' or '4k'. 4K requires your partner account to have 4K enabled;
   *   otherwise PB Vision rejects the upload.
   * @property {integer} [fps=30] the frame rate to record at: 30 or 60
   * @property {boolean} [mayChangeResolution=false] whether the person
   *   recording may choose a different resolution in the app
   * @property {boolean} [mayChangeFPS=false] whether the person recording may
   *   choose a different frame rate in the app
   */

  /**
   * @typedef {Object} RecordingLink
   * @property {string} vid the ID of the video the recording will upload to
   * @property {string} uid the ID of your partner account, which owns the video
   * @property {string} url the link that opens the PB Vision app to record the
   *   game
   */

  /**
   * Makes a video for a game and a link that opens the PB Vision app on a
   * phone straight to its camera, to record the game into that video. Anyone
   * can record from the link, signed in to PB Vision or not. On a phone
   * without the app, the link shows a page for installing it.
   *
   * By default the app records at 1080p and 30 FPS, and the person recording
   * can't change either. A phone that can't record what the link asks for
   * steps down to the best quality it supports. The link's settings apply
   * regardless of the recorder's own PB Vision subscription. 4K requires your
   * partner account to have 4K enabled (see getMyPartnerAccountInfo()), so
   * without it this refuses a 4K link rather than make a video PB Vision
   * won't analyze.
   *
   * Each link is for one recording: only the first upload to its video
   * counts, so make a new link for each game. If several people may ask for
   * the same game's link, pass the same `nonce` (e.g. your own ID for the
   * game) and they all get one video and one link.
   *
   * Open the link with a real tap (e.g. in a text message, an email, or a
   * scanned QR code) or by handing it to the phone's operating system, not
   * inside a webview, so that the phone can open the PB Vision app.
   *
   * With the test server (`useProdServer: false`), the link starts with
   * `pbvision://record` and needs a development build of the PB Vision app.
   *
   * @param {RecordingLinkOptions} [options]
   * @returns {RecordingLink}
   * @throws {Error} if gameStartEpoch is given, a camera setting is invalid,
   *   4K is asked for and your partner account doesn't have 4K, or PB Vision
   *   doesn't make the video (e.g. because the account paying for it can't
   *   pay for it)
   */
  async makeRecordingLink ({
    resolution = '1080p', fps = 30, mayChangeResolution = false, mayChangeFPS = false,
    gameStartEpoch, userEmails, name, desc, facility, court, fid, playersForTagging, nonce
  } = {}) {
    assert(gameStartEpoch === undefined,
      'makeRecordingLink() does not take gameStartEpoch: the game\'s time comes from its recording, and a time set now would override it')
    assert(RECORDING_RESOLUTIONS.includes(resolution),
      `resolution must be '1080p' or '4k', not ${JSON.stringify(resolution)}`)
    assert(RECORDING_FPS.includes(fps), `fps must be 30 or 60, not ${JSON.stringify(fps)}`)
    assert(typeof mayChangeResolution === 'boolean', 'mayChangeResolution must be a boolean')
    assert(typeof mayChangeFPS === 'boolean', 'mayChangeFPS must be a boolean')

    // checked before making the video, so a 4K link that PB Vision wouldn't
    // analyze never gets one; only a yes is remembered, so an account that
    // gets 4K later can ask for it without a new PBVision
    if (resolution === '4k' && !this.__is4K && !(await this.getMyPartnerAccountInfo()).is4K) {
      throw new Error('your partner account does not have 4K enabled, so PB Vision would not analyze a 4K recording; ask for 1080p, or ask PB Vision to enable 4K')
    }

    const { vid, uid, hasCredits } = await this.makeVideoId({
      fileExt: 'mp4', userEmails, name, desc, facility, court, fid, playersForTagging, nonce })
    if (hasCredits === false) {
      throw new Error('PB Vision did not make a video for this recording link: the account paying for it has no credits available (hasCredits is false)')
    }
    if (!vid) {
      throw new Error('PB Vision did not return a video ID for this recording link')
    }
    // env=test lets the app explain a test-server link it can't record,
    // instead of recording a game whose video exists only on the test server
    const params = { vid, uid, resolution, fps, mayChangeResolution, mayChangeFPS }
    if (this.isDev) {
      params.env = 'test'
    }
    // encodeURIComponent() rather than URLSearchParams, which writes a space
    // as "+" and not every app reads that back as a space
    const query = Object.entries(params)
      .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
      .join('&')
    return { vid, uid, url: `${this.recordUrl}?${query}` }
  }

  /**
   * Upload a video for processing by the AI.
   *
   * The video is only uploaded if the account paying for it has credit(s)
   * available with which the video can be analyzed. If not, nothing is
   * uploaded and this returns `{ hasCredits: false }` with no `vid`.
   *
   * The upload resumes by itself after a dropped connection or a temporary
   * error from the storage service. If it still fails, the error thrown has
   * the video's ID as its `vid` property. To retry without making a second
   * video for the game, pass a `nonce` (see makeVideoId()): calling this again
   * with the same nonce uploads to the same video.
   *
   * @param {string} mp4Filename
   * @param {VideoMetadata} [metadata]
   * @returns {VideoUrlToDownloadResponse}
   * @throws {Error} if the upload fails, with the video's ID as `vid`. A
   *   missing or empty file is refused before any video is made.
   */
  async uploadVideo (mp4Filename, metadata = {}) {
    // checked before making the video, so a file that can't be uploaded
    // doesn't leave a video behind that never gets one
    const stats = await fs.promises.stat(mp4Filename)
    assert(stats.isFile(), `cannot upload ${mp4Filename}: it is not a file`)
    assert(stats.size > 0, `cannot upload ${mp4Filename}: the file is empty`)
    const pieces = mp4Filename.split('.')
    const ext = pieces[pieces.length - 1]
    const { hasCredits, vid } = await this.makeVideoId({ ...metadata, fileExt: ext })
    if (hasCredits === false) {
      return { hasCredits }
    }
    const bucket = `pbv-uploads${this.isDev ? '-dev' : ''}`
    const objName = `${this.uid}/${vid}.${ext}`
    try {
      await uploadToGCS(bucket, objName, mp4Filename)
    } catch (e) {
      const err = new Error(`PB Vision Upload of video ${vid} failed: ${e.message}`, { cause: e })
      err.vid = vid
      throw err
    }
    const ret = { vid }
    if (hasCredits !== undefined) {
      ret.hasCredits = hasCredits
    }
    return ret
  }

  /**
   * @typedef {Object} GetOrCreateFolderResponse
   * @property {integer} fid the folder's ID; pass it as the `fid` metadata
   *   field to put a new video in this folder
   * @property {boolean} created true if this call created the folder, false if
   *   it already existed
   */

  /**
   * Gets the folder in your partner account's library with exactly this name,
   * creating it if there is none. Calling it again with the same name returns
   * the same folder, as long as it hasn't been renamed or moved since, so it
   * is safe to retry.
   *
   * Use a unique name for each event, such as one that includes its date;
   * otherwise two events with the same name would share one folder.
   *
   * @param {string} name the folder's name: 1 to 200 characters, with no
   *   leading or trailing whitespace
   * @param {Object} [options]
   * @param {integer} [options.parent] the ID of the folder to look in, and to
   *   create the folder in; omit for the top level of your library
   * @returns {GetOrCreateFolderResponse}
   */
  async getOrCreateFolder (name, { parent } = {}) {
    const resp = await this.__callAPI('folder/get_or_create', { name, parent })
    const { fid, created } = JSON.parse(resp)
    return { fid, created }
  }

  /**
   * Renames a folder in your partner account's library, moves it, or changes
   * whether it is public. Fields you omit are left as they are.
   *
   * @param {integer} fid the ID of the folder to change
   * @param {Object} [changes]
   * @param {string} [changes.name] the folder's new name: 1 to 200 characters,
   *   with no leading or trailing whitespace
   * @param {?integer} [changes.parent] the ID of the folder to move it into, or
   *   null to move it to the top level of your library
   * @param {boolean} [changes.public] whether anyone with the folder's link may
   *   view it, signed in or not (see getPublicFolderUrl()). A folder inside a
   *   public folder is only shown with it if it is public too.
   */
  async updateFolder (fid, { name, parent, public: isPublic } = {}) {
    await this.__callAPI('folder/update', { fid, name, parent, public: isPublic })
  }

  /**
   * The link to a folder's page on PB Vision, where anyone can view its videos
   * once the folder is public (see updateFolder()).
   *
   * @param {integer} fid the folder's ID
   * @returns {string}
   */
  getPublicFolderUrl (fid) {
    assert(Number.isInteger(fid) && fid > 0, `invalid folder ID: ${fid}`)
    return `${this.webAppUrl}/library/public/${encodeURIComponent(this.uid)}/${fid}`
  }
}

// How uploadToGCS() reads and sends the file: one chunk in memory at a time.
// GCS needs every chunk but the last to be a multiple of 256 KiB; larger
// chunks tend to upload faster but take more memory.
const UPLOAD_CHUNK_SIZE = 8 * 1024 * 1024

// How uploadToGCS() retries a request that fails with a network error or a
// temporary server error: up to UPLOAD_MAX_RETRIES times in a row without the
// upload making progress, waiting about twice as long before each retry, up to
// UPLOAD_MAX_RETRY_DELAY_MS (plus up to a second at random).
const UPLOAD_MAX_RETRIES = 6
const UPLOAD_FIRST_RETRY_DELAY_MS = 1000
const UPLOAD_MAX_RETRY_DELAY_MS = 32000

// Uploads a file to GCS with a resumable upload, following GCS's protocol: a
// 308 response says how much GCS has stored so far (its Range header), and the
// upload is only done once GCS answers 200 or 201. After a network error or a
// temporary server error, it waits, asks GCS how much it has stored, and goes
// on from there. Throws an Error saying why if the upload fails.
async function uploadToGCS (bucket, objName, filename) {
  const file = await fs.promises.open(filename, 'r')
  try {
    const { size: numBytesTotal } = await file.stat()
    if (numBytesTotal === 0) {
      throw new Error('the file is empty') // it was emptied after it was checked
    }
    const sessionURI = await startResumableUpload(bucket, objName, numBytesTotal)

    let offset = 0 // how many bytes GCS has stored
    let failures = 0 // tries in a row which made no progress
    let mustAskOffset = false // whether to ask GCS for the offset before sending
    for (;;) {
      let request, action
      if (mustAskOffset) {
        action = 'checking the upload\'s progress'
        request = {
          method: 'PUT',
          headers: { 'Content-Length': '0', 'Content-Range': `bytes */${numBytesTotal}` }
        }
      } else {
        const endIdx = Math.min(offset + UPLOAD_CHUNK_SIZE, numBytesTotal) - 1
        action = `uploading bytes ${offset}-${endIdx}`
        // a file that can't be read is not worth retrying, so this throws
        const chunk = await readChunk(file, offset, endIdx - offset + 1, numBytesTotal)
        request = {
          method: 'PUT',
          headers: {
            'Content-Length': String(chunk.length),
            'Content-Range': `bytes ${offset}-${endIdx}/${numBytesTotal}`
          },
          body: chunk
        }
      }

      let resp, respBody, problem
      try {
        resp = await fetch(sessionURI, request)
        respBody = await resp.text()
      } catch (e) {
        // even a 200 whose body was cut off is confirmed by asking GCS
        resp = undefined
        problem = `network error while ${action}: ${e.message}`
      }

      if (resp && (resp.status === 200 || resp.status === 201)) {
        return
      }
      if (resp?.status === 308) {
        const stored = parseStoredRange(resp.headers.get('Range'), numBytesTotal)
        if (stored === numBytesTotal) {
          throw new Error(`GCS stored all ${numBytesTotal} bytes but did not finish the upload (308)`)
        }
        const madeProgress = stored > offset
        const wasAsking = mustAskOffset
        problem = `GCS stored nothing more while ${action} (308, offset ${stored})`
        offset = stored
        mustAskOffset = false
        if (madeProgress) {
          failures = 0
          continue
        }
        if (wasAsking) {
          continue // the failure that made us ask was already counted
        }
        // GCS kept none of the chunk: resend it, after a wait
      } else if (resp && !isTemporaryStatus(resp.status)) {
        if (mustAskOffset && (resp.status === 404 || resp.status === 410)) {
          throw new Error(`the upload session expired (${resp.status}): ${respBody}`)
        }
        throw new Error(`GCS answered ${resp.status} while ${action}: ${respBody}`)
      } else {
        problem ??= `GCS answered ${resp.status} while ${action}: ${respBody}`
        mustAskOffset = true
      }

      failures++
      if (failures > UPLOAD_MAX_RETRIES) {
        throw new Error(`gave up after ${UPLOAD_MAX_RETRIES} retries; last error: ${problem}`)
      }
      await waitBeforeRetry(failures)
    }
  } finally {
    await file.close()
  }
}

// Starts a resumable upload and returns its session URI.
async function startResumableUpload (bucket, objName, numBytesTotal) {
  const url = `https://storage.googleapis.com/upload/storage/v1/b/${bucket}/o?uploadType=resumable&name=${objName}`
  for (let failures = 0; ; failures++) {
    let resp, respBody, problem
    try {
      resp = await fetch(url, {
        method: 'POST',
        headers: { 'X-Upload-Content-Length': String(numBytesTotal) }
      })
      respBody = await resp.text()
    } catch (e) {
      problem = `network error while initializing: ${e.message}`
    }
    if (resp?.ok) {
      const sessionURI = resp.headers.get('Location')
      if (!sessionURI) {
        throw new Error('GCS started the upload but gave no session URI')
      }
      return sessionURI
    }
    if (resp && !isTemporaryStatus(resp.status)) {
      throw new Error(`failed to initialize (${resp.status}): ${respBody}`)
    }
    problem ??= `GCS answered ${resp.status} while initializing: ${respBody}`
    if (failures >= UPLOAD_MAX_RETRIES) {
      throw new Error(`gave up after ${UPLOAD_MAX_RETRIES} retries; last error: ${problem}`)
    }
    await waitBeforeRetry(failures + 1)
  }
}

// GCS asks clients to retry these, with exponential backoff
function isTemporaryStatus (status) {
  return status >= 500 || status === 408 || status === 429
}

async function waitBeforeRetry (retryNumber) {
  const delayMs = Math.min(
    UPLOAD_FIRST_RETRY_DELAY_MS * 2 ** (retryNumber - 1), UPLOAD_MAX_RETRY_DELAY_MS)
  await new Promise(resolve => setTimeout(resolve, delayMs + Math.random() * 1000))
}

// Returns how many bytes a 308's Range header (e.g. "bytes=0-1048575") says
// GCS has stored; no Range header means none.
function parseStoredRange (range, numBytesTotal) {
  if (range === null) {
    return 0
  }
  const match = /^bytes=0-(\d+)$/.exec(range)
  const stored = match ? Number(match[1]) + 1 : NaN
  if (!(stored <= numBytesTotal)) {
    throw new Error(`GCS sent an unexpected Range header: ${range}`)
  }
  return stored
}

// Reads length bytes of the file, starting at position.
async function readChunk (file, position, length, numBytesTotal) {
  const chunk = Buffer.alloc(length)
  let chunkBytesRead = 0
  while (chunkBytesRead < length) {
    const { bytesRead } = await file.read(
      chunk, chunkBytesRead, length - chunkBytesRead, position + chunkBytesRead)
    if (bytesRead === 0) {
      throw new Error(`the file ended at byte ${position + chunkBytesRead}, before the ${numBytesTotal} bytes it had when the upload started`)
    }
    chunkBytesRead += bytesRead
  }
  return chunk
}