const _ = require('lodash')
const os = require('os')
const debug = require('debug')('cypress:server:api')
const request = require('@cypress/request-promise')
const RequestErrors = require('@cypress/request-promise/errors')
const Promise = require('bluebird')
const humanInterval = require('human-interval')
const { agent } = require('@packages/network')
const pkg = require('@packages/root')
const machineId = require('./util/machine_id')
const errors = require('./errors')
const { apiRoutes, onRoutes } = require('./util/routes')

const THIRTY_SECONDS = humanInterval('30 seconds')
const SIXTY_SECONDS = humanInterval('60 seconds')
const TWO_MINUTES = humanInterval('2 minutes')

let intervals

let DELAYS = [
  THIRTY_SECONDS,
  SIXTY_SECONDS,
  TWO_MINUTES,
]

const runnerCapabilities = {
  'dynamicSpecsInSerialMode': true,
  'skipSpecAction': true,
}

let responseCache = {}

intervals = process.env.API_RETRY_INTERVALS

if (intervals) {
  DELAYS = _
  .chain(intervals)
  .split(',')
  .map(_.toNumber)
  .value()
}

const rp = request.defaults((params = {}, callback) => {
  let resp

  if (params.cacheable && (resp = getCachedResponse(params))) {
    debug('resolving with cached response for ', params.url)

    return Promise.resolve(resp)
  }

  _.defaults(params, {
    agent,
    proxy: null,
    gzip: true,
    cacheable: false,
  })

  const headers = params.headers != null ? params.headers : (params.headers = {})

  _.defaults(headers, {
    'x-os-name': os.platform(),
    'x-cypress-version': pkg.version,
  })

  const method = params.method.toLowerCase()

  // use %j argument to ensure deep nested properties are serialized
  debug(
    'request to url: %s with params: %j and token: %s',
    `${params.method} ${params.url}`,
    _.pick(params, 'body', 'headers'),
    params.auth && params.auth.bearer,
  )

  return request[method](params, callback)
  .promise()
  .tap((resp) => {
    if (params.cacheable) {
      debug('caching response for ', params.url)
      cacheResponse(resp, params)
    }

    return debug('response %o', resp)
  })
})

const cacheResponse = (resp, params) => {
  return responseCache[params.url] = resp
}

const getCachedResponse = (params) => {
  return responseCache[params.url]
}

const retryWithBackoff = (fn) => {
  // for e2e testing purposes
  let attempt

  if (process.env.DISABLE_API_RETRIES) {
    debug('api retries disabled')

    return Promise.try(() => fn(0))
  }

  return (attempt = (retryIndex) => {
    return Promise
    .try(() => fn(retryIndex))
    .catch(isRetriableError, (err) => {
      if (retryIndex > DELAYS.length) {
        throw err
      }

      const delay = DELAYS[retryIndex]

      errors.warning(
        'DASHBOARD_API_RESPONSE_FAILED_RETRYING', {
          delay,
          tries: DELAYS.length - retryIndex,
          response: err,
        },
      )

      retryIndex++

      return Promise
      .delay(delay)
      .then(() => {
        debug(`retry #${retryIndex} after ${delay}ms`)

        return attempt(retryIndex)
      })
    })
  })(0)
}

const formatResponseBody = function (err) {
  // if the body is JSON object
  if (_.isObject(err.error)) {
    // transform the error message to include the
    // stringified body (represented as the 'error' property)
    const body = JSON.stringify(err.error, null, 2)

    err.message = [err.statusCode, body].join('\n\n')
  }

  throw err
}

const tagError = function (err) {
  err.isApiError = true
  throw err
}

// retry on timeouts, 5xx errors, or any error without a status code
const isRetriableError = (err) => {
  return (err instanceof Promise.TimeoutError) ||
    (500 <= err.statusCode && err.statusCode < 600) ||
    (err.statusCode == null)
}

module.exports = {
  rp,

  ping () {
    return rp.get(apiRoutes.ping())
    .catch(tagError)
  },

  getMe (authToken) {
    return rp.get({
      url: apiRoutes.me(),
      json: true,
      auth: {
        bearer: authToken,
      },
    })
  },

  getAuthUrls () {
    return rp.get({
      url: apiRoutes.auth(),
      json: true,
      cacheable: true,
      headers: {
        'x-route-version': '2',
      },
    })
    .catch(tagError)
  },

  getOrgs (authToken) {
    return rp.get({
      url: apiRoutes.orgs(),
      json: true,
      auth: {
        bearer: authToken,
      },
    })
    .catch(tagError)
  },

  getProjects (authToken) {
    return rp.get({
      url: apiRoutes.projects(),
      json: true,
      auth: {
        bearer: authToken,
      },
    })
    .catch(tagError)
  },

  getProject (projectId, authToken) {
    return rp.get({
      url: apiRoutes.project(projectId),
      json: true,
      auth: {
        bearer: authToken,
      },
      headers: {
        'x-route-version': '2',
      },
    })
    .catch(tagError)
  },

  getProjectRuns (projectId, authToken, options = {}) {
    if (options.page == null) {
      options.page = 1
    }

    return rp.get({
      url: apiRoutes.projectRuns(projectId),
      json: true,
      timeout: options.timeout != null ? options.timeout : 10000,
      auth: {
        bearer: authToken,
      },
      headers: {
        'x-route-version': '3',
      },
    })
    .catch(RequestErrors.StatusCodeError, formatResponseBody)
    .catch(tagError)
  },

  createRun (options = {}) {
    return retryWithBackoff((attemptIndex) => {
      const body = {
        ..._.pick(options, [
          'ci',
          'specs',
          'commit',
          'group',
          'platform',
          'parallel',
          'ciBuildId',
          'projectId',
          'recordKey',
          'specPattern',
          'tags',
          'testingType',
        ]),
        runnerCapabilities,
      }

      return rp.post({
        body,
        url: apiRoutes.runs(),
        json: true,
        timeout: options.timeout != null ? options.timeout : SIXTY_SECONDS,
        headers: {
          'x-route-version': '4',
          'x-cypress-request-attempt': attemptIndex,
        },
      })
      .catch(RequestErrors.StatusCodeError, formatResponseBody)
      .catch(tagError)
    })
  },

  createInstance (options = {}) {
    const { runId, timeout } = options

    const body = _.pick(options, [
      'spec',
      'groupId',
      'machineId',
      'platform',
    ])

    return retryWithBackoff((attemptIndex) => {
      return rp.post({
        body,
        url: apiRoutes.instances(runId),
        json: true,
        timeout: timeout != null ? timeout : SIXTY_SECONDS,
        headers: {
          'x-route-version': '5',
          'x-cypress-run-id': runId,
          'x-cypress-request-attempt': attemptIndex,
        },
      })
      .catch(RequestErrors.StatusCodeError, formatResponseBody)
      .catch(tagError)
    })
  },

  postInstanceTests (options = {}) {
    const { instanceId, runId, timeout, ...body } = options

    return retryWithBackoff((attemptIndex) => {
      return rp.post({
        url: apiRoutes.instanceTests(instanceId),
        json: true,
        timeout: timeout || SIXTY_SECONDS,
        headers: {
          'x-route-version': '1',
          'x-cypress-run-id': runId,
          'x-cypress-request-attempt': attemptIndex,
        },
        body,
      })
      .catch(RequestErrors.StatusCodeError, formatResponseBody)
      .catch(tagError)
    })
  },

  updateInstanceStdout (options = {}) {
    return retryWithBackoff((attemptIndex) => {
      return rp.put({
        url: apiRoutes.instanceStdout(options.instanceId),
        json: true,
        timeout: options.timeout != null ? options.timeout : SIXTY_SECONDS,
        body: {
          stdout: options.stdout,
        },
        headers: {
          'x-cypress-run-id': options.runId,
          'x-cypress-request-attempt': attemptIndex,

        },
      })
      .catch(RequestErrors.StatusCodeError, formatResponseBody)
      .catch(tagError)
    })
  },

  postInstanceResults (options = {}) {
    return retryWithBackoff((attemptIndex) => {
      return rp.post({
        url: apiRoutes.instanceResults(options.instanceId),
        json: true,
        timeout: options.timeout != null ? options.timeout : SIXTY_SECONDS,
        headers: {
          'x-route-version': '1',
          'x-cypress-run-id': options.runId,
          'x-cypress-request-attempt': attemptIndex,
        },
        body: _.pick(options, [
          'stats',
          'tests',
          'exception',
          'video',
          'screenshots',
          'reporterStats',
          'metadata',
        ]),
      })
      .catch(RequestErrors.StatusCodeError, formatResponseBody)
      .catch(tagError)
    })
  },

  createCrashReport (body, authToken, timeout = 3000) {
    return rp.post({
      url: apiRoutes.exceptions(),
      json: true,
      body,
      auth: {
        bearer: authToken,
      },
    })
    .timeout(timeout)
    .catch(tagError)
  },

  postLogout (authToken) {
    return Promise.join(
      this.getAuthUrls(),
      machineId.machineId(),
      (urls, machineId) => {
        return rp.post({
          url: urls.dashboardLogoutUrl,
          json: true,
          auth: {
            bearer: authToken,
          },
          headers: {
            'x-machine-id': machineId,
          },
        })
        .catch({ statusCode: 401 }, () => {}) // do nothing on 401
        .catch(tagError)
      },
    )
  },

  createProject (projectDetails, remoteOrigin, authToken) {
    debug('create project with args %o', {
      projectDetails,
      remoteOrigin,
      authToken,
    })

    return rp.post({
      url: apiRoutes.projects(),
      json: true,
      auth: {
        bearer: authToken,
      },
      headers: {
        'x-route-version': '2',
      },
      body: {
        name: projectDetails.projectName,
        orgId: projectDetails.orgId,
        public: projectDetails.public,
        remoteOrigin,
      },
    })
    .catch(RequestErrors.StatusCodeError, formatResponseBody)
    .catch(tagError)
  },

  getProjectRecordKeys (projectId, authToken) {
    return rp.get({
      url: apiRoutes.projectRecordKeys(projectId),
      json: true,
      auth: {
        bearer: authToken,
      },
    })
    .catch(tagError)
  },

  requestAccess (projectId, authToken) {
    return rp.post({
      url: apiRoutes.membershipRequests(projectId),
      json: true,
      auth: {
        bearer: authToken,
      },
    })
    .catch(RequestErrors.StatusCodeError, formatResponseBody)
    .catch(tagError)
  },

  getReleaseNotes (version) {
    return rp.get({
      url: onRoutes.releaseNotes(version),
      json: true,
    })
    .catch((err) => {
      // log and ignore by sending an empty response if there's an error
      debug('error getting release notes for version %s: %s', version, err.stack || err.message || err)

      return {}
    })
  },

  clearCache () {
    responseCache = {}
  },

  retryWithBackoff,
}
