const Arborist = require('../../lib/arborist')
const t = require('tap')
const { resolve } = require('node:path')
const fixture = resolve(__dirname, '../fixtures/install-types')
const swonlyfixture = resolve(__dirname, '../fixtures/install-types-sw-only')
const badfixture = resolve(__dirname, '../fixtures/root')
const pnpmFixture = resolve(__dirname, '../fixtures/pnpm')
const depTypesFixture = resolve(__dirname, '../fixtures/dev-deps')
const bundleFixture = resolve(__dirname, '../fixtures/two-bundled-deps')
const emptyFixture = resolve(__dirname, '../fixtures/empty-with-shrinkwrap')
const emptyFixtureNoPJ = resolve(__dirname, '../fixtures/empty-with-shrinkwrap-no-pj')
const linkedMeta = resolve(__dirname, '../fixtures/cli-750')
const oldMeta = resolve(__dirname, '../fixtures/old-package-lock')
const tapAndFlow = resolve(__dirname, '../fixtures/tap-and-flow')
const editFixture = resolve(__dirname, '../fixtures/edit-package-json')
const Shrinkwrap = require('../../lib/shrinkwrap.js')
const Node = require('../../lib/node.js')

const {
  normalizePath,
  printTree,
} = require('../fixtures/utils.js')

const cwd = normalizePath(process.cwd())
t.cleanSnapshot = s => s.split(cwd).join('{CWD}')

const loadVirtual = (path, opts) =>
  new Arborist({ path, ...(opts || {}) }).loadVirtual(opts)

t.test('load from fixture', t =>
  loadVirtual(fixture).then(virtualTree => {
    t.matchSnapshot(printTree(virtualTree), 'loaded virtual tree from fixture')
    return new Arborist({ path: fixture, virtualTree }).loadVirtual()
      .then(tree2 => t.equal(tree2, virtualTree, 'same tree reused'))
  }))

t.test('load from fixture using filesystem root path', t => {
  const root = new Node({
    path: '/',
    pkg: require(fixture + '/package.json'),
  })
  return new Arborist({ path: '/' }).loadVirtual({ root })
    .then(virtualTree => t.equal(virtualTree, root))
})

t.test('load from root that already has shrinkwrap', t =>
  Shrinkwrap.load({ path: tapAndFlow }).then(meta => {
    const root = new Node({
      path: tapAndFlow,
      pkg: require(tapAndFlow + '/package.json'),
      meta,
    })
    return new Arborist({ path: tapAndFlow }).loadVirtual({ root })
      .then(virtualTree => t.equal(virtualTree, root))
  }))

t.test('tree with link deps of link deps', t =>
  t.resolveMatchSnapshot(loadVirtual(linkedMeta).then(printTree)))

t.test('load from cwd', t => {
  const cwd = process.cwd()
  t.teardown(() => process.chdir(cwd))
  process.chdir(fixture)
  return loadVirtual().then(tree =>
    t.matchSnapshot(printTree(tree), 'loaded virtual tree from fixture'))
})

t.test('loading without a package-lock fails', t =>
  t.rejects(loadVirtual(badfixture), {
    message: 'loadVirtual requires existing shrinkwrap file',
    code: 'ENOLOCK',
  }))

t.test('load from npm-shrinkwrap.json', t => {
  const lock = require(fixture + '/package-lock.json')
  const pkg = require(fixture + '/package.json')
  const path = t.testdir({
    'npm-shrinkwrap.json': JSON.stringify(lock),
    'package.json': JSON.stringify(pkg),
  })
  return loadVirtual(path).then(tree =>
    t.matchSnapshot(printTree(tree), 'loaded virtual tree from fixture'))
})

t.test('load without a root package.json is fine', t => {
  return loadVirtual(swonlyfixture).then(tree =>
    t.matchSnapshot(printTree(tree), 'loaded virtual no package json'))
})

t.test('load a tree with some links to nodes outside of node_modules', t =>
  loadVirtual(pnpmFixture).then(tree =>
    t.matchSnapshot(printTree(tree), 'loaded virtual tree with fsParents')))

t.test('load a tree with optional and dev dependencies', t =>
  loadVirtual(depTypesFixture).then(tree =>
    t.matchSnapshot(printTree(tree), 'loaded virtual tree with dev/optional deps')))

t.test('load a tree with a bunch of bundles', t =>
  loadVirtual(bundleFixture).then(tree =>
    t.matchSnapshot(printTree(tree), 'virtual tree with multiple bundles')))

t.test('load a tree with an empty root, pj, and a lockfile', async t => {
  const tree = await loadVirtual(emptyFixture)
  t.matchSnapshot(printTree(tree), 'virtual tree with no deps')
})

t.test('load a tree with an empty root, no pj, and a lockfile', async t => {
  const tree = await loadVirtual(emptyFixtureNoPJ)
  t.matchSnapshot(printTree(tree), 'virtual tree with no deps')
})

t.test('load a tree with a v1 lockfile', t =>
  loadVirtual(oldMeta).then(tree =>
    t.matchSnapshot(printTree(tree), 'virtual tree with v1 shronk')))

t.test('load a tree where package.json edited', async t => {
  const ok = resolve(editFixture, 'ok')
  const removed = resolve(editFixture, 'removed')
  const changed = resolve(editFixture, 'changed')
  const wsChanged = resolve(editFixture, 'workspaces-changed')
  const ws = resolve(__dirname, '../fixtures/workspaces-simple-virtual')
  const kFlagsSuspect = Symbol.for('flagsSuspect')

  const okArb = new Arborist({ path: ok })
  await okArb.loadVirtual()
  t.matchSnapshot(printTree(okArb.virtualTree), 'deps match')
  t.equal(okArb[kFlagsSuspect], false, 'flags not suspect')

  const wsArb = new Arborist({ path: ws })
  await wsArb.loadVirtual()
  t.matchSnapshot(printTree(wsArb.virtualTree), 'ws match')
  t.equal(wsArb[kFlagsSuspect], false, 'flags not suspect')

  const rmArb = new Arborist({ path: removed })
  await rmArb.loadVirtual()
  t.matchSnapshot(printTree(rmArb.virtualTree), 'deps removed')
  t.equal(rmArb[kFlagsSuspect], true, 'flags suspect')

  const wsChangedArb = new Arborist({ path: wsChanged })
  await wsChangedArb.loadVirtual()
  t.matchSnapshot(printTree(wsChangedArb.virtualTree), 'ws changed')
  t.equal(wsChangedArb[kFlagsSuspect], true, 'flags suspect')

  const changeArb = new Arborist({ path: changed })
  const root = await changeArb.loadVirtual()
  t.matchSnapshot(printTree(root), 'deps changed')
  t.equal(changeArb[kFlagsSuspect], true, 'flags suspect')

  // pretend that it's a new root with fresh metadata
  root.meta.loadedFromDisk = false
  const newArb = new Arborist({ path: changed })
  await newArb.loadVirtual({ root })
  t.matchSnapshot(printTree(newArb.virtualTree),
    'changed, but re-using the same root that already has meta')
  t.equal(newArb[kFlagsSuspect], false, 'flags not suspect')
})

t.test('workspaces', t => {
  t.test('load a simple example', t =>
    loadVirtual(
      resolve(__dirname, '../fixtures/workspaces-simple-virtual')
    ).then(tree =>
      t.matchSnapshot(printTree(tree), 'virtual tree with multiple bundles')))

  t.test('load shared dependencies example', t =>
    loadVirtual(
      resolve(__dirname, '../fixtures/workspaces-shared-deps-virtual')
    ).then(tree =>
      t.matchSnapshot(printTree(tree), 'virtual tree with shared dependencies')))

  t.test('load conflicting dep versions example', t =>
    loadVirtual(
      resolve(__dirname, '../fixtures/workspaces-conflicting-versions-virtual')
    ).then(tree =>
      t.matchSnapshot(printTree(tree), 'virtual tree with resolved conflicting dependencies')))

  t.test('load prefer linking nested workspaces', t =>
    loadVirtual(
      resolve(__dirname, '../fixtures/workspaces-prefer-linking-virtual')
    ).then(tree =>
      t.matchSnapshot(printTree(tree), 'virtual tree linking to local workspaces')))

  t.test('load installed from registry on version not satisfied', t =>
    loadVirtual(
      resolve(__dirname, '../fixtures/workspaces-version-unsatisfied-virtual')
    ).then(tree =>
      t.matchSnapshot(printTree(tree), 'virtual tree with deduped dep')))

  t.test('load linked top level nested workspaces', t =>
    loadVirtual(
      resolve(__dirname, '../fixtures/workspaces-top-level-link-virtual')
    ).then(tree =>
      t.matchSnapshot(printTree(tree), 'virtual tree top level dep')))

  t.test('load installed workspace with transitive dependencies', t =>
    loadVirtual(
      resolve(__dirname, '../fixtures/workspaces-transitive-deps-virtual')
    ).then(tree =>
      t.matchSnapshot(printTree(tree), 'virtual tree with transitive deps')))

  t.test('load installed tree with ignored nested node_modules folders', t =>
    loadVirtual(
      resolve(__dirname, '../fixtures/workspaces-ignore-nm-virtual')
    ).then(tree =>
      t.matchSnapshot(printTree(tree), 'virtual tree ignoring nested node_modules')))

  t.test('load installed workspace with dependency overrides', t =>
    loadVirtual(
      resolve(__dirname, '../fixtures/workspaces-with-overrides')
    ).then(tree =>
      t.matchSnapshot(printTree(tree), 'virtual tree with overrides')))

  t.end()
})

t.test('do not reset flags on supplied root option', async t => {
  const path = resolve(__dirname, '../fixtures/test-package-with-shrinkwrap')
  const root = new Node({
    path,
    pkg: require(path + '/package.json'),
    dev: true,
    optional: false,
    peer: false,
    devOptional: true,
  })
  const tree = await loadVirtual(path, { root })
  t.equal(tree.dev, true, 'tree is still dev')
  t.equal(tree.devOptional, true, 'tree is still devOptional')
  t.equal(tree.optional, false, 'tree is not optional')
  t.equal(tree.peer, false, 'tree is not peer')
})

t.test('do not bundle the entire universe', async t => {
  const path = resolve(__dirname, '../fixtures/tap-old-lockfile')
  const tree = await loadVirtual(path)
  t.same(tree.children.get('tap').package.bundleDependencies.sort(), [
    'ink',
    'treport',
    '@types/react',
    'import-jsx',
    'minipass',
    'signal-exit',
    'tap-parser',
    'tap-yaml',
    'yaml',
  ].sort())
})

t.test('error when link target is missing', async t => {
  const path = t.testdir({
    'package.json': JSON.stringify({
      name: 'root',
      workspaces: ['packages/*'],
    }),
    'package-lock.json': JSON.stringify({
      name: 'root',
      lockfileVersion: 3,
      packages: {
        '': {
          workspaces: ['packages/*'],
        },
        // This is the problematic entry - a link with no corresponding target
        'node_modules/@my-scope/my-package': {
          resolved: 'packages/some-folder/my-package',
          link: true,
        },
        // Missing entry for 'packages/some-folder/my-package'
      },
    }),
    packages: {
      'some-folder': {
        'my-package': {
          'package.json': JSON.stringify({
            name: '@my-scope/my-package',
            version: '1.0.0',
          }),
        },
      },
    },
  })

  const arb = new Arborist({ path })

  await t.rejects(arb.loadVirtual(), {
    code: 'EMISSINGTARGET',
    message: /Missing target in lock file:.*but does not exist/,
  })
})

t.test('subtreeOnly containment', t => {
  // simulates the sub-Arborist invocation reify uses when inflating a
  // dependency's bundled npm-shrinkwrap.json. a dep must not be able to
  // declare entries that resolve outside its own install directory.

  const makeDep = (sw) => {
    const path = t.testdir({
      'package.json': JSON.stringify({ name: 'dep', version: '1.0.0' }),
      'npm-shrinkwrap.json': JSON.stringify({
        name: 'dep',
        version: '1.0.0',
        lockfileVersion: 3,
        requires: true,
        packages: {
          '': { name: 'dep', version: '1.0.0' },
          ...sw,
        },
      }),
    })
    const root = new Node({
      path,
      pkg: require(path + '/package.json'),
    })
    return { path, root }
  }

  t.test('rejects traversal package key', async t => {
    const { path, root } = makeDep({
      '../sibling': { name: 'sibling', version: '9.9.9' },
    })
    await t.rejects(
      new Arborist({ path }).loadVirtual({ root, subtreeOnly: true }),
      { code: 'EINVALIDLOCATION', location: '../sibling' },
      'traversal package entry is rejected with EINVALIDLOCATION'
    )
  })

  t.test('rejects deeply traversing package key', async t => {
    const { path, root } = makeDep({
      'node_modules/x/../../../escape': { name: 'escape', version: '1.0.0' },
    })
    await t.rejects(
      new Arborist({ path }).loadVirtual({ root, subtreeOnly: true }),
      { code: 'EINVALIDLOCATION' },
      'normalized traversal is rejected'
    )
  })

  t.test('rejects absolute package key', async t => {
    const { path, root } = makeDep({
      '/etc/passwd': { name: 'whatever', version: '1.0.0' },
    })
    await t.rejects(
      new Arborist({ path }).loadVirtual({ root, subtreeOnly: true }),
      { code: 'EINVALIDLOCATION', location: '/etc/passwd' },
      'absolute package key is rejected'
    )
  })

  t.test('rejects non-root location that normalizes back to base', async t => {
    const { path, root } = makeDep({
      'node_modules/..': { name: 'dep', version: '9.9.9' },
    })
    await t.rejects(
      new Arborist({ path }).loadVirtual({ root, subtreeOnly: true }),
      { code: 'EINVALIDLOCATION' },
      'collapsed-to-base path is rejected'
    )
  })

  t.test('rejects link entry whose location traverses', async t => {
    const { path, root } = makeDep({
      '../evil-link': { resolved: 'node_modules/x', link: true },
      'node_modules/x': { name: 'x', version: '1.0.0' },
    })
    await t.rejects(
      new Arborist({ path }).loadVirtual({ root, subtreeOnly: true }),
      { code: 'EINVALIDLOCATION' },
      'link location that escapes the subtree is rejected'
    )
  })

  t.test('rejects link entry whose resolved target traverses', async t => {
    const { path, root } = makeDep({
      'node_modules/x': { resolved: '../escape-target', link: true },
    })
    await t.rejects(
      new Arborist({ path }).loadVirtual({ root, subtreeOnly: true }),
      { code: 'EINVALIDLOCATION' },
      'link resolved target outside the subtree is rejected'
    )
  })

  t.test('rejects link entry with empty resolved (falls back to location)', async t => {
    const { path, root } = makeDep({
      'node_modules/x': { resolved: '', link: true },
    })
    await t.rejects(
      new Arborist({ path }).loadVirtual({ root, subtreeOnly: true }),
      { code: 'EINVALIDLOCATION', location: 'node_modules/x' },
      'link without a real resolved target is rejected'
    )
  })

  t.test('permits legitimate intra-subtree entries', async t => {
    const { path, root } = makeDep({
      'node_modules/abbrev': {
        version: '1.1.1',
        resolved: 'https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz',
        integrity: 'sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==',
      },
    })
    const tree = await new Arborist({ path }).loadVirtual({ root, subtreeOnly: true })
    t.equal(tree, root, 'returns the supplied root')
    t.ok(tree.children.get('abbrev'), 'abbrev node was materialized')
  })

  t.test('without subtreeOnly, root lockfiles with file:../ entries still load', async t => {
    // root lockfiles can legitimately point at external file: deps; the
    // containment check only applies in subtreeOnly mode.
    const dir = t.testdir({
      sibling: {
        'package.json': JSON.stringify({ name: 'sibling', version: '1.0.0' }),
      },
      project: {
        'package.json': JSON.stringify({
          name: 'project',
          version: '1.0.0',
          dependencies: { sibling: 'file:../sibling' },
        }),
        'package-lock.json': JSON.stringify({
          name: 'project',
          version: '1.0.0',
          lockfileVersion: 3,
          requires: true,
          packages: {
            '': {
              name: 'project',
              version: '1.0.0',
              dependencies: { sibling: 'file:../sibling' },
            },
            '../sibling': { name: 'sibling', version: '1.0.0' },
            'node_modules/sibling': { resolved: '../sibling', link: true },
          },
        }),
      },
    })
    const tree = await loadVirtual(resolve(dir, 'project'))
    t.ok(tree.children.get('sibling'), 'external file:../ link still loads')
  })

  t.end()
})
