const _ = require('lodash')
const la = require('lazy-ass')
const chalk = require('chalk')
const check = require('check-more-types')
const debug = require('debug')('cypress:server:record')
const Promise = require('bluebird')
const isForkPr = require('is-fork-pr')
const commitInfo = require('@cypress/commit-info')
const api = require('../api')
const logger = require('../logger')
const errors = require('../errors')
const capture = require('../capture')
const upload = require('../upload')
const env = require('../util/env')
const keys = require('../util/keys')
const terminal = require('../util/terminal')
const humanTime = require('../util/human_time')
const ciProvider = require('../util/ci_provider')
const settings = require('../util/settings')

const onBeforeRetry = (details) => {
  return errors.warning(
    'DASHBOARD_API_RESPONSE_FAILED_RETRYING', {
      delay: humanTime.long(details.delay, false),
      tries: details.total - details.retryIndex,
      response: details.err,
    },
  )
}

const logException = (err) => {
  // give us up to 1 second to
  // create this exception report
  return logger.createException(err)
  .timeout(1000)
  .catch(() => {})
}

// dont yell about any errors either

const runningInternalTests = () => {
  return env.get('CYPRESS_INTERNAL_E2E_TESTS') === '1'
}

const warnIfCiFlag = (ci) => {
  // if we are using the ci flag that means
  // we have an old version of the CLI tools installed
  // and that we need to warn the user what to update
  if (ci) {
    const type = (() => {
      if (env.get('CYPRESS_CI_KEY')) {
        return 'CYPRESS_CI_DEPRECATED_ENV_VAR'
      }

      return 'CYPRESS_CI_DEPRECATED'
    })()

    return errors.warning(type)
  }
}

const haveProjectIdAndKeyButNoRecordOption = (projectId, options) => {
  // if we have a project id
  // and we have a key
  // and (record or ci) hasn't been set to true or false
  return (projectId && options.key) && (
    _.isUndefined(options.record) && _.isUndefined(options.ci)
  )
}

const warnIfProjectIdButNoRecordOption = (projectId, options) => {
  if (haveProjectIdAndKeyButNoRecordOption(projectId, options)) {
    // log a warning telling the user
    // that they either need to provide us
    // with a RECORD_KEY or turn off
    // record mode
    return errors.warning('PROJECT_ID_AND_KEY_BUT_MISSING_RECORD_OPTION', projectId)
  }
}

const throwIfIndeterminateCiBuildId = (ciBuildId, parallel, group) => {
  if ((!ciBuildId && !ciProvider.provider()) && (parallel || group)) {
    errors.throw(
      'INDETERMINATE_CI_BUILD_ID',
      {
        group,
        parallel,
      },
      ciProvider.detectableCiBuildIdProviders(),
    )
  }
}

const throwIfRecordParamsWithoutRecording = (record, ciBuildId, parallel, group, tag) => {
  if (!record && _.some([ciBuildId, parallel, group, tag])) {
    errors.throw('RECORD_PARAMS_WITHOUT_RECORDING', {
      ciBuildId,
      tag,
      group,
      parallel,
    })
  }
}

const throwIfIncorrectCiBuildIdUsage = (ciBuildId, parallel, group) => {
  // we've been given an explicit ciBuildId
  // but no parallel or group flag
  if (ciBuildId && (!parallel && !group)) {
    errors.throw('INCORRECT_CI_BUILD_ID_USAGE', { ciBuildId })
  }
}

const throwIfNoProjectId = (projectId, configFile) => {
  if (!projectId) {
    errors.throw('CANNOT_RECORD_NO_PROJECT_ID', configFile)
  }
}

const getSpecRelativePath = (spec) => {
  return _.get(spec, 'relative', null)
}

const uploadArtifacts = (options = {}) => {
  const { video, screenshots, videoUploadUrl, shouldUploadVideo, screenshotUploadUrls } = options

  const uploads = []
  let count = 0

  const nums = () => {
    count += 1

    return chalk.gray(`(${count}/${uploads.length})`)
  }

  const send = (pathToFile, url) => {
    const success = () => {
      // eslint-disable-next-line no-console
      return console.log(`  - Done Uploading ${nums()}`, chalk.blue(pathToFile))
    }

    const fail = (err) => {
      debug('failed to upload artifact %o', {
        file: pathToFile,
        stack: err.stack,
      })

      // eslint-disable-next-line no-console
      return console.log(`  - Failed Uploading ${nums()}`, chalk.red(pathToFile))
    }

    return uploads.push(
      upload.send(pathToFile, url)
      .then(success)
      .catch(fail),
    )
  }

  if (videoUploadUrl && shouldUploadVideo) {
    send(video, videoUploadUrl)
  }

  if (screenshotUploadUrls) {
    screenshotUploadUrls.forEach((obj) => {
      const screenshot = _.find(screenshots, { screenshotId: obj.screenshotId })

      return send(screenshot.path, obj.uploadUrl)
    })
  }

  if (!uploads.length) {
    // eslint-disable-next-line no-console
    console.log('  - Nothing to Upload')
  }

  return Promise
  .all(uploads)
  .catch((err) => {
    errors.warning('DASHBOARD_CANNOT_UPLOAD_RESULTS', err)

    return logException(err)
  })
}

const updateInstanceStdout = (options = {}) => {
  const { instanceId, captured } = options

  const stdout = captured.toString()

  const makeRequest = () => {
    return api.updateInstanceStdout({
      stdout,
      instanceId,
    })
  }

  return api.retryWithBackoff(makeRequest, { onBeforeRetry })
  .catch((err) => {
    debug('failed updating instance stdout %o', {
      stack: err.stack,
    })

    errors.warning('DASHBOARD_CANNOT_CREATE_RUN_OR_INSTANCE', err)

    // dont log exceptions if we have a 503 status code
    if (err.statusCode !== 503) {
      return logException(err)
    }
  }).finally(capture.restore)
}

const updateInstance = (options = {}) => {
  const { instanceId, results, group, parallel, ciBuildId } = options
  let { stats, tests, hooks, video, screenshots, reporterStats, error } = results

  video = Boolean(video)
  const cypressConfig = options.config

  // get rid of the path property
  screenshots = _.map(screenshots, (screenshot) => {
    return _.omit(screenshot, 'path')
  })

  const makeRequest = () => {
    return api.updateInstance({
      stats,
      tests,
      error,
      video,
      hooks,
      instanceId,
      screenshots,
      reporterStats,
      cypressConfig,
    })
  }

  return api.retryWithBackoff(makeRequest, { onBeforeRetry })
  .catch((err) => {
    debug('failed updating instance %o', {
      stack: err.stack,
    })

    if (parallel) {
      return errors.throw('DASHBOARD_CANNOT_PROCEED_IN_PARALLEL', {
        response: err,
        flags: {
          group,
          ciBuildId,
        },
      })
    }

    errors.warning('DASHBOARD_CANNOT_CREATE_RUN_OR_INSTANCE', err)

    // dont log exceptions if we have a 503 status code
    if (err.statusCode !== 503) {
      return logException(err)
      .return(null)
    }

    return null
  })
}

const getCommitFromGitOrCi = (git) => {
  la(check.object(git), 'expected git information object', git)

  return ciProvider.commitDefaults({
    sha: git.sha,
    branch: git.branch,
    authorName: git.author,
    authorEmail: git.email,
    message: git.message,
    remoteOrigin: git.remote,
    defaultBranch: null,
  })
}

const usedTestsMessage = (limit, phrase) => {
  if (_.isFinite(limit)) {
    return `The limit is ${chalk.blue(limit)} ${phrase} recordings.`
  }

  return ''
}

const billingLink = (orgId) => {
  if (orgId) {
    return `https://on.cypress.io/dashboard/organizations/${orgId}/billing`
  }

  return 'https://on.cypress.io/set-up-billing'
}

const gracePeriodMessage = (gracePeriodEnds) => {
  return gracePeriodEnds || 'the grace period ends'
}

const createRun = Promise.method((options = {}) => {
  _.defaults(options, {
    group: null,
    tags: null,
    parallel: null,
    ciBuildId: null,
  })

  let { projectId, recordKey, platform, git, specPattern, specs, parallel, ciBuildId, group, tags } = options

  if (recordKey == null) {
    recordKey = env.get('CYPRESS_RECORD_KEY') || env.get('CYPRESS_CI_KEY')
  }

  if (!recordKey) {
    // are we a forked pull request (forked PR) and are we NOT running our own internal
    // e2e tests? currently some e2e tests fail when a user submits
    // a PR because this logic triggers unintended here
    if (isForkPr.isForkPr() && !runningInternalTests()) {
      // bail with a warning
      return errors.warning('RECORDING_FROM_FORK_PR')
    }

    // else throw
    errors.throw('RECORD_KEY_MISSING')
  }

  // go back to being a string
  if (specPattern) {
    specPattern = specPattern.join(',')
  }

  if (ciBuildId) {
    // stringify
    ciBuildId = String(ciBuildId)
  }

  specs = _.map(specs, getSpecRelativePath)

  const commit = getCommitFromGitOrCi(git)

  debug('commit information from Git or from environment variables')
  debug(commit)

  const makeRequest = () => {
    return api.createRun({
      specs,
      group,
      tags,
      parallel,
      platform,
      ciBuildId,
      projectId,
      recordKey,
      specPattern,
      ci: {
        params: ciProvider.ciParams(),
        provider: ciProvider.provider(),
      },
      commit,
    })
  }

  return api.retryWithBackoff(makeRequest, { onBeforeRetry })
  .tap((response) => {
    if (!(response && response.warnings && response.warnings.length)) {
      return
    }

    return _.each(response.warnings, (warning) => {
      switch (warning.code) {
        case 'FREE_PLAN_IN_GRACE_PERIOD_EXCEEDS_MONTHLY_PRIVATE_TESTS':
          return errors.warning('FREE_PLAN_IN_GRACE_PERIOD_EXCEEDS_MONTHLY_PRIVATE_TESTS', {
            usedTestsMessage: usedTestsMessage(warning.limit, 'private test'),
            gracePeriodMessage: gracePeriodMessage(warning.gracePeriodEnds),
            link: billingLink(warning.orgId),
          })
        case 'FREE_PLAN_IN_GRACE_PERIOD_EXCEEDS_MONTHLY_TESTS':
          return errors.warning('FREE_PLAN_IN_GRACE_PERIOD_EXCEEDS_MONTHLY_TESTS', {
            usedTestsMessage: usedTestsMessage(warning.limit, 'test'),
            gracePeriodMessage: gracePeriodMessage(warning.gracePeriodEnds),
            link: billingLink(warning.orgId),
          })
        case 'FREE_PLAN_IN_GRACE_PERIOD_PARALLEL_FEATURE':
          return errors.warning('FREE_PLAN_IN_GRACE_PERIOD_PARALLEL_FEATURE', {
            gracePeriodMessage: gracePeriodMessage(warning.gracePeriodEnds),
            link: billingLink(warning.orgId),
          })
        case 'FREE_PLAN_EXCEEDS_MONTHLY_TESTS_V2':
          return errors.warning('PLAN_EXCEEDS_MONTHLY_TESTS', {
            planType: 'free',
            usedTestsMessage: usedTestsMessage(warning.limit, 'test'),
            link: billingLink(warning.orgId),
          })
        case 'PAID_PLAN_EXCEEDS_MONTHLY_PRIVATE_TESTS':
          return errors.warning('PLAN_EXCEEDS_MONTHLY_TESTS', {
            planType: 'current',
            usedTestsMessage: usedTestsMessage(warning.limit, 'private test'),
            link: billingLink(warning.orgId),
          })
        case 'PAID_PLAN_EXCEEDS_MONTHLY_TESTS':
          return errors.warning('PLAN_EXCEEDS_MONTHLY_TESTS', {
            planType: 'current',
            usedTestsMessage: usedTestsMessage(warning.limit, 'test'),
            link: billingLink(warning.orgId),
          })
        case 'PLAN_IN_GRACE_PERIOD_RUN_GROUPING_FEATURE_USED':
          return errors.warning('PLAN_IN_GRACE_PERIOD_RUN_GROUPING_FEATURE_USED', {
            gracePeriodMessage: gracePeriodMessage(warning.gracePeriodEnds),
            link: billingLink(warning.orgId),
          })
        default:
          return errors.warning('DASHBOARD_UNKNOWN_CREATE_RUN_WARNING', {
            message: warning.message,
            props: _.omit(warning, 'message'),
          })
      }
    })
  }).catch((err) => {
    debug('failed creating run %o', {
      stack: err.stack,
    })

    switch (err.statusCode) {
      case 401:
        recordKey = keys.hide(recordKey)

        return errors.throw('DASHBOARD_RECORD_KEY_NOT_VALID', recordKey, projectId)
      case 402: {
        const { code, payload } = err.error

        const limit = _.get(payload, 'limit')
        const orgId = _.get(payload, 'orgId')

        switch (code) {
          case 'FREE_PLAN_EXCEEDS_MONTHLY_PRIVATE_TESTS':
            return errors.throw('FREE_PLAN_EXCEEDS_MONTHLY_PRIVATE_TESTS', {
              usedTestsMessage: usedTestsMessage(limit, 'private test'),
              link: billingLink(orgId),
            })
          case 'FREE_PLAN_EXCEEDS_MONTHLY_TESTS':
            return errors.throw('FREE_PLAN_EXCEEDS_MONTHLY_TESTS', {
              usedTestsMessage: usedTestsMessage(limit, 'test'),
              link: billingLink(orgId),
            })
          case 'PARALLEL_FEATURE_NOT_AVAILABLE_IN_PLAN':
            return errors.throw('PARALLEL_FEATURE_NOT_AVAILABLE_IN_PLAN', {
              link: billingLink(orgId),
            })
          case 'RUN_GROUPING_FEATURE_NOT_AVAILABLE_IN_PLAN':
            return errors.throw('RUN_GROUPING_FEATURE_NOT_AVAILABLE_IN_PLAN', {
              link: billingLink(orgId),
            })
          default:
            return errors.throw('DASHBOARD_UNKNOWN_INVALID_REQUEST', {
              response: err,
              flags: {
                group,
                tags,
                parallel,
                ciBuildId,
              },
            })
        }
      }
      case 404:
        return errors.throw('DASHBOARD_PROJECT_NOT_FOUND', projectId, settings.configFile(options))
      case 412:
        return errors.throw('DASHBOARD_INVALID_RUN_REQUEST', err.error)
      case 422: {
        const { code, payload } = err.error

        const runUrl = _.get(payload, 'runUrl')

        switch (code) {
          case 'RUN_GROUP_NAME_NOT_UNIQUE':
            return errors.throw('DASHBOARD_RUN_GROUP_NAME_NOT_UNIQUE', {
              group,
              runUrl,
              ciBuildId,
            })
          case 'PARALLEL_GROUP_PARAMS_MISMATCH': {
            const { browserName, browserVersion, osName, osVersion } = platform

            return errors.throw('DASHBOARD_PARALLEL_GROUP_PARAMS_MISMATCH', {
              group,
              runUrl,
              ciBuildId,
              parameters: {
                osName,
                osVersion,
                browserName,
                browserVersion,
                specs,
              },
            })
          }
          case 'PARALLEL_DISALLOWED':
            return errors.throw('DASHBOARD_PARALLEL_DISALLOWED', {
              tags,
              group,
              runUrl,
              ciBuildId,
            })
          case 'PARALLEL_REQUIRED':
            return errors.throw('DASHBOARD_PARALLEL_REQUIRED', {
              tags,
              group,
              runUrl,
              ciBuildId,
            })
          case 'ALREADY_COMPLETE':
            return errors.throw('DASHBOARD_ALREADY_COMPLETE', {
              runUrl,
              tags,
              group,
              parallel,
              ciBuildId,
            })
          case 'STALE_RUN':
            return errors.throw('DASHBOARD_STALE_RUN', {
              runUrl,
              tags,
              group,
              parallel,
              ciBuildId,
            })
          default:
            return errors.throw('DASHBOARD_UNKNOWN_INVALID_REQUEST', {
              response: err,
              flags: {
                tags,
                group,
                parallel,
                ciBuildId,
              },
            })
        }
      }
      default:
        if (parallel) {
          return errors.throw('DASHBOARD_CANNOT_PROCEED_IN_PARALLEL', {
            response: err,
            flags: {
              group,
              ciBuildId,
            },
          })
        }

        // warn the user that assets will be not recorded
        errors.warning('DASHBOARD_CANNOT_CREATE_RUN_OR_INSTANCE', err)

        // report on this exception
        // and return null
        return logException(err)
        .return(null)
    }
  })
})

const createInstance = (options = {}) => {
  let { runId, group, groupId, parallel, machineId, ciBuildId, platform, spec } = options

  spec = getSpecRelativePath(spec)

  const makeRequest = () => {
    return api.createInstance({
      spec,
      runId,
      groupId,
      platform,
      machineId,
    })
  }

  return api.retryWithBackoff(makeRequest, { onBeforeRetry })
  .catch((err) => {
    debug('failed creating instance %o', {
      stack: err.stack,
    })

    if (parallel) {
      return errors.throw('DASHBOARD_CANNOT_PROCEED_IN_PARALLEL', {
        response: err,
        flags: {
          group,
          ciBuildId,
        },
      })
    }

    errors.warning('DASHBOARD_CANNOT_CREATE_RUN_OR_INSTANCE', err)

    // dont log exceptions if we have a 503 status code
    if (err.statusCode !== 503) {
      return logException(err)
      .return(null)
    }

    return null
  })
}

const createRunAndRecordSpecs = (options = {}) => {
  const { specPattern, specs, sys, browser, projectId, projectRoot, runAllSpecs, parallel, ciBuildId, group } = options
  const recordKey = options.key

  // we want to normalize this to an array to send to API
  const tags = _.split(options.tag, ',')

  return commitInfo.commitInfo(projectRoot)
  .then((git) => {
    debug('found the following git information')
    debug(git)

    const platform = {
      osCpus: sys.osCpus,
      osName: sys.osName,
      osMemory: sys.osMemory,
      osVersion: sys.osVersion,
      browserName: browser.displayName,
      browserVersion: browser.version,
    }

    return createRun({
      git,
      specs,
      group,
      tags,
      parallel,
      platform,
      recordKey,
      ciBuildId,
      projectId,
      specPattern,
    })
    .then((resp) => {
      if (!resp) {
        // if a forked run, can't record and can't be parallel
        // because the necessary env variables aren't present
        return runAllSpecs({
          parallel: false,
        })
      }

      const { runUrl, runId, machineId, groupId } = resp

      let captured = null
      let instanceId = null

      const beforeSpecRun = (spec) => {
        debug('before spec run %o', { spec })

        capture.restore()

        captured = capture.stdout()

        return createInstance({
          spec,
          runId,
          group,
          groupId,
          platform,
          parallel,
          ciBuildId,
          machineId,
        })
        .then((resp = {}) => {
          resp = resp || {}
          instanceId = resp.instanceId

          // pull off only what we need
          return _
          .chain(resp)
          .pick('spec', 'claimedInstances', 'totalInstances')
          .extend({
            estimated: resp.estimatedWallClockDuration,
          })
          .value()
        })
      }

      const afterSpecRun = (spec, results, config) => {
        // dont do anything if we failed to
        // create the instance
        if (!instanceId) {
          return
        }

        debug('after spec run %o', { spec })

        // eslint-disable-next-line no-console
        console.log('')

        terminal.header('Uploading Results', {
          color: ['blue'],
        })

        // eslint-disable-next-line no-console
        console.log('')

        return updateInstance({
          group,
          config,
          results,
          parallel,
          ciBuildId,
          instanceId,
        })
        .then((resp) => {
          if (!resp) {
            return
          }

          const { video, shouldUploadVideo, screenshots } = results
          const { videoUploadUrl, screenshotUploadUrls } = resp

          return uploadArtifacts({
            video,
            screenshots,
            videoUploadUrl,
            shouldUploadVideo,
            screenshotUploadUrls,
          })
          .finally(() => {
            // always attempt to upload stdout
            // even if uploading failed
            return updateInstanceStdout({
              captured,
              instanceId,
            })
          })
        })
      }

      return runAllSpecs({
        runUrl,
        parallel,
        beforeSpecRun,
        afterSpecRun,
      })
    })
  })
}

module.exports = {
  createRun,

  createInstance,

  updateInstance,

  updateInstanceStdout,

  uploadArtifacts,

  warnIfCiFlag,

  throwIfNoProjectId,

  throwIfIndeterminateCiBuildId,

  throwIfIncorrectCiBuildIdUsage,

  warnIfProjectIdButNoRecordOption,

  throwIfRecordParamsWithoutRecording,

  createRunAndRecordSpecs,

  getCommitFromGitOrCi,
}
