/* eslint-disable no-extend-native */
/* eslint-disable no-global-assign */
const EventEmitter = require('events')
const t = require('tap')

// NOTE: Although these unit tests may look like the rest on the surface,
// they are in fact very special due to the amount of things hooking directly
// to global process and variables defined in the module scope. That makes
// for tests that are very interdependent and their order are important.

// generic error to be used in tests
const err = Object.assign(new Error('ERROR'), { code: 'ERROR' })
err.stack = 'Error: ERROR'

const redactCwd = (path) => {
  const normalizePath = p => p
    .replace(/\\+/g, '/')
    .replace(/\r\n/g, '\n')
  return normalizePath(path)
    .replace(new RegExp(normalizePath(process.cwd()), 'g'), '{CWD}')
}

t.cleanSnapshot = (str) => redactCwd(str)

// internal modules mocks
const cacheFile = {
  append: () => null,
  write: () => null,
}
const config = {
  values: {
    cache: 'cachefolder',
    timing: true,
  },
  loaded: true,
  updateNotification: null,
  get (key) {
    return this.values[key]
  },
}

const npm = {
  version: '1.0.0',
  config,
  shelloutCommands: ['exec', 'run-script'],
}

const npmlog = {
  disableProgress: () => null,
  log (level, ...args) {
    this.record.push({
      id: this.record.length,
      level,
      message: args.reduce((res, i) => `${res} ${i.message ? i.message : i}`, ''),
      prefix: level !== 'verbose' ? 'foo' : '',
    })
  },
  error (...args) {
    this.log('error', ...args)
  },
  info (...args) {
    this.log('info', ...args)
  },
  level: 'silly',
  levels: {
    silly: 0,
    verbose: 1,
    info: 2,
    error: 3,
    silent: 4,
  },
  notice (...args) {
    this.log('notice', ...args)
  },
  record: [],
  verbose (...args) {
    this.log('verbose', ...args)
  },
}

// overrides OS type/release for cross platform snapshots
const os = require('os')
os.type = () => 'Foo'
os.release = () => '1.0.0'

// bootstrap tap before cutting off process ref
t.test('ok', (t) => {
  t.ok('ok')
  t.end()
})
// cut off process from script so that it won't quit the test runner
// while trying to run through the myriad of cases
const _process = process
process = Object.assign(
  new EventEmitter(),
  {
    argv: ['/node', ..._process.argv.slice(1)],
    cwd: _process.cwd,
    env: _process.env,
    exit () {},
    exitCode: 0,
    version: 'v1.0.0',
    stdout: { write (_, cb) {
      cb()
    } },
    stderr: { write () {} },
    hrtime: _process.hrtime,
  }
)
// needs to put process back in its place
// in order for tap to exit properly
t.teardown(() => {
  process = _process
})

const mocks = {
  npmlog,
  '../../../lib/npm.js': npm,
  '../../../lib/utils/error-message.js': (err) => ({
    ...err,
    summary: [['ERR', err.message]],
    detail: [['ERR', err.message]],
  }),
  '../../../lib/utils/cache-file.js': cacheFile,
}

let errorHandler = t.mock('../../../lib/utils/error-handler.js', mocks)

t.test('default exit code', (t) => {
  t.plan(1)

  // manually simulate timing handlers
  process.emit('timing', 'foo', 1)
  process.emit('timing', 'foo', 2)

  // generates logfile name with mocked date
  const _toISOString = Date.prototype.toISOString
  Date.prototype.toISOString = () => 'expecteddate'

  npmlog.level = 'silent'
  const _exit = process.exit
  process.exit = (code) => {
    t.equal(code, 1, 'should default to error code 1')
  }

  // skip console.error logs
  const _error = console.error
  console.error = () => null

  process.emit('exit', 1)

  t.teardown(() => {
    npmlog.level = 'silly'
    process.exit = _exit
    console.error = _error
    Date.prototype.toISOString = _toISOString
  })
})

t.test('handles unknown error', (t) => {
  t.plan(2)

  cacheFile.write = (filename, content) => {
    t.equal(
      redactCwd(filename),
      '{CWD}/cachefolder/_logs/expecteddate-debug.log',
      'should use expected log filename'
    )
    t.matchSnapshot(
      content,
      'should have expected log contents for unknown error'
    )
  }

  errorHandler(err)

  t.teardown(() => {
    cacheFile.write = () => null
  })
  t.end()
})

t.test('npm.config not ready', (t) => {
  t.plan(1)

  config.loaded = false

  const _error = console.error
  console.error = (msg) => {
    t.match(
      msg,
      /Error: Exit prior to config file resolving./,
      'should exit with config error msg'
    )
  }

  errorHandler()

  t.teardown(() => {
    console.error = _error
    config.loaded = true
  })
})

t.test('fail to write logfile', (t) => {
  t.plan(1)

  cacheFile.write = () => {
    throw err
  }
  t.teardown(() => {
    cacheFile.write = () => null
  })

  t.doesNotThrow(
    () => errorHandler(err),
    'should not throw on cache write failure'
  )
})

t.test('console.log output using --json', (t) => {
  t.plan(1)

  config.values.json = true

  const _error = console.error
  console.error = (jsonOutput) => {
    t.same(
      JSON.parse(jsonOutput),
      {
        error: {
          code: 'EBADTHING', // should default error code to E[A-Z]+
          summary: 'Error: EBADTHING Something happened',
          detail: 'Error: EBADTHING Something happened',
        },
      },
      'should output expected json output'
    )
  }

  errorHandler(new Error('Error: EBADTHING Something happened'))

  t.teardown(() => {
    console.error = _error
    delete config.values.json
  })
})

t.test('throw a non-error obj', (t) => {
  t.plan(3)

  const weirdError = {
    code: 'ESOMETHING',
    message: 'foo bar',
  }

  const _logError = npmlog.error
  npmlog.error = (title, err) => {
    t.equal(title, 'weird error', 'should name it a weird error')
    t.same(err, weirdError, 'should log given weird error')
  }

  const _exit = process.exit
  process.exit = (code) => {
    t.equal(code, 1, 'should exit with code 1')
  }

  errorHandler(weirdError)

  t.teardown(() => {
    process.exit = _exit
    npmlog.error = _logError
  })
})

t.test('throw a string error', (t) => {
  t.plan(3)

  const error = 'foo bar'

  const _logError = npmlog.error
  npmlog.error = (title, err) => {
    t.equal(title, '', 'should have an empty name ref')
    t.same(err, 'foo bar', 'should log string error')
  }

  const _exit = process.exit
  process.exit = (code) => {
    t.equal(code, 1, 'should exit with code 1')
  }

  errorHandler(error)

  t.teardown(() => {
    process.exit = _exit
    npmlog.error = _logError
  })
})

t.test('update notification', (t) => {
  t.plan(2)

  const updateMsg = 'you should update npm!'
  npm.updateNotification = updateMsg

  const _notice = npmlog.notice
  npmlog.notice = (prefix, msg) => {
    t.equal(prefix, '', 'should have no prefix')
    t.equal(msg, updateMsg, 'should show update message')
  }

  errorHandler(err)

  t.teardown(() => {
    npmlog.notice = _notice
    delete npm.updateNotification
  })
})

t.test('on exit handler', (t) => {
  t.plan(2)

  const _exit = process.exit
  process.exit = (code) => {
    t.equal(code, 1, 'should default to error code 1')
  }

  process.once('timeEnd', (msg) => {
    t.equal(msg, 'npm', 'should trigger timeEnd for npm')
  })

  // skip console.error logs
  const _error = console.error
  console.error = () => null

  process.emit('exit', 1)

  t.teardown(() => {
    console.error = _error
    process.exit = _exit
  })
})

t.test('it worked', (t) => {
  t.plan(2)

  config.values.timing = false

  const _exit = process.exit
  process.exit = (code) => {
    process.exit = _exit
    t.equal(code, 0, 'should exit with code 0')

    const _info = npmlog.info
    npmlog.info = (msg) => {
      npmlog.info = _info
      t.equal(msg, 'ok', 'should log ok if "it worked"')
    }

    process.emit('exit', 0)
  }

  t.teardown(() => {
    process.exit = _exit
    config.values.timing = true
  })

  errorHandler.exit(0)
})

t.test('uses code from errno', (t) => {
  t.plan(1)

  errorHandler = t.mock('../../../lib/utils/error-handler.js', mocks)

  npmlog.level = 'silent'
  const _exit = process.exit
  process.exit = (code) => {
    t.equal(code, 127, 'should use set errno')
  }

  errorHandler(Object.assign(
    new Error('Error with errno'),
    {
      errno: 127,
    }
  ))

  t.teardown(() => {
    npmlog.level = 'silly'
    process.exit = _exit
  })
})

t.test('uses exitCode as code if using a number', (t) => {
  t.plan(1)

  errorHandler = t.mock('../../../lib/utils/error-handler.js', mocks)

  npmlog.level = 'silent'
  const _exit = process.exit
  process.exit = (code) => {
    t.equal(code, 404, 'should use code if a number')
  }

  errorHandler(Object.assign(
    new Error('Error with code type number'),
    {
      code: 404,
    }
  ))

  t.teardown(() => {
    npmlog.level = 'silly'
    process.exit = _exit
  })
})

t.test('call errorHandler with no error', (t) => {
  t.plan(1)

  errorHandler = t.mock('../../../lib/utils/error-handler.js', mocks)

  const _exit = process.exit
  process.exit = (code) => {
    t.equal(code, 0, 'should exit with code 0')
  }

  t.teardown(() => {
    process.exit = _exit
  })

  errorHandler()
})

t.test('callback called twice', (t) => {
  t.plan(2)

  const _verbose = npmlog.verbose
  npmlog.verbose = (key, value) => {
    t.equal(key, 'stack', 'should log stack in verbose level')
    t.match(
      value,
      /Error: Callback called more than once./,
      'should have expected error msg'
    )
    npmlog.verbose = _verbose
  }

  errorHandler()
})

t.test('defaults to log error msg if stack is missing', (t) => {
  t.plan(1)

  const noStackErr = Object.assign(
    new Error('Error with no stack'),
    {
      code: 'ENOSTACK',
      errno: 127,
    }
  )
  delete noStackErr.stack

  npm.config.loaded = false

  const _error = console.error
  console.error = (msg) => {
    console.error = _error
    npm.config.loaded = true
    t.equal(msg, 'Error with no stack', 'should use error msg')
  }

  errorHandler(noStackErr)
})

t.test('set it worked', (t) => {
  t.plan(1)

  errorHandler = t.mock('../../../lib/utils/error-handler.js', mocks)

  const _exit = process.exit
  process.exit = () => {
    t.ok('ok')
  }

  t.teardown(() => {
    process.exit = _exit
  })

  errorHandler.exit(0, true)
})

t.test('use exitCode when emitting exit event', (t) => {
  t.plan(1)

  npmlog.level = 'silent'
  const _exit = process.exit
  process.exit = (code) => {
    process.exit = _exit
    t.equal(code, 1, 'should exit with code 1')
  }

  t.teardown(() => {
    process.exit = _exit
    npmlog.level = 'silly'
  })

  process.emit('exit')
})

t.test('do no fancy handling for shellouts', t => {
  const { exit } = process
  const { command } = npm
  const { log } = npmlog
  const LOG_RECORD = []
  t.teardown(() => {
    npmlog.log = log
    process.exit = exit
    npm.command = command
  })

  npmlog.log = function (level, ...args) {
    log.call(this, level, ...args)
    LOG_RECORD.push(npmlog.record[npmlog.record.length - 1])
  }

  npm.command = 'exec'

  let EXPECT_EXIT = 0
  process.exit = code => {
    t.equal(code, EXPECT_EXIT, 'got expected exit code')
    EXPECT_EXIT = 0
  }
  t.beforeEach(() => LOG_RECORD.length = 0)

  const loudNoises = () => LOG_RECORD
    .filter(({ level }) => ['warn', 'error'].includes(level))

  t.test('shellout with a numeric error code', t => {
    EXPECT_EXIT = 5
    errorHandler(Object.assign(new Error(), { code: 5 }))
    t.equal(EXPECT_EXIT, 0, 'called process.exit')
    // should log no warnings or errors, verbose/silly is fine.
    t.strictSame(loudNoises(), [], 'no noisy warnings')
    t.end()
  })

  t.test('shellout without a numeric error code (something in npm)', t => {
    EXPECT_EXIT = 1
    errorHandler(Object.assign(new Error(), { code: 'banana stand' }))
    t.equal(EXPECT_EXIT, 0, 'called process.exit')
    // should log some warnings and errors, because something weird happened
    t.strictNotSame(loudNoises(), [], 'bring the noise')
    t.end()
  })

  t.test('shellout with code=0 (extra weird?)', t => {
    EXPECT_EXIT = 1
    errorHandler(Object.assign(new Error(), { code: 0 }))
    t.equal(EXPECT_EXIT, 0, 'called process.exit')
    // should log some warnings and errors, because something weird happened
    t.strictNotSame(loudNoises(), [], 'bring the noise')
    t.end()
  })

  t.end()
})
