const { join } = require('node:path')
const fs = require('node:fs/promises')
const ini = require('ini')
const tspawk = require('../../fixtures/tspawk')
const t = require('tap')
const { load: _loadMockNpm } = require('../../fixtures/mock-npm')
const { cleanCwd } = require('../../fixtures/clean-snapshot.js')

const spawk = tspawk(t)

const replaceJsonOrIni = (key) => [
  new RegExp(`(\\s(?:${key} = |"${key}": )"?)[^"\\n,]+`, 'g'),
  `$1{${key.toUpperCase()}}`,
]

const replaceIniComment = (key) => [
  new RegExp(`(; ${key} = ).*`, 'g'),
  `$1{${key.replaceAll(' ', '-').toUpperCase()}}`,
]

t.cleanSnapshot = (s) => cleanCwd(s)
  .replaceAll(...replaceIniComment('node version'))
  .replaceAll(...replaceIniComment('npm version'))
  .replaceAll(...replaceIniComment('node bin location'))
  .replaceAll(...replaceJsonOrIni('npm-version'))
  .replaceAll(...replaceJsonOrIni('viewer'))
  .replaceAll(...replaceJsonOrIni('shell'))
  .replaceAll(...replaceJsonOrIni('editor'))
  .replaceAll(...replaceJsonOrIni('progress'))
  .replaceAll(...replaceJsonOrIni('color'))
  .replaceAll(...replaceJsonOrIni('cache'))

const loadMockNpm = (t, opts = {}) => _loadMockNpm(t, {
  ...opts,
  config: {
    ...opts.config,
    // Reset configs that mock npm sets by default
    'fetch-retries': undefined,
    loglevel: undefined,
    color: false,
  },
})

t.test('config no args', async t => {
  const { npm } = await loadMockNpm(t)

  await t.rejects(
    npm.exec('config', []),
    {
      code: 'EUSAGE',
    },
    'rejects with usage'
  )
})

t.test('config ignores workspaces', async t => {
  const { npm } = await loadMockNpm(t, {
    config: {
      workspaces: true,
    },
  })

  await t.rejects(
    npm.exec('config'),
    {
      code: 'ENOWORKSPACES',
    },
    'rejects with usage'
  )
})

t.test('config list', async t => {
  const { npm, joinedOutput } = await loadMockNpm(t, {
    prefixDir: {
      '.npmrc': 'projectloaded=yes',
    },
    globalPrefixDir: {
      etc: {
        npmrc: 'globalloaded=yes',
      },
    },
    homeDir: {
      '.npmrc': [
        'userloaded=yes',
        'auth=bad',
        '_auth=bad',
        '//nerfdart:auth=bad',
        '//nerfdart:_auth=bad',
      ].join('\n'),
    },
  })

  await npm.exec('config', ['list'])

  const output = joinedOutput()

  t.match(output, 'projectloaded = "yes"')
  t.match(output, 'globalloaded = "yes"')
  t.match(output, 'userloaded = "yes"')

  t.matchSnapshot(output, 'output matches snapshot')
})

t.test('config list --long', async t => {
  const { npm, joinedOutput } = await loadMockNpm(t, {
    prefixDir: {
      '.npmrc': 'projectloaded=yes',
    },
    globalPrefixDir: {
      etc: {
        npmrc: 'globalloaded=yes',
      },
    },
    homeDir: {
      '.npmrc': 'userloaded=yes',
    },
    config: {
      long: true,
    },
  })

  await npm.exec('config', ['list'])

  const output = joinedOutput()

  t.match(output, 'projectloaded = "yes"')
  t.match(output, 'globalloaded = "yes"')
  t.match(output, 'userloaded = "yes"')

  t.matchSnapshot(output, 'output matches snapshot')
})

t.test('config list --json', async t => {
  const { npm, joinedOutput } = await loadMockNpm(t, {
    prefixDir: {
      '.npmrc': 'projectloaded=yes',
    },
    globalPrefixDir: {
      etc: {
        npmrc: 'globalloaded=yes',
      },
    },
    homeDir: {
      '.npmrc': 'userloaded=yes',
    },
    config: {
      json: true,
    },
  })

  await npm.exec('config', ['list'])

  const output = joinedOutput()

  t.match(output, '"projectloaded": "yes",')
  t.match(output, '"globalloaded": "yes",')
  t.match(output, '"userloaded": "yes",')

  t.matchSnapshot(output, 'output matches snapshot')
})

t.test('config list with publishConfig', async t => {
  const loadMockNpmWithPublishConfig = (t, opts) => loadMockNpm(t, {
    prefixDir: {
      'package.json': JSON.stringify({
        publishConfig: {
          other: 'not defined',
          registry: 'https://some.registry',
          '//some.registry:_authToken': 'mytoken',
        },
      }),
    },
    ...opts,
  })

  t.test('local', async t => {
    const { npm, logs, joinedOutput } = await loadMockNpmWithPublishConfig(t)

    await npm.exec('config', ['list'])

    const output = joinedOutput()

    t.match(output, 'registry = "https://some.registry"')

    t.matchSnapshot(output, 'output matches snapshot')
    t.matchSnapshot(logs.warn, 'warns about unknown config')
  })

  t.test('global', async t => {
    const { npm, joinedOutput } = await loadMockNpmWithPublishConfig(t, {
      config: {
        global: true,
      },
    })

    await npm.exec('config', ['list'])

    const output = joinedOutput()

    t.notMatch(output, 'registry = "https://some.registry"')

    t.matchSnapshot(output, 'output matches snapshot')
  })
})

t.test('config delete no args', async t => {
  const { npm } = await loadMockNpm(t)

  await t.rejects(
    npm.exec('config', ['delete']),
    {
      code: 'EUSAGE',
    },
    'rejects with usage'
  )
})

t.test('config delete single key', async t => {
  // location defaults to user, so we work with a userconfig
  const { npm, home } = await loadMockNpm(t, {
    homeDir: {
      '.npmrc': 'access=public\nall=true',
    },
  })

  await npm.exec('config', ['delete', 'access'])

  t.equal(npm.config.get('access'), null, 'acces should be defaulted')

  const contents = await fs.readFile(join(home, '.npmrc'), { encoding: 'utf8' })
  const rc = ini.parse(contents)
  t.not(rc.access, 'access is not set')
})

t.test('config delete multiple keys', async t => {
  const { npm, home } = await loadMockNpm(t, {
    homeDir: {
      '.npmrc': 'access=public\nall=true\naudit=false',
    },
  })

  await npm.exec('config', ['delete', 'access', 'all'])

  t.equal(npm.config.get('access'), null, 'access should be defaulted')
  t.equal(npm.config.get('all'), false, 'all should be defaulted')

  const contents = await fs.readFile(join(home, '.npmrc'), { encoding: 'utf8' })
  const rc = ini.parse(contents)
  t.not(rc.access, 'access is not set')
  t.not(rc.all, 'all is not set')
})

t.test('config delete key --location=global', async t => {
  const { npm, globalPrefix } = await loadMockNpm(t, {
    globalPrefixDir: {
      etc: {
        npmrc: 'access=public\nall=true',
      },
    },
    config: {
      location: 'global',
    },
  })
  await npm.exec('config', ['delete', 'access'])

  t.equal(npm.config.get('access', 'global'), undefined, 'access should be defaulted')

  const contents = await fs.readFile(join(globalPrefix, 'etc/npmrc'), { encoding: 'utf8' })
  const rc = ini.parse(contents)
  t.not(rc.access, 'access is not set')
})

t.test('config delete key --global', async t => {
  const { npm, globalPrefix } = await loadMockNpm(t, {
    globalPrefixDir: {
      etc: {
        npmrc: 'access=public\nall=true',
      },
    },
    config: {
      global: true,
    },
  })

  await npm.exec('config', ['delete', 'access'])

  t.equal(npm.config.get('access', 'global'), undefined, 'access should no longer be set')

  const contents = await fs.readFile(join(globalPrefix, 'etc/npmrc'), { encoding: 'utf8' })
  const rc = ini.parse(contents)
  t.not(rc.access, 'access is not set')
})

t.test('config set invalid option', async t => {
  const { npm } = await loadMockNpm(t)
  await t.rejects(
    npm.exec('config', ['set', 'nonexistantconfigoption', 'something']),
    /not a valid npm option/
  )
})

t.test('config set deprecated option', async t => {
  const { npm } = await loadMockNpm(t)
  await t.rejects(
    npm.exec('config', ['set', 'shrinkwrap', 'true']),
    /deprecated/
  )
})

t.test('config set nerf-darted option', async t => {
  const { npm } = await loadMockNpm(t)
  await npm.exec('config', ['set', '//npm.pkg.github.com/:_authToken', '0xdeadbeef'])
  t.equal(
    npm.config.get('//npm.pkg.github.com/:_authToken'),
    '0xdeadbeef',
    'nerf-darted config is set'
  )
})

t.test('config set scoped optoin', async t => {
  const { npm } = await loadMockNpm(t)
  await npm.exec('config', ['set', '@npm:registry', 'https://registry.npmjs.org'])
  t.equal(
    npm.config.get('@npm:registry'),
    'https://registry.npmjs.org',
    'scoped config is set'
  )
})

t.test('config set no args', async t => {
  const { npm } = await loadMockNpm(t)

  await t.rejects(
    npm.exec('config', ['set']),
    {
      code: 'EUSAGE',
    },
    'rejects with usage'
  )
})

t.test('config set key', async t => {
  const { npm, home } = await loadMockNpm(t, {
    homeDir: {
      '.npmrc': 'access=public',
    },
  })

  await npm.exec('config', ['set', 'access'])

  t.equal(npm.config.get('access'), null, 'set the value for access')

  await t.rejects(fs.stat(join(home, '.npmrc'), { encoding: 'utf8' }), 'removed empty config')
})

t.test('config set key value', async t => {
  const { npm, home } = await loadMockNpm(t, {
    homeDir: {
      '.npmrc': 'access=public',
    },
  })

  await npm.exec('config', ['set', 'access', 'restricted'])

  t.equal(npm.config.get('access'), 'restricted', 'set the value for access')

  const contents = await fs.readFile(join(home, '.npmrc'), { encoding: 'utf8' })
  const rc = ini.parse(contents)
  t.equal(rc.access, 'restricted', 'access is set to restricted')
})

t.test('config set key value with equals', async t => {
  const { npm, home } = await loadMockNpm(t, {
    homeDir: {
      '.npmrc': 'access=public',
    },
  })

  await npm.exec('config', ['set', 'access=restricted'])

  t.equal(npm.config.get('access'), 'restricted', 'set the value for access')

  const contents = await fs.readFile(join(home, '.npmrc'), { encoding: 'utf8' })
  const rc = ini.parse(contents)
  t.equal(rc.access, 'restricted', 'access is set to restricted')
})

t.test('config set key1 value1 key2=value2 key3', async t => {
  const { npm, home } = await loadMockNpm(t, {
    homeDir: {
      '.npmrc': 'access=public\nall=true\naudit=true',
    },
  })

  await npm.exec('config', ['set', 'access', 'restricted', 'all=false', 'audit'])

  t.equal(npm.config.get('access'), 'restricted', 'access was set')
  t.equal(npm.config.get('all'), false, 'all was set')
  t.equal(npm.config.get('audit'), true, 'audit was unset and restored to its default')

  const contents = await fs.readFile(join(home, '.npmrc'), { encoding: 'utf8' })
  const rc = ini.parse(contents)
  t.equal(rc.access, 'restricted', 'access is set to restricted')
  t.equal(rc.all, false, 'all is set to false')
  t.not(contents.includes('audit='), 'config file does not set audit')
})

t.test('config set invalid key logs warning', async t => {
  const { npm, logs, home } = await loadMockNpm(t)

  // this doesn't reject, it only logs a warning
  await npm.exec('config', ['set', 'access=foo'])
  t.equal(logs.warn[0],
    `invalid config access="foo" set in ${join(home, '.npmrc')}`,
    'logged warning'
  )
})

t.test('config set key=value --location=global', async t => {
  const { npm, globalPrefix } = await loadMockNpm(t, {
    globalPrefixDir: {
      etc: {
        npmrc: 'access=public\nall=true',
      },
    },
    config: {
      location: 'global',
    },
  })

  await npm.exec('config', ['set', 'access=restricted'])

  t.equal(npm.config.get('access', 'global'), 'restricted', 'foo should be set')

  const contents = await fs.readFile(join(globalPrefix, 'etc/npmrc'), { encoding: 'utf8' })
  const rc = ini.parse(contents)
  t.equal(rc.access, 'restricted', 'access is set to restricted')
})

t.test('config set key=value --global', async t => {
  const { npm, globalPrefix } = await loadMockNpm(t, {
    globalPrefixDir: {
      etc: {
        npmrc: 'access=public\nall=true',
      },
    },
    config: {
      global: true,
    },
  })

  await npm.exec('config', ['set', 'access=restricted'])

  t.equal(npm.config.get('access', 'global'), 'restricted', 'access should be set')

  const contents = await fs.readFile(join(globalPrefix, 'etc/npmrc'), { encoding: 'utf8' })
  const rc = ini.parse(contents)
  t.equal(rc.access, 'restricted', 'access is set to restricted')
})

t.test('config get no args', async t => {
  const { npm, joinedOutput, clearOutput } = await loadMockNpm(t)

  await npm.exec('config', ['get'])
  const getOutput = joinedOutput()

  clearOutput()
  await npm.exec('config', ['list'])
  const listOutput = joinedOutput()

  t.equal(listOutput, getOutput, 'get with no args outputs list')
})

t.test('config get single key', async t => {
  const { npm, joinedOutput } = await loadMockNpm(t)

  await npm.exec('config', ['get', 'all'])
  t.equal(joinedOutput(), `${npm.config.get('all')}`, 'should get the value')
})

t.test('config get multiple keys', async t => {
  const { npm, joinedOutput } = await loadMockNpm(t)

  await npm.exec('config', ['get', 'yes', 'all'])
  t.equal(joinedOutput(), `yes=${npm.config.get('yes')}\nall=${npm.config.get('all')}`)
})

t.test('config get private key', async t => {
  const { npm } = await loadMockNpm(t)

  await t.rejects(
    npm.exec('config', ['get', '_authToken']),
    /_authToken option is protected/,
    'rejects with protected string'
  )

  await t.rejects(
    npm.exec('config', ['get', 'authToken']),
    /authToken option is protected/,
    'rejects with protected string'
  )

  await t.rejects(
    npm.exec('config', ['get', '//localhost:8080/:_password']),
    /_password option is protected/,
    'rejects with protected string'
  )
})

t.test('config redacted values', async t => {
  const { npm, joinedOutput, clearOutput } = await loadMockNpm(t)

  await npm.exec('config', ['set', 'proxy', 'https://proxy.npmjs.org/'])
  await npm.exec('config', ['get', 'proxy'])

  t.equal(joinedOutput(), 'https://proxy.npmjs.org/')
  clearOutput()

  await npm.exec('config', ['set', 'proxy', 'https://u:password@proxy.npmjs.org/'])

  await t.rejects(npm.exec('config', ['get', 'proxy']), /proxy option is protected/)

  await npm.exec('config', ['ls'])

  t.match(joinedOutput(), 'proxy = "https://u:***@proxy.npmjs.org/"')
  clearOutput()
})

t.test('config edit', async t => {
  const EDITOR = 'vim'
  const editor = spawk.spawn(EDITOR).exit(0)

  const { npm, home } = await loadMockNpm(t, {
    homeDir: {
      '.npmrc': 'foo=bar\nbar=baz',
    },
    config: {
      editor: EDITOR,
    },
  })

  await npm.exec('config', ['edit'])

  t.ok(editor.called, 'editor was spawned')
  t.same(
    editor.calledWith.args,
    [join(home, '.npmrc')],
    'editor opened the user config file'
  )

  const contents = await fs.readFile(join(home, '.npmrc'), { encoding: 'utf8' })
  t.ok(contents.includes('foo=bar'), 'kept foo')
  t.ok(contents.includes('bar=baz'), 'kept bar')
  t.ok(contents.includes('shown below with default values'), 'appends defaults to file')
})

t.test('config edit - editor exits non-0', async t => {
  const EDITOR = 'vim'
  const editor = spawk.spawn(EDITOR).exit(1)

  const { npm, home } = await loadMockNpm(t, {
    config: {
      editor: EDITOR,
    },
  })

  await t.rejects(
    npm.exec('config', ['edit']),
    {
      message: 'editor process exited with code: 1',
    },
    'rejects with error about editor code'
  )

  t.ok(editor.called, 'editor was spawned')
  t.same(
    editor.calledWith.args,
    [join(home, '.npmrc')],
    'editor opened the user config file'
  )
})

t.test('config fix', (t) => {
  t.test('no problems', async (t) => {
    const { npm, joinedOutput } = await loadMockNpm(t, {
      homeDir: {
        '.npmrc': '',
      },
    })

    await npm.exec('config', ['fix'])
    t.equal(joinedOutput(), '', 'printed nothing')
  })

  t.test('repairs all configs by default', async (t) => {
    const { npm, home, globalPrefix, joinedOutput } = await loadMockNpm(t, {
      globalPrefixDir: {
        etc: {
          npmrc: '_authtoken=notatoken\n_authToken=afaketoken',
        },
      },
      homeDir: {
        '.npmrc': '_authtoken=thisisinvalid\n_auth=beef',
      },
    })

    const registry = `//registry.npmjs.org/`

    await npm.exec('config', ['fix'])

    // global config fixes
    t.match(joinedOutput(), '`_authtoken` deleted from global config',
      'output has deleted global _authtoken')
    t.match(joinedOutput(), `\`_authToken\` renamed to \`${registry}:_authToken\` in global config`,
      'output has renamed global _authToken')
    t.not(npm.config.get('_authtoken', 'global'), '_authtoken is not set globally')
    t.not(npm.config.get('_authToken', 'global'), '_authToken is not set globally')
    t.equal(npm.config.get(`${registry}:_authToken`, 'global'), 'afaketoken',
      'global _authToken was scoped')
    const globalConfig = await fs.readFile(join(globalPrefix, 'etc/npmrc'), { encoding: 'utf8' })
    t.equal(globalConfig, `${registry}:_authToken=afaketoken\n`, 'global config was written')

    // user config fixes
    t.match(joinedOutput(), '`_authtoken` deleted from user config',
      'output has deleted user _authtoken')
    t.match(joinedOutput(), `\`_auth\` renamed to \`${registry}:_auth\` in user config`,
      'output has renamed user _auth')
    t.not(npm.config.get('_authtoken', 'user'), '_authtoken is not set in user config')
    t.not(npm.config.get('_auth'), '_auth is not set in user config')
    t.equal(npm.config.get(`${registry}:_auth`, 'user'), 'beef', 'user _auth was scoped')
    const userConfig = await fs.readFile(join(home, '.npmrc'), { encoding: 'utf8' })
    t.equal(userConfig, `${registry}:_auth=beef\n`, 'user config was written')
  })

  t.test('repairs only the config specified by --location if asked', async (t) => {
    const { npm, home, globalPrefix, joinedOutput } = await loadMockNpm(t, {
      globalPrefixDir: {
        etc: {
          npmrc: '_authtoken=notatoken\n_authToken=afaketoken',
        },
      },
      homeDir: {
        '.npmrc': '_authtoken=thisisinvalid\n_auth=beef',
      },
      config: {
        location: 'user',
      },
    })
    const registry = `//registry.npmjs.org/`

    await npm.exec('config', ['fix'])

    // global config should be untouched
    t.notMatch(joinedOutput(), '`_authtoken` deleted from global',
      'output has deleted global _authtoken')
    t.notMatch(joinedOutput(), `\`_authToken\` renamed to \`${registry}:_authToken\` in global`,
      'output has renamed global _authToken')
    t.equal(npm.config.get('_authtoken', 'global'), 'notatoken', 'global _authtoken untouched')
    t.equal(npm.config.get('_authToken', 'global'), 'afaketoken', 'global _authToken untouched')
    t.not(npm.config.get(`${registry}:_authToken`, 'global'), 'global _authToken not scoped')
    const globalConfig = await fs.readFile(join(globalPrefix, 'etc/npmrc'), { encoding: 'utf8' })
    t.equal(globalConfig, '_authtoken=notatoken\n_authToken=afaketoken',
      'global config was not written')

    // user config fixes
    t.match(joinedOutput(), '`_authtoken` deleted from user',
      'output has deleted user _authtoken')
    t.match(joinedOutput(), `\`_auth\` renamed to \`${registry}:_auth\` in user`,
      'output has renamed user _auth')
    t.not(npm.config.get('_authtoken', 'user'), '_authtoken is not set in user config')
    t.not(npm.config.get('_auth', 'user'), '_auth is not set in user config')
    t.equal(npm.config.get(`${registry}:_auth`, 'user'), 'beef', 'user _auth was scoped')
    const userConfig = await fs.readFile(join(home, '.npmrc'), { encoding: 'utf8' })
    t.equal(userConfig, `${registry}:_auth=beef\n`, 'user config was written')
  })

  t.end()
})

t.test('completion', async t => {
  const { config, npm } = await loadMockNpm(t, { command: 'config' })

  const allKeys = Object.keys(npm.config.definitions)

  const testComp = async (argv, expect, msg) => {
    const options = Array.isArray(argv) ? {
      conf: {
        argv: {
          remain: ['config', ...argv],
        },
      },
    } : argv
    options.conf.argv.remain.unshift('npm')
    const res = await config.completion(options)
    t.strictSame(res, expect, msg ?? argv.join(' '))
  }

  await testComp([], ['get', 'set', 'delete', 'ls', 'rm', 'edit', 'fix', 'list'])
  await testComp(['set', 'foo'], [])
  await testComp(['get'], allKeys)
  await testComp(['set'], allKeys)
  await testComp(['delete'], allKeys)
  await testComp(['rm'], allKeys)
  await testComp(['edit'], [])
  await testComp(['fix'], [])
  await testComp(['list'], [])
  await testComp(['ls'], [])

  await testComp({
    conf: { argv: { remain: ['get'] } },
  }, allKeys, 'also works for just npm get')

  await testComp({
    partialWord: 'l',
    conf: { argv: { remain: ['config'] } },
  }, ['get', 'set', 'delete', 'ls', 'rm', 'edit', 'fix'], 'and works on partials')
})
