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

import fetch from 'node-fetch'

const ENVIRONMENTS = {
  test: {
    apiServer: 'https://api-ko3kowqi6a-uc.a.run.app',
    firebaseConfig: {
      apiKey: 'AIzaSyCV1uh4fM7IFopuZOJ306oVWLV3cKLijFc',
      projectId: 'pbv-dev',
      appId: '1:542837591762:web:06f45c0d7a7e62f25aa70b'
    }
  },
  prod: {
    apiServer: 'https://api-2o2klzx4pa-uc.a.run.app',
    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.isDev = config === ENVIRONMENTS.test
  }

  /**
   * 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 } = {}) {
    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 })
    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 which this video should be added
   */

  /**
   * Upload a video for processing by the AI.
   *
   * For passthrough partners, the video is only uploaded if the paying user
   * has credit(s) available with which the video can be analyzed.
   *
   * @param {string} mp4Filename
   * @param {VideoMetadata} [metadata]
   * @returns {VideoUrlToDownloadResponse}
   */
  async uploadVideo (mp4Filename, { userEmails = [], name, desc, gameStartEpoch, facility, court, fid } = {}) {
    const pieces = mp4Filename.split('.')
    const ext = pieces[pieces.length - 1]
    const platform = { name: 'api', version: '0.1.12' }
    const makeVIDResp = await this.__callAPI('make_video_id', { platform, userEmails, name, desc, gameStartEpoch, facility, court, fid, fileExt: ext })
    const { hasCredits, vid } = JSON.parse(makeVIDResp)
    if (hasCredits === false) {
      return { hasCredits }
    }
    const bucket = `pbv-uploads${this.isDev ? '-dev' : ''}`
    const objName = `${this.uid}/${vid}.${ext}`
    await uploadToGCS(bucket, objName, mp4Filename)
    const ret = { vid }
    if (hasCredits !== undefined) {
      ret.hasCredits = hasCredits
    }
    return ret
  }
}

async function uploadToGCS (bucket, objName, filename) {
  // request to start a new upload
  const url = `https://storage.googleapis.com/upload/storage/v1/b/${bucket}/o?uploadType=resumable&name=${objName}`
  const numBytesTotal = fs.statSync(filename).size
  let headers = { 'X-Upload-Content-Length': numBytesTotal }
  let resp = await fetch(url, { method: 'POST', headers })
  if (!resp.ok) {
    throw new Error(`PB Vision Upload failed to initialize (${resp.status}): ${await resp.text()}`)
  }
  const sessionURI = resp.headers.get('Location')

  // determine how much data to read from the file at once; larger tends to
  // result in faster uploads but also has a bigger memory footprint
  const minChunkSz = 256 * 1024 // this is the *minimum* size
  const targetChunkSzMB = 8
  const chunkSize = Math.max(minChunkSz, targetChunkSzMB * 1024 * 1024)

  // upload one chunk at a time until it is done successfully
  let startIdx = 0
  while (startIdx < numBytesTotal) {
    let endIdx = startIdx + chunkSize - 1
    endIdx = Math.min(endIdx, numBytesTotal - 1)
    const thisChunkSize = endIdx - startIdx + 1

    // read just the chunk we need from the file
    const streamPromise = new Promise((resolve, reject) => {
      const chunk = Buffer.alloc(thisChunkSize)
      let chunkBytesRead = 0
      const stream = fs.createReadStream(
        filename, { start: startIdx, end: endIdx })
      stream.on('data', x => {
        x.copy(chunk, chunkBytesRead)
        chunkBytesRead += x.length
      })
      stream.on('end', () => resolve(chunk))
      stream.on('error', e => reject(e))
    })
    let chunk
    try {
      chunk = await streamPromise
    } catch (e) {
      throw new Error(`PB Vision Upload failed to read from file ${e.toString()}`)
    }

    headers = {
      'Content-Length': chunk.length,
      'Content-Range': `bytes ${startIdx}-${endIdx}/${numBytesTotal}`
    }
    assert(chunk.length <= numBytesTotal)
    assert(chunk.length === endIdx - startIdx + 1)
    resp = await fetch(sessionURI, { method: 'PUT', headers, body: chunk })
    if (!resp.status >= 400) {
      throw new Error(`PB Vision Upload failed to upload chunk ${startIdx} (${resp.status}): ${await resp.text()} ${JSON.stringify(resp.headers.raw())}`)
    }
    startIdx = endIdx + 1
  }
  return true
}