const os = require('node:os')
const fs = require('node:fs').promises
const fsSync = require('node:fs')
const path = require('node:path')
const tap = require('tap')
const mockLogs = require('./mock-logs.js')
const mockGlobals = require('@npmcli/mock-globals')
const tmock = require('./tmock')
const MockRegistry = require('@npmcli/mock-registry')
const defExitCode = process.exitCode

const changeDir = (dir) => {
  if (dir) {
    const cwd = process.cwd()
    process.chdir(dir)
    return () => process.chdir(cwd)
  }
  return () => {}
}

const setGlobalNodeModules = (globalDir) => {
  const updateSymlinks = (obj, visit) => {
    for (const [key, value] of Object.entries(obj)) {
      if (/Fixture<symlink>/.test(value.toString())) {
        obj[key] = tap.fixture('symlink', path.join('..', value.content))
      } else if (typeof value === 'object') {
        obj[key] = updateSymlinks(value, visit)
      }
    }
    return obj
  }

  if (globalDir.lib) {
    throw new Error('`globalPrefixDir` should not have a top-level `lib/` directory, only a ' +
      'top-level `node_modules/` dir that gets set in the correct location based on platform. ' +
      `Received the following top level entries: ${Object.keys(globalDir).join(', ')}.`
    )
  }

  if (process.platform !== 'win32' && globalDir.node_modules) {
    const { node_modules: nm, ...rest } = globalDir
    return {
      ...rest,
      lib: { node_modules: updateSymlinks(nm) },
    }
  }

  return globalDir
}

const buildMocks = (t, mocks) => {
  const allMocks = { ...mocks }
  // The definitions must be mocked since they are a singleton that reads from
  // process and environs to build defaults in order to break the requiure
  // cache. We also need to mock them with any mocks that were passed in for the
  // test in case those mocks are for things like ci-info which is used there.
  const definitions = '@npmcli/config/lib/definitions'
  allMocks[definitions] = tmock(t, definitions, allMocks)
  return allMocks
}

const getMockNpm = async (t, { mocks, init, load, npm: npmOpts }) => {
  const { streams, logs } = mockLogs()
  const allMocks = buildMocks(t, mocks)
  const Npm = tmock(t, '{LIB}/npm.js', allMocks)

  class MockNpm extends Npm {
    constructor (opts) {
      super({
        ...opts,
        ...streams,
        ...npmOpts,
      })
    }

    async load () {
      const res = await super.load()
      // Wait for any promises (currently only log file cleaning) to be
      // done before returning from load in tests. This helps create more
      // deterministic testing behavior because in reality that promise
      // is left hanging on purpose as a best-effort and the process gets
      // closed regardless of if it has finished or not.
      await Promise.all(this.unrefPromises)
      return res
    }
  }

  const npm = init ? new MockNpm() : null
  if (npm && load) {
    await npm.load()
  }

  return {
    Npm: MockNpm,
    npm,
    ...logs,
  }
}

const mockNpms = new Map()

const setupMockNpm = async (t, {
  init = true,
  load = init,
  // preload a command
  command = null, // string name of the command
  exec = null, // optionally exec the command before returning
  // test dirs
  prefixDir = {},
  homeDir = {},
  cacheDir = {},
  globalPrefixDir = { node_modules: {} },
  otherDirs = {},
  chdir = ({ prefix }) => prefix,
  // setup config, env vars, mocks, npm opts
  config: _config = {},
  mocks = {},
  globals = {},
  npm: npmOpts = {},
  argv: rawArgv = [],
} = {}) => {
  // easy to accidentally forget to pass in tap
  if (!(t instanceof tap.Test)) {
    throw new Error('first argument must be a tap instance')
  }

  // mockNpm is designed to only be run once per test chain so we assign it to
  // the test in the cache and error if it is attempted to run again
  let tapInstance = t
  while (tapInstance) {
    if (mockNpms.has(tapInstance)) {
      throw new Error('mockNpm can only be called once in each t.test chain')
    }
    tapInstance = tapInstance.parent
  }
  mockNpms.set(t, true)

  if (!init && load) {
    throw new Error('cant `load` without `init`')
  }

  // These are globals manipulated by npm itself that we need to reset to their
  // original values between tests
  const npmEnvs = Object.keys(process.env).filter(k => k.startsWith('npm_'))
  mockGlobals(t, {
    process: {
      title: process.title,
      execPath: process.execPath,
      env: {
        NODE_ENV: process.env.NODE_ENV,
        COLOR: process.env.COLOR,
        // further, these are npm controlled envs that we need to zero out before
        // before the test. setting them to undefined ensures they are not set and
        // also returned to their original value after the test
        ...npmEnvs.reduce((acc, k) => {
          acc[k] = undefined
          return acc
        }, {}),
      },
    },
  })

  const dir = t.testdir({
    home: homeDir,
    prefix: prefixDir,
    cache: cacheDir,
    global: setGlobalNodeModules(globalPrefixDir),
    other: otherDirs,
  })

  const dirs = {
    testdir: dir,
    prefix: path.join(dir, 'prefix'),
    cache: path.join(dir, 'cache'),
    globalPrefix: path.join(dir, 'global'),
    home: path.join(dir, 'home'),
    other: path.join(dir, 'other'),
  }

  // Option objects can also be functions that are called with all the dir paths
  // so they can be used to set configs that need to be based on paths
  const withDirs = (v) => typeof v === 'function' ? v(dirs) : v

  const teardownDir = changeDir(withDirs(chdir))

  const defaultConfigs = {
    // We want to fail fast when writing tests. Default this to 0 unless it was
    // explicitly set in a test.
    'fetch-retries': 0,
    cache: dirs.cache,
    // This will give us all the loglevels including timing in a non-colorized way
    // so we can easily assert their contents. Individual tests can overwrite these
    // with my passing in configs if they need to test other forms of output.
    loglevel: 'silly',
    color: false,
  }

  const { argv, env, config } = Object.entries({ ...defaultConfigs, ...withDirs(_config) })
    .reduce((acc, [key, value]) => {
      // nerfdart configs passed in need to be set via env var instead of argv
      // and quoted with `"` so mock globals will ignore that it contains dots
      if (key.startsWith('//')) {
        acc.env[`process.env."npm_config_${key}"`] = value
      } else if (value !== undefined) {
        const values = [].concat(value)
        acc.argv.push(...values.flatMap(v => v === '' ? `--${key}` : `--${key}=${v.toString()}`))
      }
      if (value !== undefined) {
        acc.config[key] = value
      }
      return acc
    }, { argv: [...rawArgv], env: {}, config: {} })

  const mockedGlobals = mockGlobals(t, {
    'process.env.HOME': dirs.home,
    // global prefix cannot be (easily) set via argv so this is the easiest way
    // to set it that also closely mimics the behavior a user would see since it
    // will already be set while `npm.load()` is being run
    // Note that this only sets the global prefix and the prefix is set via chdir
    'process.env.PREFIX': dirs.globalPrefix,
    ...withDirs(globals),
    ...env,
  })

  const { npm, ...mockNpm } = await getMockNpm(t, {
    init,
    load,
    mocks: withDirs(mocks),
    npm: {
      argv: command ? [command, ...argv] : argv,
      excludeNpmCwd: true,
      ...withDirs(npmOpts),
    },
  })

  if (config.omit?.includes('prod')) {
    // XXX(HACK): --omit=prod is not a valid config according to the definitions but
    // it was being hacked in via flatOptions for older tests so this is to
    // preserve that behavior and reduce churn in the snapshots. this should be
    // removed or fixed in the future
    npm.flatOptions.omit.push('prod')
  }

  t.teardown(() => {
    if (npm) {
      npm.unload()
    }
    // only set exitCode back if we're passing tests
    if (t.passing()) {
      process.exitCode = defExitCode
    }
    teardownDir()
  })

  const mockCommand = {}
  if (command) {
    const Cmd = mockNpm.Npm.cmd(command)
    mockCommand.cmd = new Cmd(npm)
    mockCommand[command] = {
      usage: Cmd.describeUsage,
      exec: (args) => npm.exec(command, args),
      completion: (args) => Cmd.completion(args, npm),
    }
    if (exec) {
      await mockCommand[command].exec(exec === true ? [] : exec)
      // assign string output to the command now that we have it
      // for easier testing
      mockCommand[command].output = mockNpm.joinedOutput()
    }
  }

  return {
    npm,
    mockedGlobals,
    ...mockNpm,
    ...dirs,
    ...mockCommand,
    debugFile: async () => {
      const readFiles = npm.logFiles.map(f => fs.readFile(f))
      const logFiles = await Promise.all(readFiles)
      return logFiles
        .flatMap((d) => d.toString().trim().split(os.EOL))
        .filter(Boolean)
        .join('\n')
    },
    timingFile: async () => {
      const data = await fs.readFile(npm.logPath + 'timing.json', 'utf8')
      return JSON.parse(data)
    },
  }
}

const loadNpmWithRegistry = async (t, opts) => {
  const mock = await setupMockNpm(t, opts)
  return {
    ...mock,
    ...loadRegistry(t, mock, opts),
    ...loadFsAssertions(t, mock),
  }
}

const loadRegistry = (t, mock, opts) => {
  const registry = new MockRegistry({
    tap: t,
    registry: opts.registry ?? mock.npm.config.get('registry'),
    authorization: opts.authorization,
    basic: opts.basic,
    debug: opts.debugRegistry ?? false,
    strict: opts.strictRegistryNock ?? true,
  })
  return { registry }
}

const loadFsAssertions = (t, mock) => {
  const fileShouldExist = (filePath) => {
    t.equal(
      fsSync.existsSync(path.join(mock.npm.prefix, filePath)), true, `${filePath} should exist`
    )
  }

  const fileShouldNotExist = (filePath) => {
    t.equal(
      fsSync.existsSync(path.join(mock.npm.prefix, filePath)), false, `${filePath} should not exist`
    )
  }

  const packageVersionMatches = (filePath, version) => {
    t.equal(
      JSON.parse(fsSync.readFileSync(path.join(mock.npm.prefix, filePath), 'utf8')).version, version
    )
  }

  const packageInstalled = (target) => {
    const spec = path.basename(target)
    const dirname = path.dirname(target)
    const [name, version = '1.0.0'] = spec.split('@')
    fileShouldNotExist(`${dirname}/${name}/${name}@${version}.txt`)
    packageVersionMatches(`${dirname}/${name}/package.json`, version)
    fileShouldExist(`${dirname}/${name}/index.js`)
  }

  const packageMissing = (target) => {
    const spec = path.basename(target)
    const dirname = path.dirname(target)
    const [name, version = '1.0.0'] = spec.split('@')
    fileShouldNotExist(`${dirname}/${name}/${name}@${version}.txt`)
    fileShouldNotExist(`${dirname}/${name}/package.json`)
    fileShouldNotExist(`${dirname}/${name}/index.js`)
  }

  const packageDirty = (target) => {
    const spec = path.basename(target)
    const dirname = path.dirname(target)
    const [name, version = '1.0.0'] = spec.split('@')
    fileShouldExist(`${dirname}/${name}/${name}@${version}.txt`)
    packageVersionMatches(`${dirname}/${name}/package.json`, version)
    fileShouldNotExist(`${dirname}/${name}/index.js`)
  }

  const assert = {
    fileShouldExist,
    fileShouldNotExist,
    packageVersionMatches,
    packageInstalled,
    packageMissing,
    packageDirty,
  }

  return { assert }
}

/** breaks down a spec "abbrev@1.1.1" into different parts for mocking */
function dependencyDetails (spec, opt = {}) {
  const [name, version = '1.0.0'] = spec.split('@')
  const { parent, hoist = true, ws, clean = true } = opt
  const modulePathPrefix = !hoist && parent ? `${parent}/` : ''
  const modulePath = `${modulePathPrefix}node_modules/${name}`
  const resolved = `https://registry.npmjs.org/${name}/-/${name}-${version}.tgz`
  // deps
  const wsEntries = Object.entries({ ...ws })
  const depsMap = wsEntries.map(([s, o]) => dependencyDetails(s, { ...o, parent: name }))
  const dependencies = Object.assign({}, ...depsMap.map(d => d.packageDependency))
  const spreadDependencies = depsMap.length ? { dependencies } : {}
  // package and lock objects
  const packageDependency = { [name]: version }
  const packageLockEntry = { [modulePath]: { version, resolved } }
  const packageLockLink = { [modulePath]: { resolved: name, link: true } }
  const packageLockLocal = { [name]: { version, dependencies } }
  // build package.js
  const packageJSON = { name, version, ...spreadDependencies }
  const packageJSONString = JSON.stringify(packageJSON)
  const packageJSONFile = { 'package.json': packageJSONString }
  // build index.js
  const indexJSString = 'module.exports = "hello world"'
  const indexJSFile = { 'index.js': indexJSString }
  // tarball
  const packageFiles = { ...packageJSONFile, ...indexJSFile }
  const nodeModules = Object.assign({}, ...depsMap.map(d => d.hoist ? {} : d.dirtyOrCleanDir))
  const nodeModulesDir = { node_modules: nodeModules }
  const packageDir = { [name]: { ...packageFiles, ...nodeModulesDir } }
  const tarballDir = { [`${name}@${version}`]: packageFiles }
  // dirty files
  const dirtyFile = { [`${name}@${version}.txt`]: 'dirty file' }
  const dirtyFiles = { ...packageJSONFile, ...dirtyFile }
  const dirtyDir = { [name]: dirtyFiles }
  const dirtyOrCleanDir = clean ? {} : dirtyDir

  return {
    packageDependency,
    hoist,
    depsMap,
    dirtyOrCleanDir,
    tarballDir,
    packageDir,
    packageLockEntry,
    packageLockLink,
    packageLockLocal,
  }
}

function workspaceMock (t, opts) {
  const toObject = [(a, c) => ({ ...a, ...c }), {}]
  const { workspaces: workspacesDef, ...rest } = { clean: true, ...opts }
  const workspaces = Object.fromEntries(Object.entries(workspacesDef).map(([name, ws]) => {
    return [name, Object.fromEntries(Object.entries(ws).map(([wsPackageDep, wsPackageDepOpts]) => {
      return [wsPackageDep, { ...rest, ...wsPackageDepOpts }]
    }))]
  }))
  const root = 'workspace-root'
  const version = '1.0.0'
  const names = Object.keys(workspaces)
  const ws = Object.entries(workspaces).map(([name, _ws]) => dependencyDetails(name, { ws: _ws }))
  const deps = ws.map(({ depsMap }) => depsMap).flat()
  const tarballs = deps.map(w => w.tarballDir).reduce(...toObject)
  const symlinks = names
    .map((name) => ({ [name]: t.fixture('symlink', `../${name}`) })).reduce(...toObject)
  const hoisted = deps.filter(d => d.hoist).map(w => w.dirtyOrCleanDir).reduce(...toObject)
  const workspaceFolders = ws.map(w => w.packageDir).reduce(...toObject)
  const packageJSON = { name: root, version, workspaces: names }
  const packageLockJSON = ({
    name: root,
    version,
    lockfileVersion: 3,
    requires: true,
    packages: {
      '': { name: root, version, workspaces: names },
      ...deps.filter(d => d.hoist).map(d => d.packageLockEntry).reduce(...toObject),
      ...ws.map(d => d.packageLockEntry).flat().reduce(...toObject),
      ...ws.map(d => d.packageLockLink).flat().reduce(...toObject),
      ...ws.map(d => d.packageLockLocal).flat().reduce(...toObject),
      ...deps.filter(d => !d.hoist).map(d => d.packageLockEntry).reduce(...toObject),
    },
  })
  return {
    tarballs,
    node_modules: {
      ...hoisted,
      ...symlinks,
    },
    'package-lock.json': JSON.stringify(packageLockJSON),
    'package.json': JSON.stringify(packageJSON),
    ...workspaceFolders,
  }
}

module.exports = setupMockNpm
module.exports.load = setupMockNpm
module.exports.setGlobalNodeModules = setGlobalNodeModules
module.exports.loadNpmWithRegistry = loadNpmWithRegistry
module.exports.workspaceMock = workspaceMock
