// Licensed under the Apache License, Version 2.0 (the 'License'); you may not
// use this file except in compliance with the License. You may obtain a copy of
// the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations under
// the License.

const { URL } = require('url')
const assert = require('assert')
const querystring = require('qs')
const axios = require('axios').default
const axiosCookieJarSupport = require('axios-cookiejar-support').default
const tough = require('tough-cookie')
axiosCookieJarSupport(axios)
const cookieJar = new tough.CookieJar()
const stream = require('stream')
const http = require('http')
const https = require('https')
const pkg = require('../package.json')
const AGENT_DEFAULTS = { keepAlive: true, maxSockets: 50, keepAliveMsecs: 30000 }
const SCRUBBED_STR = 'XXXXXX'
const defaultHttpAgent = new http.Agent(AGENT_DEFAULTS)
const defaultHttpsAgent = new https.Agent(AGENT_DEFAULTS)
const ChangesReader = require('./changesreader.js')
const MultiPartFactory = require('./multipart.js')

function isEmpty (val) {
  return val == null || !(Object.keys(val) || val).length
}

function getCallback (opts, callback) {
  if (typeof opts === 'function') {
    callback = opts
    opts = {}
  }
  opts = opts || {}
  return {
    opts,
    callback
  }
}

// feed this any number of arguements, it will return true if
// any of them are missing (falsey)
function missing (...params) {
  return params.some(param => !param)
}

// calls the supplied callback or if not supplied, returns a rejected promise
function callbackOrRejectError (callback, error = new Error('Invalid parameters')) {
  if (callback) {
    return callback(error, null)
  } else {
    return Promise.reject(error)
  }
}

module.exports = exports = function dbScope (cfg) {
  let serverScope = {}

  if (typeof cfg === 'string') {
    cfg = { url: cfg }
  }

  assert.strictEqual(typeof cfg, 'object',
    'You must specify the endpoint url when invoking this module')
  assert.ok(/^https?:/.test(cfg.url), 'url is not valid')

  cfg = Object.assign({}, cfg)

  serverScope.config = cfg
  cfg.requestDefaults = cfg.requestDefaults || {}

  const dummyLogger = () => {}
  const log = typeof cfg.log === 'function' ? cfg.log : dummyLogger
  const parseUrl = 'parseUrl' in cfg ? cfg.parseUrl : true

  function maybeExtractDatabaseComponent () {
    if (!parseUrl) {
      return
    }

    const path = new URL(cfg.url)
    const pathArray = path.pathname.split('/').filter(function (e) { return e })
    const db = pathArray.pop()
    const rootPath = path.pathname.replace(/\/?$/, '/..')

    if (db) {
      cfg.url = urlResolveFix(cfg.url, rootPath).replace(/\/?$/, '')
      return db
    }
  }

  function scrubURL (str) {
    if (str) {
      str = str.replace(/\/\/(.*)@/, `//${SCRUBBED_STR}:${SCRUBBED_STR}@`)
    }
    return str
  }

  function scrubRequest (req, cloned) {
    // scrub credentials
    req.url = scrubURL(req.url)
    if (req.headers.cookie) {
      req.headers.cookie = 'XXXXXXX'
    }
    if (req.auth) {
      if (!cloned) {
        req.auth = JSON.parse(JSON.stringify(req.auth)) // clone just auth if not already cloned
      }
      req.auth.username = SCRUBBED_STR
      req.auth.password = SCRUBBED_STR
    }
  }

  const responseHandler = function (response, req, opts, resolve, reject, callback) {
    const statusCode = response.status || (response.response && response.response.status) || 500
    if (response.isAxiosError && response.response) {
      response = response.response
    }
    let body = response.data
    response.statusCode = statusCode

    // let parsed
    const responseHeaders = Object.assign({
      uri: scrubURL(req.url),
      statusCode: statusCode
    }, response.headers)
    if (!response.status) {
      log({ err: 'socket', body: body, headers: responseHeaders })
      if (reject) {
        reject(new Error('error happened in your connection'))
      }
      if (callback) {
        const returnError = {
          message: 'error happened in your connection',
          scope: 'socket',
          errid: 'request'
        }
        callback(returnError)
      }
      return
    }

    delete responseHeaders.server
    delete responseHeaders['content-length']

    /* if (opts.dontParse) {
      parsed = body
    } else {
      try { parsed = JSON.parse(body) } catch (err) { parsed = body }
    } */

    if (statusCode >= 200 && statusCode < 400) {
      log({ err: null, body: body, headers: responseHeaders })
      if (resolve) {
        resolve(body)
      }
      if (callback) {
        callback(null, body, responseHeaders)
      }
      return
    }

    // cloudant stacktrace
    if (typeof body === 'string') {
      body = { message: body }
    }

    if (!body.message && (body.reason || body.error)) {
      body.message = (body.reason || body.error)
    }

    // fix cloudant issues where they give an erlang stacktrace as js
    delete body.stack

    // scrub credentials
    scrubRequest(req)

    log({ err: 'couch', body: body, headers: responseHeaders })

    const message = body.message || 'couch returned ' + statusCode
    const errors = new Error(message)
    errors.scope = 'couch'
    errors.statusCode = statusCode
    errors.request = req
    errors.headers = responseHeaders
    errors.errid = 'non_200'
    errors.name = 'Error'
    errors.description = message
    // add any attributes from the HTTP response into the
    // Error object (except message, which would overwrite
    // the text message of the Error)
    delete body.message
    Object.assign(errors, body)

    if (reject) {
      reject(errors)
    }
    if (callback) {
      callback(errors)
    }
  }

  const streamResponseHandler = function (response, req, stream) {
    const statusCode = response.status || (response.response && response.response.status) || 500
    if (response.isAxiosError && response.response) {
      response = response.response
    }
    const message = response.statusText

    scrubRequest(req)

    const responseHeaders = Object.assign({
      uri: req.url,
      statusCode: statusCode
    }, response.headers)

    const error = new Error(message)
    error.scope = 'couch'
    error.statusCode = statusCode
    error.request = req
    error.headers = responseHeaders
    error.errid = 'non_200'
    error.name = 'Error'
    error.description = message
    error.reason = message

    log({ err: 'couch', body: message, headers: responseHeaders })

    stream.emit('error', error)
  }

  function relax (opts, callback) {
    if (typeof opts === 'function') {
      callback = opts
      opts = { path: '' }
    }

    if (typeof opts === 'string') {
      opts = { path: opts }
    }

    if (!opts) {
      opts = { path: '' }
      callback = null
    }

    const qs = Object.assign({}, opts.qs)

    const headers = {
      'content-type': 'application/json',
      accept: 'application/json',
      'user-agent': `${pkg.name}/${pkg.version} (Node.js ${process.version})`,
      'Accept-Encoding': 'deflate, gzip'
    }

    const req = Object.assign({
      method: (opts.method || 'GET'),
      headers: headers,
      uri: cfg.url
    }, {
      ...cfg.requestDefaults,
      headers: Object.assign(headers, cfg.requestDefaults && cfg.requestDefaults.headers ? cfg.requestDefaults.headers : {})
    })

    // https://github.com/mikeal/request#requestjar
    const isJar = opts.jar || cfg.jar || (cfg.requestDefaults && cfg.requestDefaults.jar)

    if (isJar) {
      req.jar = cookieJar
      req.withCredentials = true
    }

    // http://wiki.apache.org/couchdb/HTTP_database_API#Naming_and_Addressing
    if (opts.db) {
      req.uri = urlResolveFix(req.uri, encodeURIComponent(opts.db))
    }

    if (opts.multipart) {
      // generate the multipart/related body, header and boundary to
      // upload multiple binary attachments in one request
      const mp = new MultiPartFactory(opts.multipart)
      opts.contentType = mp.header
      req.body = mp.data
    }

    req.headers = Object.assign(req.headers, opts.headers, cfg.defaultHeaders)

    if (opts.path) {
      req.uri += '/' + opts.path
    } else if (opts.doc) {
      if (!/^_design|_local/.test(opts.doc)) {
        // http://wiki.apache.org/couchdb/HTTP_Document_API#Naming.2FAddressing
        req.uri += '/' + encodeURIComponent(opts.doc)
      } else {
        // http://wiki.apache.org/couchdb/HTTP_Document_API#Document_IDs
        req.uri += '/' + opts.doc
      }

      // http://wiki.apache.org/couchdb/HTTP_Document_API#Attachments
      if (opts.att) {
        req.uri += '/' + opts.att
      }
    }

    // prevent bugs where people set encoding when piping
    if (opts.encoding !== undefined) {
      req.encoding = opts.encoding
      delete req.headers['content-type']
      delete req.headers.accept
    }

    if (opts.contentType) {
      req.headers['content-type'] = opts.contentType
      delete req.headers.accept
    }

    if (opts.accept) {
      req.headers.accept = opts.accept
    }

    // http://guide.couchdb.org/draft/security.html#cookies
    if (cfg.cookie) {
      req.headers['X-CouchDB-WWW-Authenticate'] = 'Cookie'
      req.headers.cookie = cfg.cookie
    }

    // http://wiki.apache.org/couchdb/HTTP_view_API#Querying_Options
    if (typeof opts.qs === 'object' && !isEmpty(opts.qs)) {
      ['startkey', 'endkey', 'key', 'keys', 'start_key', 'end_key'].forEach(function (key) {
        if (key in opts.qs) {
          qs[key] = JSON.stringify(opts.qs[key])
        }
      })
      req.qs = qs
    }

    if (opts.body) {
      if (Buffer.isBuffer(opts.body) || opts.dontStringify) {
        req.body = opts.body
      } else {
        req.body = JSON.stringify(opts.body, function (key, value) {
          // don't encode functions
          if (typeof (value) === 'function') {
            return value.toString()
          } else {
            return value
          }
        })
      }
    }

    if (opts.form) {
      req.headers['content-type'] =
        'application/x-www-form-urlencoded; charset=utf-8'
      req.body = querystring.stringify(opts.form).toString('utf8')
    }

    // ask request to render query string arrays as repeated values e.g.
    // ?drilldown=["author","Dickens"]&drilldown=["publisher","Penguin"]
    req.qsStringifyOptions = { arrayFormat: 'repeat' }

    cfg.cookies = cookieJar.getCookiesSync(cfg.url)

    // This where the HTTP request is made.
    // Nano used to use the now-deprecated "request" library but now we're going to
    // use axios, so let's modify the "req" object to suit axios
    req.url = req.uri
    delete req.uri
    req.method = req.method.toLowerCase()
    req.params = req.qs
    delete req.qs
    req.paramsSerializer = (params) => {
      return querystring.stringify(params, { arrayFormat: 'brackets' })
    }
    req.data = req.body
    delete req.body
    req.maxRedirects = 0
    if (opts.stream) {
      req.responseType = 'stream'
    } else if (opts.dontParse) {
      req.responseType = 'arraybuffer'
    }

    // scrub and log
    const scrubbedReq = {
      method: req.method,
      headers: JSON.parse(JSON.stringify(req.headers)),
      url: req.url
    }
    scrubRequest(scrubbedReq, true)
    log(scrubbedReq)

    // add http agents
    req.httpAgent = cfg.requestDefaults.agent || defaultHttpAgent
    req.httpsAgent = cfg.requestDefaults.agent || defaultHttpsAgent

    // actually do the HTTP request
    if (opts.stream) {
      // return the Request object for streaming
      const outStream = new stream.PassThrough()
      axios(req)
        .then((response) => {
          response.data.pipe(outStream)
        }).catch(e => {
          streamResponseHandler(e, req, outStream)
        })
      return outStream
    } else {
      if (typeof callback === 'function') {
        axios(req).then((response) => {
          responseHandler(response, req, opts, null, null, callback)
        }).catch((e) => {
          responseHandler(e, req, opts, null, null, callback)
        })
      } else {
        return new Promise((resolve, reject) => {
          axios(req).then((response) => {
            responseHandler(response, req, opts, resolve, reject)
          }).catch((e) => {
            responseHandler(e, req, opts, resolve, reject)
          })
        })
      }
    }
  }

  // http://docs.couchdb.org/en/latest/api/server/authn.html#cookie-authentication
  function auth (username, password, callback) {
    return relax({
      method: 'POST',
      db: '_session',
      form: {
        name: username,
        password: password
      }
    }, callback)
  }

  // http://docs.couchdb.org/en/latest/api/server/authn.html#post--_session
  function session (callback) {
    return relax({ db: '_session' }, callback)
  }

  // https://docs.couchdb.org/en/latest/api/server/common.html#api-server-root
  function info (callback) {
    return relax({ path: '' }, callback)
  }

  // http://docs.couchdb.org/en/latest/api/server/common.html#get--_db_updates
  function updates (qs0, callback0) {
    const { opts, callback } = getCallback(qs0, callback0)
    return relax({
      db: '_db_updates',
      qs: opts
    }, callback)
  }

  // http://docs.couchdb.org/en/latest/api/database/common.html#put--db
  function createDb (dbName, qs0, callback0) {
    const { opts, callback } = getCallback(qs0, callback0)
    if (missing(dbName)) {
      return callbackOrRejectError(callback)
    }
    return relax({ db: dbName, method: 'PUT', qs: opts }, callback)
  }

  // http://docs.couchdb.org/en/latest/api/database/common.html#delete--db
  function destroyDb (dbName, callback) {
    if (missing(dbName)) {
      return callbackOrRejectError(callback)
    }
    return relax({ db: dbName, method: 'DELETE' }, callback)
  }

  // http://docs.couchdb.org/en/latest/api/database/common.html#get--db
  function getDb (dbName, callback) {
    if (missing(dbName)) {
      return callbackOrRejectError(callback)
    }
    return relax({ db: dbName }, callback)
  }

  // http://docs.couchdb.org/en/latest/api/server/common.html#get--_all_dbs
  function listDbs (callback) {
    return relax({ db: '_all_dbs' }, callback)
  }

  // http://docs.couchdb.org/en/latest/api/server/common.html#get--_all_dbs
  function listDbsAsStream () {
    return relax({ db: '_all_dbs', stream: true })
  }

  // http://docs.couchdb.org/en/latest/api/database/compact.html#post--db-_compact
  function compactDb (dbName, ddoc, callback) {
    if (typeof ddoc === 'function') {
      callback = ddoc
      ddoc = null
    }
    if (missing(dbName)) {
      return callbackOrRejectError(callback)
    }
    return relax({
      db: dbName,
      doc: '_compact',
      att: ddoc,
      method: 'POST'
    }, callback)
  }

  // http://docs.couchdb.org/en/latest/api/database/changes.html#get--db-_changes
  function changesDb (dbName, qs0, callback0) {
    const { opts, callback } = getCallback(qs0, callback0)
    if (missing(dbName)) {
      return callbackOrRejectError(callback)
    }
    return relax({ db: dbName, path: '_changes', qs: opts }, callback)
  }

  function changesDbAsStream (dbName, opts) {
    return relax({ db: dbName, path: '_changes', stream: true, qs: opts })
  }

  function _serializeAsUrl (db) {
    if (typeof db === 'object' && db.config && db.config.url && db.config.db) {
      return urlResolveFix(db.config.url, encodeURIComponent(db.config.db))
    } else {
      try {
        // if it parses, return it
        const parsed = new URL(db)
        return parsed.toString()
      } catch (e) {
        // otherwise treat it as a database name
        return urlResolveFix(cfg.url, encodeURIComponent(db))
      }
    }
  }

  // http://docs.couchdb.org/en/latest/api/server/common.html#post--_replicate
  function replicateDb (source, target, opts0, callback0) {
    const { opts, callback } = getCallback(opts0, callback0)

    if (missing(source, target)) {
      return callbackOrRejectError(callback)
    }

    // _replicate
    opts.source = _serializeAsUrl(source)
    opts.target = _serializeAsUrl(target)

    return relax({ db: '_replicate', body: opts, method: 'POST' }, callback)
  }

  // http://docs.couchdb.org/en/latest/api/server/common.html#uuids
  function uuids (count, callback) {
    if (typeof count === 'function') {
      callback = count
      count = 1
    }
    count = count || 1
    return relax({ method: 'GET', path: '_uuids', qs: { count: count } }, callback)
  }

  // http://guide.couchdb.org/draft/replication.html
  function enableReplication (source, target, opts0, callback0) {
    const { opts, callback } = getCallback(opts0, callback0)

    if (missing(source, target)) {
      return callbackOrRejectError(callback)
    }

    // _replicator
    opts.source = _serializeAsUrl(source)
    opts.target = _serializeAsUrl(target)

    return relax({ db: '_replicator', body: opts, method: 'POST' }, callback)
  }

  // http://guide.couchdb.org/draft/replication.html
  function queryReplication (id, opts0, callback0) {
    const { opts, callback } = getCallback(opts0, callback0)
    if (missing(id)) {
      return callbackOrRejectError(callback)
    }
    return relax({ db: '_replicator', method: 'GET', path: id, qs: opts }, callback)
  }

  // http://guide.couchdb.org/draft/replication.html
  function disableReplication (id, rev, opts0, callback0) {
    const { opts, callback } = getCallback(opts0, callback0)
    if (missing(id, rev)) {
      return callbackOrRejectError(callback)
    }
    const req = {
      db: '_replicator',
      method: 'DELETE',
      path: id,
      qs: Object.assign(opts, { rev: rev })
    }
    return relax(req, callback)
  }

  function docModule (dbName) {
    let docScope = {}
    dbName = decodeURIComponent(dbName)

    // http://docs.couchdb.org/en/latest/api/document/common.html#put--db-docid
    // http://docs.couchdb.org/en/latest/api/database/common.html#post--db
    function insertDoc (doc, qs0, callback0) {
      const req = { db: dbName, body: doc, method: 'POST' }

      let { opts, callback } = getCallback(qs0, callback0)

      if (typeof opts === 'string') {
        opts = { docName: opts }
      }

      if (opts) {
        if (opts.docName) {
          req.doc = opts.docName
          req.method = 'PUT'
          delete opts.docName
        }
        req.qs = opts
      }

      return relax(req, callback)
    }

    // http://docs.couchdb.org/en/latest/api/document/common.html#delete--db-docid
    function destroyDoc (docName, rev, callback) {
      if (missing(docName)) {
        return callbackOrRejectError(callback)
      }

      return relax({
        db: dbName,
        doc: docName,
        method: 'DELETE',
        qs: { rev: rev }
      }, callback)
    }

    // http://docs.couchdb.org/en/latest/api/document/common.html#get--db-docid
    function getDoc (docName, qs0, callback0) {
      const { opts, callback } = getCallback(qs0, callback0)

      if (missing(docName)) {
        return callbackOrRejectError(callback)
      }

      return relax({ db: dbName, doc: docName, qs: opts }, callback)
    }

    // http://docs.couchdb.org/en/latest/api/document/common.html#head--db-docid
    function headDoc (docName, callback) {
      if (missing(docName)) {
        return callbackOrRejectError(callback)
      }
      if (callback) {
        relax({
          db: dbName,
          doc: docName,
          method: 'HEAD',
          qs: {}
        }, callback)
      } else {
        // this function doesn't pass on the Promise from relax because it needs
        // to return the headers when resolving the Promise
        return new Promise(function (resolve, reject) {
          relax({
            db: dbName,
            doc: docName,
            method: 'HEAD',
            qs: {}
          }, function (err, body, headers) {
            if (err) {
              reject(err)
            } else {
              resolve(headers)
            }
          })
        })
      }
    }

    // http://docs.couchdb.org/en/latest/api/database/bulk-api.html#get--db-_all_docs
    function listDoc (qs0, callback0) {
      const { opts, callback } = getCallback(qs0, callback0)
      return relax({ db: dbName, path: '_all_docs', qs: opts }, callback)
    }

    // http://docs.couchdb.org/en/latest/api/database/bulk-api.html#get--db-_all_docs
    function listDocAsStream (opts) {
      return relax({ db: dbName, path: '_all_docs', qs: opts, stream: true })
    }

    // http://docs.couchdb.org/en/latest/api/database/bulk-api.html#post--db-_all_docs
    function fetchDocs (docNames, qs0, callback0) {
      const { opts, callback } = getCallback(qs0, callback0)
      opts.include_docs = true

      if (missing(docNames) || typeof docNames !== 'object' ||
          !docNames.keys || !Array.isArray(docNames.keys) ||
          docNames.keys.length === 0) {
        return callbackOrRejectError(callback)
      }

      return relax({
        db: dbName,
        path: '_all_docs',
        method: 'POST',
        qs: opts,
        body: docNames
      }, callback)
    }

    function fetchRevs (docNames, qs0, callback0) {
      const { opts, callback } = getCallback(qs0, callback0)

      if (missing(docNames) || typeof docNames !== 'object' ||
          !docNames.keys || !Array.isArray(docNames.keys) ||
          docNames.keys.length === 0) {
        return callbackOrRejectError(callback)
      }

      return relax({
        db: dbName,
        path: '_all_docs',
        method: 'POST',
        qs: opts,
        body: docNames
      }, callback)
    }

    function view (ddoc, viewName, meta, qs0, callback0) {
      const { opts, callback } = getCallback(qs0, callback0)

      if (missing(ddoc, viewName) && !meta.viewPath) {
        return callbackOrRejectError(callback)
      }

      if (typeof meta.stream !== 'boolean') {
        meta.stream = false
      }

      // prevent mutation of the client qs object by using a clone
      const qs1 = Object.assign({}, opts)

      const viewPath = meta.viewPath || '_design/' + ddoc + '/_' + meta.type +
            '/' + viewName

      if (meta.type === 'search') {
        return relax({
          db: dbName,
          path: viewPath,
          method: 'POST',
          body: qs1,
          stream: meta.stream
        }, callback)
      } else if (qs1 && qs1.keys) {
        const body = { keys: qs1.keys }
        delete qs1.keys
        return relax({
          db: dbName,
          path: viewPath,
          method: 'POST',
          qs: qs1,
          body: body,
          stream: meta.stream
        }, callback)
      } else if (qs1 && qs1.queries) {
        const body = { queries: qs1.queries }
        delete qs1.queries
        return relax({
          db: dbName,
          path: viewPath,
          method: 'POST',
          qs: qs1,
          body: body
        }, callback)
      } else {
        const req = {
          db: dbName,
          method: meta.method || 'GET',
          path: viewPath,
          qs: qs1,
          stream: meta.stream
        }

        if (meta.body) {
          req.body = meta.body
        }

        return relax(req, callback)
      }
    }

    // http://docs.couchdb.org/en/latest/api/ddoc/views.html#post--db-_design-ddoc-_view-view
    function viewDocs (ddoc, viewName, qs, callback) {
      return view(ddoc, viewName, { type: 'view' }, qs, callback)
    }

    // http://docs.couchdb.org/en/latest/api/ddoc/views.html#post--db-_design-ddoc-_view-view
    function viewDocsAsStream (ddoc, viewName, qs) {
      return view(ddoc, viewName, { type: 'view', stream: true }, qs)
    }

    // cloudant
    function viewSearch (ddoc, viewName, qs, callback) {
      return view(ddoc, viewName, { type: 'search' }, qs, callback)
    }

    // cloudant
    function viewSearchAsStream (ddoc, viewName, qs) {
      return view(ddoc, viewName, { type: 'search', stream: true }, qs)
    }

    // http://docs.couchdb.org/en/latest/api/ddoc/render.html#get--db-_design-ddoc-_show-func
    function showDoc (ddoc, viewName, docName, qs, callback) {
      if (missing(ddoc, viewName, docName)) {
        return callbackOrRejectError(callback)
      }

      return view(ddoc, viewName + '/' + docName, { type: 'show' }, qs, callback)
    }

    // http://docs.couchdb.org/en/latest/api/ddoc/render.html#put--db-_design-ddoc-_update-func-docid
    function updateWithHandler (ddoc, viewName, docName, body, callback) {
      if (typeof body === 'function') {
        callback = body
        body = undefined
      }
      if (missing(ddoc, viewName, docName)) {
        return callbackOrRejectError(callback)
      }
      return view(ddoc, viewName + '/' + encodeURIComponent(docName), {
        type: 'update',
        method: 'PUT',
        body: body
      }, callback)
    }

    function viewWithList (ddoc, viewName, listName, qs, callback) {
      return view(ddoc, listName + '/' + viewName, {
        type: 'list'
      }, qs, callback)
    }

    function viewWithListAsStream (ddoc, viewName, listName, qs, callback) {
      return view(ddoc, listName + '/' + viewName, {
        type: 'list', stream: true
      }, qs, callback)
    }

    // http://docs.couchdb.org/en/latest/api/database/bulk-api.html#post--db-_bulksDoc
    function bulksDoc (docs, qs0, callback0) {
      const { opts, callback } = getCallback(qs0, callback0)
      return relax({
        db: dbName,
        path: '_bulk_docs',
        body: docs,
        method: 'POST',
        qs: opts
      }, callback)
    }

    // http://docs.couchdb.org/en/latest/api/document/common.html#creating-multiple-attachments
    function insertMultipart (doc, attachments, qs, callback) {
      if (typeof qs === 'string') {
        qs = { docName: qs }
      }
      qs = qs || {}

      const docName = qs.docName
      delete qs.docName

      if (missing(doc, attachments, docName)) {
        return callbackOrRejectError(callback)
      }

      doc = Object.assign({ _attachments: {} }, doc)

      const multipart = []

      attachments.forEach(function (att) {
        doc._attachments[att.name] = {
          follows: true,
          length: Buffer.isBuffer(att.data) ? att.data.length : Buffer.byteLength(att.data),
          /* jscs:disable requireCamelCaseOrUpperCaseIdentifiers */
          content_type: att.content_type
        }
        multipart.push(att)
      })

      multipart.unshift({
        content_type: 'application/json',
        data: JSON.stringify(doc),
        name: 'document'
      })

      return relax({
        db: dbName,
        method: 'PUT',
        contentType: 'multipart/related',
        doc: docName,
        qs: qs,
        multipart: multipart
      }, callback)
    }

    function getMultipart (docName, qs0, callback0) {
      const { opts, callback } = getCallback(qs0, callback0)
      opts.attachments = true

      if (missing(docName)) {
        return callbackOrRejectError(callback)
      }

      return relax({
        db: dbName,
        doc: docName,
        encoding: null,
        accept: 'multipart/related',
        qs: opts
      }, callback)
    }

    function insertAtt (docName, attName, att, contentType, qs0, callback0) {
      const { opts, callback } = getCallback(qs0, callback0)
      if (missing(docName, attName, att, contentType)) {
        return callbackOrRejectError(callback)
      }

      return relax({
        db: dbName,
        att: attName,
        method: 'PUT',
        contentType: contentType,
        doc: docName,
        qs: opts,
        body: att,
        dontStringify: true
      }, callback)
    }

    function getAtt (docName, attName, qs0, callback0) {
      const { opts, callback } = getCallback(qs0, callback0)

      if (missing(docName, attName)) {
        return callbackOrRejectError(callback)
      }

      return relax({
        db: dbName,
        att: attName,
        doc: docName,
        qs: opts,
        encoding: null,
        dontParse: true
      }, callback)
    }

    function getAttAsStream (docName, attName, opts) {
      return relax({
        db: dbName,
        att: attName,
        doc: docName,
        qs: opts,
        stream: true,
        encoding: null,
        dontParse: true
      })
    }

    function destroyAtt (docName, attName, qs, callback) {
      if (missing(docName, attName)) {
        return callbackOrRejectError(callback)
      }

      return relax({
        db: dbName,
        att: attName,
        method: 'DELETE',
        doc: docName,
        qs: qs
      }, callback)
    }

    function find (query, callback) {
      if (missing(query) || typeof query !== 'object') {
        return callbackOrRejectError(callback)
      }

      return relax({
        db: dbName,
        path: '_find',
        method: 'POST',
        body: query
      }, callback)
    }

    function findAsStream (query) {
      return relax({
        db: dbName,
        path: '_find',
        method: 'POST',
        body: query,
        stream: true
      })
    }

    function createIndex (indexDef, callback) {
      if (missing(indexDef) || typeof indexDef !== 'object') {
        return callbackOrRejectError(callback)
      }

      return relax({
        db: dbName,
        path: '_index',
        method: 'POST',
        body: indexDef
      }, callback)
    }

    function partitionInfo (partitionKey, callback) {
      if (missing(partitionKey)) {
        return callbackOrRejectError(callback)
      }

      return relax({
        db: dbName,
        path: '_partition/' + partitionKey
      }, callback)
    }

    function partitionedList (partitionKey, qs0, callback0) {
      const { opts, callback } = getCallback(qs0, callback0)
      if (missing(partitionKey)) {
        return callbackOrRejectError(callback)
      }
      return relax({
        db: dbName,
        path: '_partition/' + partitionKey + '/_all_docs',
        qs: opts
      }, callback)
    }

    function partitionedListAsStream (partitionKey, qs) {
      return relax({
        db: dbName,
        path: '_partition/' + partitionKey + '/_all_docs',
        qs: qs,
        stream: true
      })
    }

    function partitionedFind (partition, query, callback) {
      if (missing(partition, query) || typeof query !== 'object') {
        return callbackOrRejectError(callback)
      }

      return relax({
        db: dbName,
        path: '_partition/' + partition + '/_find',
        method: 'POST',
        body: query
      }, callback)
    }

    function partitionedFindAsStream (partition, query) {
      return relax({
        db: dbName,
        path: '_partition/' + partition + '/_find',
        method: 'POST',
        body: query,
        stream: true
      })
    }

    function partitionedSearch (partition, ddoc, searchName, opts, callback) {
      if (missing(partition, ddoc, searchName, opts) || typeof opts !== 'object') {
        return callbackOrRejectError(callback)
      }
      return relax({
        db: dbName,
        path: '_partition/' + partition + '/_design/' + ddoc + '/_search/' + searchName,
        qs: opts
      }, callback)
    }

    function partitionedSearchAsStream (partition, ddoc, searchName, opts) {
      return relax({
        db: dbName,
        path: '_partition/' + partition + '/_design/' + ddoc + '/_search/' + searchName,
        qs: opts,
        stream: true
      })
    }

    function partitionedView (partition, ddoc, viewName, opts, callback) {
      if (missing(partition, ddoc, viewName)) {
        return callbackOrRejectError(callback)
      }
      return relax({
        db: dbName,
        path: '_partition/' + partition + '/_design/' + ddoc + '/_view/' + viewName,
        qs: opts
      }, callback)
    }

    function partitionedViewAsStream (partition, ddoc, viewName, opts) {
      return relax({
        db: dbName,
        path: '_partition/' + partition + '/_design/' + ddoc + '/_view/' + viewName,
        qs: opts,
        stream: true
      })
    }

    // db level exports
    docScope = {
      info: function (cb) {
        return getDb(dbName, cb)
      },
      replicate: function (target, opts, cb) {
        return replicateDb(dbName, target, opts, cb)
      },
      compact: function (cb) {
        return compactDb(dbName, cb)
      },
      changes: function (qs, cb) {
        return changesDb(dbName, qs, cb)
      },
      changesAsStream: function (qs) {
        return changesDbAsStream(dbName, qs)
      },
      changesReader: new ChangesReader(dbName, relax),
      auth: auth,
      session: session,
      insert: insertDoc,
      get: getDoc,
      head: headDoc,
      destroy: destroyDoc,
      bulk: bulksDoc,
      list: listDoc,
      listAsStream: listDocAsStream,
      fetch: fetchDocs,
      fetchRevs: fetchRevs,
      config: { url: cfg.url, db: dbName },
      multipart: {
        insert: insertMultipart,
        get: getMultipart
      },
      attachment: {
        insert: insertAtt,
        get: getAtt,
        getAsStream: getAttAsStream,
        destroy: destroyAtt
      },
      show: showDoc,
      atomic: updateWithHandler,
      updateWithHandler: updateWithHandler,
      baseView: view,
      search: viewSearch,
      searchAsStream: viewSearchAsStream,
      view: viewDocs,
      viewAsStream: viewDocsAsStream,
      find: find,
      findAsStream: findAsStream,
      createIndex: createIndex,
      viewWithList: viewWithList,
      viewWithListAsStream: viewWithListAsStream,
      server: serverScope,
      replication: {
        enable: function (target, opts, cb) {
          return enableReplication(dbName, target, opts, cb)
        },
        disable: function (id, revision, opts, cb) {
          return disableReplication(id, revision, opts, cb)
        },
        query: function (id, opts, cb) {
          return queryReplication(id, opts, cb)
        }
      },
      partitionInfo: partitionInfo,
      partitionedList: partitionedList,
      partitionedListAsStream: partitionedListAsStream,
      partitionedFind: partitionedFind,
      partitionedFindAsStream: partitionedFindAsStream,
      partitionedSearch: partitionedSearch,
      partitionedSearchAsStream: partitionedSearchAsStream,
      partitionedView: partitionedView,
      partitionedViewAsStream: partitionedViewAsStream
    }

    docScope.view.compact = function (ddoc, cb) {
      return compactDb(dbName, ddoc, cb)
    }

    return docScope
  }

  // server level exports
  serverScope = Object.assign(serverScope, {
    db: {
      create: createDb,
      get: getDb,
      destroy: destroyDb,
      list: listDbs,
      listAsStream: listDbsAsStream,
      use: docModule,
      scope: docModule,
      compact: compactDb,
      replicate: replicateDb,
      replication: {
        enable: enableReplication,
        disable: disableReplication,
        query: queryReplication
      },
      changes: changesDb,
      updates: updates
    },
    use: docModule,
    scope: docModule,
    request: relax,
    relax: relax,
    dinosaur: relax,
    auth: auth,
    session: session,
    updates: updates,
    uuids: uuids,
    info: info
  })

  const db = maybeExtractDatabaseComponent()

  return db ? docModule(db) : serverScope
}

/*
 * and now an ascii dinosaur
 *              _
 *            / _) ROAR! i'm a vegan!
 *     .-^^^-/ /
 *  __/       /
 * /__.|_|-|_|
 *
 * thanks for visiting! come again!
 */

function urlResolveFix (couchUrl, dbName) {
  if (/[^/]$/.test(couchUrl)) {
    couchUrl += '/'
  }
  return new URL(dbName, couchUrl).toString()
}
