'use strict'

const stream = require('stream')
const util = require('util')
const zlib = require('zlib')
const debug = require('debug')('nock.playback_interceptor')
const common = require('./common')

function parseJSONRequestBody(req, requestBody) {
  if (!requestBody || !common.isJSONContent(req.headers)) {
    return requestBody
  }

  if (common.contentEncoding(req.headers, 'gzip')) {
    requestBody = String(zlib.gunzipSync(Buffer.from(requestBody, 'hex')))
  } else if (common.contentEncoding(req.headers, 'deflate')) {
    requestBody = String(zlib.inflateSync(Buffer.from(requestBody, 'hex')))
  }

  return JSON.parse(requestBody)
}

function parseFullReplyResult(response, fullReplyResult) {
  debug('full response from callback result: %j', fullReplyResult)

  if (!Array.isArray(fullReplyResult)) {
    throw Error('A single function provided to .reply MUST return an array')
  }

  if (fullReplyResult.length > 3) {
    throw Error(
      'The array returned from the .reply callback contains too many values'
    )
  }

  const [status, body = '', headers] = fullReplyResult

  if (!Number.isInteger(status)) {
    throw new Error(`Invalid ${typeof status} value for status code`)
  }

  response.statusCode = status
  response.rawHeaders.push(...common.headersInputToRawArray(headers))
  debug('response.rawHeaders after reply: %j', response.rawHeaders)

  return body
}

/**
 * Determine which of the default headers should be added to the response.
 *
 * Don't include any defaults whose case-insensitive keys are already on the response.
 */
function selectDefaultHeaders(existingHeaders, defaultHeaders) {
  if (!defaultHeaders.length) {
    return [] // return early if we don't need to bother
  }

  const definedHeaders = new Set()
  const result = []

  common.forEachHeader(existingHeaders, (_, fieldName) => {
    definedHeaders.add(fieldName.toLowerCase())
  })
  common.forEachHeader(defaultHeaders, (value, fieldName) => {
    if (!definedHeaders.has(fieldName.toLowerCase())) {
      result.push(fieldName, value)
    }
  })

  return result
}

// Presents a list of Buffers as a Readable
class ReadableBuffers extends stream.Readable {
  constructor(buffers, opts = {}) {
    super(opts)

    this.buffers = buffers
  }

  _read(size) {
    while (this.buffers.length) {
      if (!this.push(this.buffers.shift())) {
        return
      }
    }
    this.push(null)
  }
}

function convertBodyToStream(body) {
  if (common.isStream(body)) {
    return body
  }

  if (body === undefined) {
    return new ReadableBuffers([])
  }

  if (Buffer.isBuffer(body)) {
    return new ReadableBuffers([body])
  }

  if (typeof body !== 'string') {
    body = JSON.stringify(body)
  }

  return new ReadableBuffers([Buffer.from(body)])
}

/**
 * Play back an interceptor using the given request and mock response.
 */
function playbackInterceptor({
  req,
  socket,
  options,
  requestBodyString,
  requestBodyIsUtf8Representable,
  response,
  interceptor,
}) {
  const { logger } = interceptor.scope

  function start() {
    req.headers = req.getHeaders()

    interceptor.scope.emit('request', req, interceptor, requestBodyString)

    if (typeof interceptor.errorMessage !== 'undefined') {
      let error
      if (typeof interceptor.errorMessage === 'object') {
        error = interceptor.errorMessage
      } else {
        error = new Error(interceptor.errorMessage)
      }

      const delay = interceptor.delayBodyInMs + interceptor.delayConnectionInMs
      common.setTimeout(() => req.destroy(error), delay)
      return
    }

    // This will be null if we have a fullReplyFunction,
    // in that case status code will be set in `parseFullReplyResult`
    response.statusCode = interceptor.statusCode

    // Clone headers/rawHeaders to not override them when evaluating later
    response.rawHeaders = [...interceptor.rawHeaders]
    logger('response.rawHeaders:', response.rawHeaders)

    if (interceptor.replyFunction) {
      const parsedRequestBody = parseJSONRequestBody(req, requestBodyString)

      let fn = interceptor.replyFunction
      if (fn.length === 3) {
        // Handle the case of an async reply function, the third parameter being the callback.
        fn = util.promisify(fn)
      }

      // At this point `fn` is either a synchronous function or a promise-returning function;
      // wrapping in `Promise.resolve` makes it into a promise either way.
      Promise.resolve(fn.call(interceptor, options.path, parsedRequestBody))
        .then(continueWithResponseBody)
        .catch(err => req.destroy(err))
      return
    }

    if (interceptor.fullReplyFunction) {
      const parsedRequestBody = parseJSONRequestBody(req, requestBodyString)

      let fn = interceptor.fullReplyFunction
      if (fn.length === 3) {
        fn = util.promisify(fn)
      }

      Promise.resolve(fn.call(interceptor, options.path, parsedRequestBody))
        .then(continueWithFullResponse)
        .catch(err => req.destroy(err))
      return
    }

    if (
      common.isContentEncoded(interceptor.headers) &&
      !common.isStream(interceptor.body)
    ) {
      //  If the content is encoded we know that the response body *must* be an array
      //  of response buffers which should be mocked one by one.
      //  (otherwise decompressions after the first one fails as unzip expects to receive
      //  buffer by buffer and not one single merged buffer)
      const bufferData = Array.isArray(interceptor.body)
        ? interceptor.body
        : [interceptor.body]
      const responseBuffers = bufferData.map(data => Buffer.from(data, 'hex'))
      const responseBody = new ReadableBuffers(responseBuffers)
      continueWithResponseBody(responseBody)
      return
    }

    // If we get to this point, the body is either a string or an object that
    // will eventually be JSON stringified.
    let responseBody = interceptor.body

    // If the request was not UTF8-representable then we assume that the
    // response won't be either. In that case we send the response as a Buffer
    // object as that's what the client will expect.
    if (!requestBodyIsUtf8Representable && typeof responseBody === 'string') {
      // Try to create the buffer from the interceptor's body response as hex.
      responseBody = Buffer.from(responseBody, 'hex')

      // Creating buffers does not necessarily throw errors; check for difference in size.
      if (
        !responseBody ||
        (interceptor.body.length > 0 && responseBody.length === 0)
      ) {
        // We fallback on constructing buffer from utf8 representation of the body.
        responseBody = Buffer.from(interceptor.body, 'utf8')
      }
    }

    return continueWithResponseBody(responseBody)
  }

  function continueWithFullResponse(fullReplyResult) {
    let responseBody
    try {
      responseBody = parseFullReplyResult(response, fullReplyResult)
    } catch (err) {
      req.destroy(err)
      return
    }

    continueWithResponseBody(responseBody)
  }

  function prepareResponseHeaders(body) {
    const defaultHeaders = [...interceptor.scope._defaultReplyHeaders]

    // Include a JSON content type when JSON.stringify is called on the body.
    // This is a convenience added by Nock that has no analog in Node. It's added to the
    // defaults, so it will be ignored if the caller explicitly provided the header already.
    const isJSON =
      body !== undefined &&
      typeof body !== 'string' &&
      !Buffer.isBuffer(body) &&
      !common.isStream(body)

    if (isJSON) {
      defaultHeaders.push('Content-Type', 'application/json')
    }

    response.rawHeaders.push(
      ...selectDefaultHeaders(response.rawHeaders, defaultHeaders)
    )

    // Evaluate functional headers.
    common.forEachHeader(response.rawHeaders, (value, fieldName, i) => {
      if (typeof value === 'function') {
        response.rawHeaders[i + 1] = value(req, response, body)
      }
    })

    response.headers = common.headersArrayToObject(response.rawHeaders)
  }

  function continueWithResponseBody(rawBody) {
    prepareResponseHeaders(rawBody)
    const bodyAsStream = convertBodyToStream(rawBody)
    bodyAsStream.pause()

    // IncomingMessage extends Readable so we can't simply pipe.
    bodyAsStream.on('data', function (chunk) {
      response.push(chunk)
    })
    bodyAsStream.on('end', function () {
      // https://nodejs.org/dist/latest-v10.x/docs/api/http.html#http_message_complete
      response.complete = true
      response.push(null)

      interceptor.scope.emit('replied', req, interceptor)
    })
    bodyAsStream.on('error', function (err) {
      response.emit('error', err)
    })

    const { delayBodyInMs, delayConnectionInMs } = interceptor

    function respond() {
      if (req.aborted) {
        return
      }

      // Even though we've had the response object for awhile at this point,
      // we only attach it to the request immediately before the `response`
      // event because, as in Node, it alters the error handling around aborts.
      req.res = response
      response.req = req

      logger('emitting response')
      req.emit('response', response)

      common.setTimeout(() => bodyAsStream.resume(), delayBodyInMs)
    }

    socket.applyDelay(delayConnectionInMs)
    common.setTimeout(respond, delayConnectionInMs)
  }

  // Calling `start` immediately could take the request all the way to the connection delay
  // during a single microtask execution. This setImmediate stalls the playback to ensure the
  // correct events are emitted first ('socket', 'finish') and any aborts in the in the queue or
  // called during a 'finish' listener can be called.
  common.setImmediate(() => {
    if (!req.aborted) {
      start()
    }
  })
}

module.exports = { playbackInterceptor }
