// TODO: set the scope config from package.json or explicit cli config
const { walkUp } = require('walk-up-path')
const ini = require('ini')
const nopt = require('nopt')
const { log, time } = require('proc-log')

const { resolve, dirname, join } = require('node:path')
const { homedir } = require('node:os')
const {
  readFile,
  writeFile,
  chmod,
  unlink,
  stat,
  mkdir,
} = require('node:fs/promises')

// TODO these need to be either be ignored when parsing env, formalized as config, or not exported to the env in the first place. For now this list is just to suppress warnings till we can pay off this tech debt.
const internalEnv = [
  'global-prefix',
  'local-prefix',
  'npm-version',
  'node-gyp',
]

const fileExists = (...p) => stat(resolve(...p))
  .then((st) => st.isFile())
  .catch(() => false)

const dirExists = (...p) => stat(resolve(...p))
  .then((st) => st.isDirectory())
  .catch(() => false)

const hasOwnProperty = (obj, key) =>
  Object.prototype.hasOwnProperty.call(obj, key)

const typeDefs = require('./type-defs.js')
const nerfDart = require('./nerf-dart.js')
const envReplace = require('./env-replace.js')
const parseField = require('./parse-field.js')
const setEnvs = require('./set-envs.js')

// types that can be saved back to
const confFileTypes = new Set([
  'global',
  'user',
  'project',
])

const confTypes = new Set([
  'default',
  'builtin',
  ...confFileTypes,
  'env',
  'cli',
])

class Config {
  #loaded = false
  #flatten
  // populated the first time we flatten the object
  #flatOptions = null

  static get typeDefs () {
    return typeDefs
  }

  constructor ({
    definitions,
    shorthands,
    flatten,
    nerfDarts = [],
    npmPath,

    // options just to override in tests, mostly
    env = process.env,
    argv = process.argv,
    platform = process.platform,
    execPath = process.execPath,
    cwd = process.cwd(),
    excludeNpmCwd = false,
  }) {
    this.nerfDarts = nerfDarts
    this.definitions = definitions
    // turn the definitions into nopt's weirdo syntax
    const types = {}
    const defaults = {}
    this.deprecated = {}
    for (const [key, def] of Object.entries(definitions)) {
      defaults[key] = def.default
      types[key] = def.type
      if (def.deprecated) {
        this.deprecated[key] = def.deprecated.trim().replace(/\n +/, '\n')
      }
    }

    this.#flatten = flatten
    this.types = types
    this.shorthands = shorthands
    this.defaults = defaults

    this.npmPath = npmPath
    this.npmBin = join(this.npmPath, 'bin/npm-cli.js')
    this.argv = argv
    this.env = env
    this.execPath = execPath
    this.platform = platform
    this.cwd = cwd
    this.excludeNpmCwd = excludeNpmCwd

    // set when we load configs
    this.globalPrefix = null
    this.localPrefix = null
    this.localPackage = null

    // defaults to env.HOME, but will always be *something*
    this.home = null

    // set up the prototype chain of config objects
    const wheres = [...confTypes]
    this.data = new Map()
    let parent = null
    for (const where of wheres) {
      this.data.set(where, parent = new ConfigData(parent))
    }

    this.data.set = () => {
      throw new Error('cannot change internal config data structure')
    }
    this.data.delete = () => {
      throw new Error('cannot change internal config data structure')
    }

    this.sources = new Map([])

    this.list = []
    for (const { data } of this.data.values()) {
      this.list.unshift(data)
    }
    Object.freeze(this.list)

    this.#loaded = false
  }

  get loaded () {
    return this.#loaded
  }

  get prefix () {
    return this.#get('global') ? this.globalPrefix : this.localPrefix
  }

  // return the location where key is found.
  find (key) {
    if (!this.loaded) {
      throw new Error('call config.load() before reading values')
    }

    // have to look in reverse order
    const entries = [...this.data.entries()]
    for (let i = entries.length - 1; i > -1; i--) {
      const [where, { data }] = entries[i]
      if (hasOwnProperty(data, key)) {
        return where
      }
    }
    return null
  }

  get (key, where) {
    if (!this.loaded) {
      throw new Error('call config.load() before reading values')
    }
    return this.#get(key, where)
  }

  // we need to get values sometimes, so use this internal one to do so
  // while in the process of loading.
  #get (key, where = null) {
    if (where !== null && !confTypes.has(where)) {
      throw new Error('invalid config location param: ' + where)
    }
    const { data } = this.data.get(where || 'cli')
    return where === null || hasOwnProperty(data, key) ? data[key] : undefined
  }

  set (key, val, where = 'cli') {
    if (!this.loaded) {
      throw new Error('call config.load() before setting values')
    }
    if (!confTypes.has(where)) {
      throw new Error('invalid config location param: ' + where)
    }
    this.#checkDeprecated(key)
    const { data, raw } = this.data.get(where)
    data[key] = val
    if (['global', 'user', 'project'].includes(where)) {
      raw[key] = val
    }

    // this is now dirty, the next call to this.valid will have to check it
    this.data.get(where)[_valid] = null

    // the flat options are invalidated, regenerate next time they're needed
    this.#flatOptions = null
  }

  get flat () {
    if (this.#flatOptions) {
      return this.#flatOptions
    }

    // create the object for flat options passed to deps
    const timeEnd = time.start('config:load:flatten')
    this.#flatOptions = {}
    // walk from least priority to highest
    for (const { data } of this.data.values()) {
      this.#flatten(data, this.#flatOptions)
    }
    this.#flatOptions.nodeBin = this.execPath
    this.#flatOptions.npmBin = this.npmBin
    timeEnd()

    return this.#flatOptions
  }

  delete (key, where = 'cli') {
    if (!this.loaded) {
      throw new Error('call config.load() before deleting values')
    }
    if (!confTypes.has(where)) {
      throw new Error('invalid config location param: ' + where)
    }
    const { data, raw } = this.data.get(where)
    delete data[key]
    if (['global', 'user', 'project'].includes(where)) {
      delete raw[key]
    }
  }

  async load () {
    if (this.loaded) {
      throw new Error('attempting to load npm config multiple times')
    }

    // first load the defaults, which sets the global prefix
    this.loadDefaults()

    // next load the builtin config, as this sets new effective defaults
    await this.loadBuiltinConfig()

    // cli and env are not async, and can set the prefix, relevant to project
    this.loadCLI()
    this.loadEnv()

    // next project config, which can affect userconfig location
    await this.loadProjectConfig()

    // then user config, which can affect globalconfig location
    await this.loadUserConfig()

    // last but not least, global config file
    await this.loadGlobalConfig()

    // set this before calling setEnvs, so that we don't have to share
    // private attributes, as that module also does a bunch of get operations
    this.#loaded = true

    // set proper globalPrefix now that everything is loaded
    this.globalPrefix = this.get('prefix')

    this.setEnvs()
  }

  loadDefaults () {
    this.loadGlobalPrefix()
    this.loadHome()

    const defaultsObject = {
      ...this.defaults,
      prefix: this.globalPrefix,
    }

    try {
      // This does not have an actual definition
      defaultsObject['npm-version'] = require(join(this.npmPath, 'package.json')).version
    } catch {
      // in some weird state where the passed in npmPath does not have a package.json
      // this will never happen in npm, but is guarded here in case this is consumed
      // in other ways + tests
    }

    this.#loadObject(defaultsObject, 'default', 'default values')

    const { data } = this.data.get('default')

    // if the prefix is set on cli, env, or userconfig, then we need to
    // default the globalconfig file to that location, instead of the default
    // global prefix.  It's weird that `npm get globalconfig --prefix=/foo`
    // returns `/foo/etc/npmrc`, but better to not change it at this point.
    // define a custom getter, but turn into a normal prop
    // if we set it.  otherwise it can't be set on child objects
    Object.defineProperty(data, 'globalconfig', {
      get: () => resolve(this.#get('prefix'), 'etc/npmrc'),
      set (value) {
        Object.defineProperty(data, 'globalconfig', {
          value,
          configurable: true,
          writable: true,
          enumerable: true,
        })
      },
      configurable: true,
      enumerable: true,
    })
  }

  loadHome () {
    this.home = this.env.HOME || homedir()
  }

  loadGlobalPrefix () {
    if (this.globalPrefix) {
      throw new Error('cannot load default global prefix more than once')
    }

    if (this.env.PREFIX) {
      this.globalPrefix = this.env.PREFIX
    } else if (this.platform === 'win32') {
      // c:\node\node.exe --> prefix=c:\node\
      this.globalPrefix = dirname(this.execPath)
    } else {
      // /usr/local/bin/node --> prefix=/usr/local
      this.globalPrefix = dirname(dirname(this.execPath))

      // destdir only is respected on Unix
      if (this.env.DESTDIR) {
        this.globalPrefix = join(this.env.DESTDIR, this.globalPrefix)
      }
    }
  }

  loadEnv () {
    const conf = Object.create(null)
    for (const [envKey, envVal] of Object.entries(this.env)) {
      if (!/^npm_config_/i.test(envKey) || envVal === '') {
        continue
      }
      let key = envKey.slice('npm_config_'.length)
      if (!key.startsWith('//')) { // don't normalize nerf-darted keys
        key = key.replace(/(?!^)_/g, '-') // don't replace _ at the start of the key
          .toLowerCase()
      }
      conf[key] = envVal
    }
    this.#loadObject(conf, 'env', 'environment')
  }

  loadCLI () {
    for (const s of Object.keys(this.shorthands)) {
      if (s.length > 1 && this.argv.includes(`-${s}`)) {
        log.warn(`-${s} is not a valid single-hyphen cli flag and will be removed in the future`)
      }
    }
    nopt.invalidHandler = (k, val, type) =>
      this.invalidHandler(k, val, type, 'command line options', 'cli')
    const conf = nopt(this.types, this.shorthands, this.argv)
    nopt.invalidHandler = null
    this.parsedArgv = conf.argv
    delete conf.argv
    this.#loadObject(conf, 'cli', 'command line options')
  }

  get valid () {
    for (const [where, { valid }] of this.data.entries()) {
      if (valid === false || valid === null && !this.validate(where)) {
        return false
      }
    }
    return true
  }

  validate (where) {
    if (!where) {
      let valid = true
      const authProblems = []

      for (const entryWhere of this.data.keys()) {
        // no need to validate our defaults, we know they're fine
        // cli was already validated when parsed the first time
        if (entryWhere === 'default' || entryWhere === 'builtin' || entryWhere === 'cli') {
          continue
        }
        const ret = this.validate(entryWhere)
        valid = valid && ret

        if (['global', 'user', 'project'].includes(entryWhere)) {
          // after validating everything else, we look for old auth configs we no longer support
          // if these keys are found, we build up a list of them and the appropriate action and
          // attach it as context on the thrown error

          // first, keys that should be removed
          for (const key of ['_authtoken', '-authtoken']) {
            if (this.get(key, entryWhere)) {
              authProblems.push({ action: 'delete', key, where: entryWhere })
            }
          }

          // NOTE we pull registry without restricting to the current 'where' because we want to
          // suggest scoping things to the registry they would be applied to, which is the default
          // regardless of where it was defined
          const nerfedReg = nerfDart(this.get('registry'))
          // keys that should be nerfed but currently are not
          for (const key of ['_auth', '_authToken', 'username', '_password']) {
            if (this.get(key, entryWhere)) {
              // username and _password must both exist in the same file to be recognized correctly
              if (key === 'username' && !this.get('_password', entryWhere)) {
                authProblems.push({ action: 'delete', key, where: entryWhere })
              } else if (key === '_password' && !this.get('username', entryWhere)) {
                authProblems.push({ action: 'delete', key, where: entryWhere })
              } else {
                authProblems.push({
                  action: 'rename',
                  from: key,
                  to: `${nerfedReg}:${key}`,
                  where: entryWhere,
                })
              }
            }
          }
        }
      }

      if (authProblems.length) {
        const { ErrInvalidAuth } = require('./errors.js')
        throw new ErrInvalidAuth(authProblems)
      }

      return valid
    } else {
      const obj = this.data.get(where)
      obj[_valid] = true

      nopt.invalidHandler = (k, val, type) =>
        this.invalidHandler(k, val, type, obj.source, where)

      nopt.clean(obj.data, this.types, typeDefs)

      nopt.invalidHandler = null
      return obj[_valid]
    }
  }

  // fixes problems identified by validate(), accepts the 'problems' property from a thrown
  // ErrInvalidAuth to avoid having to check everything again
  repair (problems) {
    if (!problems) {
      try {
        this.validate()
      } catch (err) {
        // coverage skipped here because we don't need to test re-throwing an error
        // istanbul ignore next
        if (err.code !== 'ERR_INVALID_AUTH') {
          throw err
        }

        problems = err.problems
      } finally {
        if (!problems) {
          problems = []
        }
      }
    }

    for (const problem of problems) {
      // coverage disabled for else branch because it doesn't do anything and shouldn't
      // istanbul ignore else
      if (problem.action === 'delete') {
        this.delete(problem.key, problem.where)
      } else if (problem.action === 'rename') {
        const raw = this.data.get(problem.where).raw?.[problem.from]
        const calculated = this.get(problem.from, problem.where)
        this.set(problem.to, raw || calculated, problem.where)
        this.delete(problem.from, problem.where)
      }
    }
  }

  // Returns true if the value is coming directly from the source defined
  // in default definitions, if the current value for the key config is
  // coming from any other different source, returns false
  isDefault (key) {
    const [defaultType, ...types] = [...confTypes]
    const defaultData = this.data.get(defaultType).data

    return hasOwnProperty(defaultData, key)
      && types.every(type => {
        const typeData = this.data.get(type).data
        return !hasOwnProperty(typeData, key)
      })
  }

  invalidHandler (k, val, type, source, where) {
    const typeDescription = require('./type-description.js')
    log.warn(
      'invalid config',
      k + '=' + JSON.stringify(val),
      `set in ${source}`
    )
    this.data.get(where)[_valid] = false

    if (Array.isArray(type)) {
      if (type.includes(typeDefs.url.type)) {
        type = typeDefs.url.type
      } else {
        /* istanbul ignore if - no actual configs matching this, but
         * path types SHOULD be handled this way, like URLs, for the
         * same reason */
        if (type.includes(typeDefs.path.type)) {
          type = typeDefs.path.type
        }
      }
    }

    const typeDesc = typeDescription(type)
    const mustBe = typeDesc
      .filter(m => m !== undefined && m !== Array)
    const msg = 'Must be' + this.#getOneOfKeywords(mustBe, typeDesc)
    const desc = mustBe.length === 1 ? mustBe[0]
      : [...new Set(mustBe.map(n => typeof n === 'string' ? n : JSON.stringify(n)))].join(', ')
    log.warn('invalid config', msg, desc)
  }

  #getOneOfKeywords (mustBe, typeDesc) {
    let keyword
    if (mustBe.length === 1 && typeDesc.includes(Array)) {
      keyword = ' one or more'
    } else if (mustBe.length > 1 && typeDesc.includes(Array)) {
      keyword = ' one or more of:'
    } else if (mustBe.length > 1) {
      keyword = ' one of:'
    } else {
      keyword = ''
    }
    return keyword
  }

  #loadObject (obj, where, source, er = null) {
    // obj is the raw data read from the file
    const conf = this.data.get(where)
    if (conf.source) {
      const m = `double-loading "${where}" configs from ${source}, ` +
        `previously loaded from ${conf.source}`
      throw new Error(m)
    }

    if (this.sources.has(source)) {
      const m = `double-loading config "${source}" as "${where}", ` +
        `previously loaded as "${this.sources.get(source)}"`
      throw new Error(m)
    }

    conf.source = source
    this.sources.set(source, where)
    if (er) {
      conf.loadError = er
      if (er.code !== 'ENOENT') {
        log.verbose('config', `error loading ${where} config`, er)
      }
    } else {
      conf.raw = obj
      for (const [key, value] of Object.entries(obj)) {
        const k = envReplace(key, this.env)
        const v = this.parseField(value, k)
        if (where !== 'default') {
          this.#checkDeprecated(k)
          if (this.definitions[key]?.exclusive) {
            for (const exclusive of this.definitions[key].exclusive) {
              if (!this.isDefault(exclusive)) {
                throw new TypeError(`--${key} can not be provided when using --${exclusive}`)
              }
            }
          }
        }
        // Some defaults like npm-version are not user-definable and thus don't have definitions
        if (where !== 'default') {
          this.checkUnknown(where, key)
        }
        conf.data[k] = v
      }
    }
  }

  checkUnknown (where, key) {
    if (!this.definitions[key]) {
      if (internalEnv.includes(key)) {
        return
      }
      if (!key.includes(':')) {
        log.warn(`Unknown ${where} config "${where === 'cli' ? '--' : ''}${key}". This will stop working in the next major version of npm.`)
        return
      }
      const baseKey = key.split(':').pop()
      if (!this.definitions[baseKey] && !this.nerfDarts.includes(baseKey)) {
        log.warn(`Unknown ${where} config "${baseKey}" (${key}). This will stop working in the next major version of npm.`)
      }
    }
  }

  #checkDeprecated (key) {
    if (this.deprecated[key]) {
      log.warn('config', key, this.deprecated[key])
    }
  }

  // Parse a field, coercing it to the best type available.
  parseField (f, key, listElement = false) {
    return parseField(f, key, this, listElement)
  }

  async #loadFile (file, type) {
    // only catch the error from readFile, not from the loadObject call
    log.silly('config', `load:file:${file}`)
    await readFile(file, 'utf8').then(
      data => {
        const parsedConfig = ini.parse(data)
        if (type === 'project' && parsedConfig.prefix) {
          // Log error if prefix is mentioned in project .npmrc
          /* eslint-disable-next-line max-len */
          log.error('config', `prefix cannot be changed from project config: ${file}.`)
        }
        return this.#loadObject(parsedConfig, type, file)
      },
      er => this.#loadObject(null, type, file, er)
    )
  }

  loadBuiltinConfig () {
    return this.#loadFile(resolve(this.npmPath, 'npmrc'), 'builtin')
  }

  async loadProjectConfig () {
    // the localPrefix can be set by the CLI config, but otherwise is
    // found by walking up the folder tree. either way, we load it before
    // we return to make sure localPrefix is set
    await this.loadLocalPrefix()

    // if we have not detected a local package json yet, try now that we
    // have a local prefix
    if (this.localPackage == null) {
      this.localPackage = await fileExists(this.localPrefix, 'package.json')
    }

    if (this.#get('global') === true || this.#get('location') === 'global') {
      this.data.get('project').source = '(global mode enabled, ignored)'
      this.sources.set(this.data.get('project').source, 'project')
      return
    }

    const projectFile = resolve(this.localPrefix, '.npmrc')
    // if we're in the ~ directory, and there happens to be a node_modules
    // folder (which is not TOO uncommon, it turns out), then we can end
    // up loading the "project" config where the "userconfig" will be,
    // which causes some calamaties.  So, we only load project config if
    // it doesn't match what the userconfig will be.
    if (projectFile !== this.#get('userconfig')) {
      return this.#loadFile(projectFile, 'project')
    } else {
      this.data.get('project').source = '(same as "user" config, ignored)'
      this.sources.set(this.data.get('project').source, 'project')
    }
  }

  async loadLocalPrefix () {
    const cliPrefix = this.#get('prefix', 'cli')
    if (cliPrefix) {
      this.localPrefix = cliPrefix
      return
    }

    const cliWorkspaces = this.#get('workspaces', 'cli')
    const isGlobal = this.#get('global') || this.#get('location') === 'global'

    for (const p of walkUp(this.cwd)) {
      // HACK: this is an option set in tests to stop the local prefix from being set
      // on tests that are created inside the npm repo
      if (this.excludeNpmCwd && p === this.npmPath) {
        break
      }

      const hasPackageJson = await fileExists(p, 'package.json')

      if (!this.localPrefix && (hasPackageJson || await dirExists(p, 'node_modules'))) {
        this.localPrefix = p
        this.localPackage = hasPackageJson

        // if workspaces are disabled, or we're in global mode, return now
        if (cliWorkspaces === false || isGlobal) {
          return
        }

        // otherwise, continue the loop
        continue
      }

      if (this.localPrefix && hasPackageJson) {
        const pkgJson = require('@npmcli/package-json')
        // if we already set localPrefix but this dir has a package.json
        // then we need to see if `p` is a workspace root by reading its package.json
        // however, if reading it fails then we should just move on
        const { content: pkg } = await pkgJson.normalize(p).catch(() => ({ content: {} }))
        if (!pkg?.workspaces) {
          continue
        }

        const mapWorkspaces = require('@npmcli/map-workspaces')
        const workspaces = await mapWorkspaces({ cwd: p, pkg })
        for (const w of workspaces.values()) {
          if (w === this.localPrefix) {
            // see if there's a .npmrc file in the workspace, if so log a warning
            if (await fileExists(this.localPrefix, '.npmrc')) {
              log.warn('config', `ignoring workspace config at ${this.localPrefix}/.npmrc`)
            }

            // set the workspace in the default layer, which allows it to be overridden easily
            const { data } = this.data.get('default')
            data.workspace = [this.localPrefix]
            this.localPrefix = p
            this.localPackage = hasPackageJson
            log.info('config', `found workspace root at ${this.localPrefix}`)
            // we found a root, so we return now
            return
          }
        }
      }
    }

    if (!this.localPrefix) {
      this.localPrefix = this.cwd
    }
  }

  loadUserConfig () {
    return this.#loadFile(this.#get('userconfig'), 'user')
  }

  loadGlobalConfig () {
    return this.#loadFile(this.#get('globalconfig'), 'global')
  }

  async save (where) {
    if (!this.loaded) {
      throw new Error('call config.load() before saving')
    }
    if (!confFileTypes.has(where)) {
      throw new Error('invalid config location param: ' + where)
    }

    const conf = this.data.get(where)
    conf[_loadError] = null

    if (where === 'user') {
      // if email is nerfed, then we want to de-nerf it
      const nerfed = nerfDart(this.get('registry'))
      const email = this.get(`${nerfed}:email`, 'user')
      if (email) {
        this.delete(`${nerfed}:email`, 'user')
        this.set('email', email, 'user')
      }
    }

    // We need the actual raw data before we called parseField so that we are
    // saving the same content back to the file
    const iniData = ini.stringify(conf.raw).trim() + '\n'
    if (!iniData.trim()) {
      // ignore the unlink error (eg, if file doesn't exist)
      await unlink(conf.source).catch(() => {})
      return
    }
    const dir = dirname(conf.source)
    await mkdir(dir, { recursive: true })
    await writeFile(conf.source, iniData, 'utf8')
    const mode = where === 'user' ? 0o600 : 0o666
    await chmod(conf.source, mode)
  }

  clearCredentialsByURI (uri, level = 'user') {
    const nerfed = nerfDart(uri)
    const def = nerfDart(this.get('registry'))
    if (def === nerfed) {
      this.delete(`-authtoken`, level)
      this.delete(`_authToken`, level)
      this.delete(`_authtoken`, level)
      this.delete(`_auth`, level)
      this.delete(`_password`, level)
      this.delete(`username`, level)
      // de-nerf email if it's nerfed to the default registry
      const email = this.get(`${nerfed}:email`, level)
      if (email) {
        this.set('email', email, level)
      }
    }
    this.delete(`${nerfed}:_authToken`, level)
    this.delete(`${nerfed}:_auth`, level)
    this.delete(`${nerfed}:_password`, level)
    this.delete(`${nerfed}:username`, level)
    this.delete(`${nerfed}:email`, level)
    this.delete(`${nerfed}:certfile`, level)
    this.delete(`${nerfed}:keyfile`, level)
  }

  setCredentialsByURI (uri, { token, username, password, certfile, keyfile }) {
    const nerfed = nerfDart(uri)

    // field that hasn't been used as documented for a LONG time,
    // and as of npm 7.10.0, isn't used at all.  We just always
    // send auth if we have it, only to the URIs under the nerf dart.
    this.delete(`${nerfed}:always-auth`, 'user')

    this.delete(`${nerfed}:email`, 'user')
    if (certfile && keyfile) {
      this.set(`${nerfed}:certfile`, certfile, 'user')
      this.set(`${nerfed}:keyfile`, keyfile, 'user')
      // cert/key may be used in conjunction with other credentials, thus no `else`
    }
    if (token) {
      this.set(`${nerfed}:_authToken`, token, 'user')
      this.delete(`${nerfed}:_password`, 'user')
      this.delete(`${nerfed}:username`, 'user')
    } else if (username || password) {
      if (!username) {
        throw new Error('must include username')
      }
      if (!password) {
        throw new Error('must include password')
      }
      this.delete(`${nerfed}:_authToken`, 'user')
      this.set(`${nerfed}:username`, username, 'user')
      // note: not encrypted, no idea why we bothered to do this, but oh well
      // protects against shoulder-hacks if password is memorable, I guess?
      const encoded = Buffer.from(password, 'utf8').toString('base64')
      this.set(`${nerfed}:_password`, encoded, 'user')
    } else if (!certfile || !keyfile) {
      throw new Error('No credentials to set.')
    }
  }

  // this has to be a bit more complicated to support legacy data of all forms
  getCredentialsByURI (uri) {
    const nerfed = nerfDart(uri)
    const def = nerfDart(this.get('registry'))
    const creds = {}

    // email is handled differently, it used to always be nerfed and now it never should be
    // if it's set nerfed to the default registry, then we copy it to the unnerfed key
    // TODO: evaluate removing 'email' from the credentials object returned here
    const email = this.get(`${nerfed}:email`) || this.get('email')
    if (email) {
      if (nerfed === def) {
        this.set('email', email, 'user')
      }
      creds.email = email
    }

    const certfileReg = this.get(`${nerfed}:certfile`)
    const keyfileReg = this.get(`${nerfed}:keyfile`)
    if (certfileReg && keyfileReg) {
      creds.certfile = certfileReg
      creds.keyfile = keyfileReg
      // cert/key may be used in conjunction with other credentials, thus no `return`
    }

    const tokenReg = this.get(`${nerfed}:_authToken`)
    if (tokenReg) {
      creds.token = tokenReg
      return creds
    }

    const userReg = this.get(`${nerfed}:username`)
    const passReg = this.get(`${nerfed}:_password`)
    if (userReg && passReg) {
      creds.username = userReg
      creds.password = Buffer.from(passReg, 'base64').toString('utf8')
      const auth = `${creds.username}:${creds.password}`
      creds.auth = Buffer.from(auth, 'utf8').toString('base64')
      return creds
    }

    const authReg = this.get(`${nerfed}:_auth`)
    if (authReg) {
      const authDecode = Buffer.from(authReg, 'base64').toString('utf8')
      const authSplit = authDecode.split(':')
      creds.username = authSplit.shift()
      creds.password = authSplit.join(':')
      creds.auth = authReg
      return creds
    }

    // at this point, nothing else is usable so just return what we do have
    return creds
  }

  // set up the environment object we have with npm_config_* environs
  // for all configs that are different from their default values, and
  // set EDITOR and HOME.
  setEnvs () {
    setEnvs(this)
  }
}

const _loadError = Symbol('loadError')
const _valid = Symbol('valid')

class ConfigData {
  #data
  #source = null
  #raw = null
  constructor (parent) {
    this.#data = Object.create(parent && parent.data)
    this.#raw = {}
    this[_valid] = true
  }

  get data () {
    return this.#data
  }

  get valid () {
    return this[_valid]
  }

  set source (s) {
    if (this.#source) {
      throw new Error('cannot set ConfigData source more than once')
    }
    this.#source = s
  }

  get source () {
    return this.#source
  }

  set loadError (e) {
    if (this[_loadError] || (Object.keys(this.#raw).length)) {
      throw new Error('cannot set ConfigData loadError after load')
    }
    this[_loadError] = e
  }

  get loadError () {
    return this[_loadError]
  }

  set raw (r) {
    if (Object.keys(this.#raw).length || this[_loadError]) {
      throw new Error('cannot set ConfigData raw after load')
    }
    this.#raw = r
  }

  get raw () {
    return this.#raw
  }
}

module.exports = Config
