const t = require('tap')
const { spawnSync } = require('child_process')
const { resolve, join, extname, basename, sep } = require('path')
const { copyFileSync, readFileSync, chmodSync, readdirSync, rmSync } = require('fs')
const Diff = require('diff')
const { sync: which } = require('which')
const { version } = require('../../package.json')

const ROOT = resolve(__dirname, '../..')
const BIN = join(ROOT, 'bin')
const SHIMS = readdirSync(BIN).reduce((acc, shim) => {
  if (extname(shim) !== '.js') {
    acc[shim] = readFileSync(join(BIN, shim), 'utf-8')
  }
  return acc
}, {})

const SHIM_EXTS = [...new Set(Object.keys(SHIMS).map(p => extname(p)))]

// windows requires each segment of a command path to be quoted when using shell: true
const quotePath = (cmd) => cmd
  .split(sep)
  .map(p => p.includes(' ') ? `"${p}"` : p)
  .join(sep)

t.test('shim contents', t => {
  // these scripts should be kept in sync so this tests the contents of each
  // and does a diff to ensure the only differences between them are necessary
  const diffFiles = (npm, npx) => Diff.diffChars(npm, npx)
    .filter(v => v.added || v.removed)
    .reduce((acc, v) => {
      if (v.value.length === 1) {
        acc.letters.add(v.value.toUpperCase())
      } else {
        acc.diff.push(v.value)
      }
      return acc
    }, { diff: [], letters: new Set() })

  t.plan(SHIM_EXTS.length)

  t.test('bash', t => {
    const { diff, letters } = diffFiles(SHIMS.npm, SHIMS.npx)
    t.match(diff[0].split('\n').reverse().join(''), /^NPX_CLI_JS=/, 'has NPX_CLI')
    t.equal(diff.length, 1)
    t.strictSame([...letters], ['M', 'X'], 'all other changes are m->x')
    t.end()
  })

  t.test('cmd', t => {
    const { diff, letters } = diffFiles(SHIMS['npm.cmd'], SHIMS['npx.cmd'])
    t.match(diff[0], /^SET "NPX_CLI_JS=/, 'has NPX_CLI')
    t.equal(diff.length, 1)
    t.strictSame([...letters], ['M', 'X'], 'all other changes are m->x')
    t.end()
  })

  t.test('pwsh', t => {
    const { diff, letters } = diffFiles(SHIMS['npm.ps1'], SHIMS['npx.ps1'])
    t.equal(diff.length, 0)
    t.strictSame([...letters], ['M', 'X'], 'all other changes are m->x')
    t.end()
  })
})

t.test('run shims', t => {
  const path = t.testdir({
    ...SHIMS,
    // simulate the state where one version of npm is installed
    // with node, but we should load the globally installed one
    'global-prefix': {
      node_modules: {
        npm: t.fixture('symlink', ROOT),
      },
    },
    // put in a shim that ONLY prints the intended global prefix,
    // and should not be used for anything else.
    node_modules: {
      npm: {
        bin: {
          'npx-cli.js': `throw new Error('this should not be called')`,
          'npm-cli.js': `
            const assert = require('assert')
            const { resolve } = require('path')
            assert.equal(process.argv.slice(2).join(' '), 'prefix -g')
            console.log(resolve(__dirname, '../../../global-prefix'))
          `,
        },
      },
    },
  })

  // hacky fix to decrease flakes of this test from `NOTEMPTY: directory not empty, rmdir`
  // this should get better in tap@18 and we can try removing it then
  copyFileSync(process.execPath, join(path, 'node.exe'))
  t.teardown(async () => {
    rmSync(join(path, 'node.exe'))
    await new Promise(res => setTimeout(res, 100))
    // this is superstition
    rmSync(join(path, 'node.exe'), { force: true })
  })

  const spawnPath = (cmd, args, { log, stdioString = true, ...opts } = {}) => {
    if (cmd.endsWith('bash.exe')) {
      // only cygwin *requires* the -l, but the others are ok with it
      args.unshift('-l')
    }
    const result = spawnSync(cmd, args, {
      // don't hit the registry for the update check
      env: { PATH: path, npm_config_update_notifier: 'false' },
      cwd: path,
      windowsHide: true,
      ...opts,
    })
    if (stdioString) {
      result.stdout = result.stdout?.toString()?.trim()
      result.stderr = result.stderr?.toString()?.trim()
    }
    return {
      status: result.status,
      signal: result.signal,
      stdout: result.stdout,
      stderr: result.stderr,
    }
  }

  const getWslVersion = (cmd) => {
    const defaultVersion = 1
    try {
      const opts = { shell: cmd, env: process.env }
      const wsl = spawnPath('wslpath', [`'${which('wsl')}'`], opts).stdout
      const distrosRaw = spawnPath(wsl, ['-l', '-v'], { ...opts, stdioString: false }).stdout
      const distros = spawnPath('iconv', ['-f', 'unicode'], { ...opts, input: distrosRaw }).stdout
      const distroArgs = distros
        .replace(/\r\n/g, '\n')
        .split('\n')
        .slice(1)
        .find(d => d.startsWith('*'))
        .replace(/\s+/g, ' ')
        .split(' ')
      return Number(distroArgs[distroArgs.length - 1]) || defaultVersion
    } catch {
      return defaultVersion
    }
  }

  for (const shim of Object.keys(SHIMS)) {
    chmodSync(join(path, shim), 0o755)
  }

  const { ProgramFiles = '/', SystemRoot = '/', NYC_CONFIG, WINDOWS_SHIMS_TEST } = process.env
  const skipDefault = WINDOWS_SHIMS_TEST || process.platform === 'win32'
    ? null : 'test not relevant on platform'

  const shells = Object.entries({
    cmd: 'cmd',
    pwsh: 'pwsh',
    git: join(ProgramFiles, 'Git', 'bin', 'bash.exe'),
    'user git': join(ProgramFiles, 'Git', 'usr', 'bin', 'bash.exe'),
    wsl: join(SystemRoot, 'System32', 'bash.exe'),
    cygwin: resolve(SystemRoot, '/', 'cygwin64', 'bin', 'bash.exe'),
  }).map(([name, cmd]) => {
    let match = {}
    const skip = { reason: skipDefault, fail: WINDOWS_SHIMS_TEST }
    const isBash = cmd.endsWith('bash.exe')
    const testName = `${name} ${isBash ? 'bash' : ''}`.trim()

    if (!skip.reason) {
      if (isBash) {
        try {
          // If WSL is installed, it *has* a bash.exe, but it fails if
          // there is no distro installed, so we need to detect that.
          if (spawnPath(cmd, ['-c', 'exit 0']).status !== 0) {
            throw new Error('not installed')
          }
          if (name === 'cygwin' && NYC_CONFIG) {
            throw new Error('does not play nicely with nyc')
          }
          // WSL version 1 does not work due to
          // https://github.com/microsoft/WSL/issues/2370
          if (name === 'wsl' && getWslVersion(cmd) === 1) {
            match = {
              status: 1,
              stderr: 'WSL 1 is not supported. Please upgrade to WSL 2 or above.',
              stdout: String,
            }
          }
        } catch (err) {
          skip.reason = err.message
        }
      } else {
        try {
          cmd = which(cmd)
        } catch {
          skip.reason = 'not installed'
        }
      }
    }

    return {
      match,
      cmd,
      name: testName,
      skip: {
        ...skip,
        reason: skip.reason ? `${testName} - ${skip.reason}` : null,
      },
    }
  })

  const matchCmd = (t, cmd, bin, match) => {
    const args = []
    const opts = {}

    switch (basename(cmd).toLowerCase()) {
      case 'cmd.exe':
        cmd = `${bin}.cmd`
        break
      case 'bash.exe':
        args.push(bin)
        break
      case 'pwsh.exe':
        cmd = quotePath(cmd)
        args.push(`${bin}.ps1`)
        opts.shell = true
        break
      default:
        throw new Error('unknown shell')
    }

    const isNpm = bin === 'npm'
    const result = spawnPath(cmd, [...args, isNpm ? 'help' : '--version'], opts)

    t.match(result, {
      status: 0,
      signal: null,
      stderr: '',
      stdout: isNpm ? `npm@${version} ${ROOT}` : version,
      ...match,
    }, `${cmd} ${bin}`)
  }

  // ensure that all tests are either run or skipped
  t.plan(shells.length)

  for (const { cmd, skip, name, match } of shells) {
    t.test(name, t => {
      if (skip.reason) {
        if (skip.fail) {
          t.fail(skip.reason)
        } else {
          t.skip(skip.reason)
        }
        return t.end()
      }
      t.plan(2)
      matchCmd(t, cmd, 'npm', match)
      matchCmd(t, cmd, 'npx', match)
    })
  }
})
