const { expect } = require('chai')
const { promisify } = require('node:util')

const OError = require('../..')

const {
  expectError,
  expectFullStackWithoutStackFramesToEqual,
} = require('./support')

describe('utils', function () {
  describe('OError.tag', function () {
    it('tags errors thrown from an async function', async function () {
      const delay = promisify(setTimeout)

      async function foo() {
        await delay(10)
        throw new Error('foo error')
      }

      async function bar() {
        try {
          await foo()
        } catch (error) {
          throw OError.tag(error, 'failed to bar', { bar: 'baz' })
        }
      }

      async function baz() {
        try {
          await bar()
        } catch (error) {
          throw OError.tag(error, 'failed to baz', { baz: 'bat' })
        }
      }

      try {
        await baz()
        expect.fail('should have thrown')
      } catch (error) {
        expectError(error, {
          name: 'Error',
          klass: Error,
          message: 'Error: foo error',
          firstFrameRx: /at foo/,
        })
        expectFullStackWithoutStackFramesToEqual(error, [
          'Error: foo error',
          'TaggedError: failed to bar',
          'TaggedError: failed to baz',
        ])
        expect(OError.getFullInfo(error)).to.eql({
          bar: 'baz',
          baz: 'bat',
        })
      }
    })

    it('tags errors thrown from a promise rejection', async function () {
      function foo() {
        return new Promise((resolve, reject) => {
          setTimeout(() => {
            reject(new Error('foo error'))
          }, 10)
        })
      }

      async function bar() {
        try {
          await foo()
        } catch (error) {
          throw OError.tag(error, 'failed to bar', { bar: 'baz' })
        }
      }

      async function baz() {
        try {
          await bar()
        } catch (error) {
          throw OError.tag(error, 'failed to baz', { baz: 'bat' })
        }
      }

      try {
        await baz()
        expect.fail('should have thrown')
      } catch (error) {
        expectError(error, {
          name: 'Error',
          klass: Error,
          message: 'Error: foo error',
          firstFrameRx: /_onTimeout/,
        })
        expectFullStackWithoutStackFramesToEqual(error, [
          'Error: foo error',
          'TaggedError: failed to bar',
          'TaggedError: failed to baz',
        ])
        expect(OError.getFullInfo(error)).to.eql({
          bar: 'baz',
          baz: 'bat',
        })
      }
    })

    it('tags errors yielded through callbacks', function (done) {
      function foo(cb) {
        setTimeout(() => {
          cb(new Error('foo error'))
        }, 10)
      }

      function bar(cb) {
        foo(err => {
          if (err) {
            return cb(OError.tag(err, 'failed to bar', { bar: 'baz' }))
          }
          cb()
        })
      }

      function baz(cb) {
        bar(err => {
          if (err) {
            return cb(OError.tag(err, 'failed to baz', { baz: 'bat' }))
          }
          cb()
        })
      }

      baz(err => {
        if (err) {
          expectError(err, {
            name: 'Error',
            klass: Error,
            message: 'Error: foo error',
            firstFrameRx: /_onTimeout/,
          })
          expectFullStackWithoutStackFramesToEqual(err, [
            'Error: foo error',
            'TaggedError: failed to bar',
            'TaggedError: failed to baz',
          ])
          expect(OError.getFullInfo(err)).to.eql({
            bar: 'baz',
            baz: 'bat',
          })
          return done()
        }
        expect.fail('should have yielded an error')
      })
    })

    it('is not included in the stack trace if using capture', function () {
      if (!Error.captureStackTrace) return this.skip()
      const err = new Error('test error')
      OError.tag(err, 'test message')
      const stack = OError.getFullStack(err)
      expect(stack).to.match(/TaggedError: test message\n\s+at/)
      expect(stack).to.not.match(/TaggedError: test message\n\s+at [\w.]*tag/)
    })

    describe('without Error.captureStackTrace', function () {
      /* eslint-disable mocha/no-hooks-for-single-case */
      before(function () {
        this.originalCaptureStackTrace = Error.captureStackTrace
        Error.captureStackTrace = null
      })
      after(function () {
        Error.captureStackTrace = this.originalCaptureStackTrace
      })

      it('still captures a stack trace, albeit including itself', function () {
        const err = new Error('test error')
        OError.tag(err, 'test message')
        expectFullStackWithoutStackFramesToEqual(err, [
          'Error: test error',
          'TaggedError: test message',
        ])
        const stack = OError.getFullStack(err)
        expect(stack).to.match(/TaggedError: test message\n\s+at [\w.]*tag/)
      })
    })

    describe('with limit on the number of tags', function () {
      before(function () {
        this.originalMaxTags = OError.maxTags
        OError.maxTags = 3
      })
      after(function () {
        OError.maxTags = this.originalMaxTags
      })

      it('should not tag more than that', function () {
        const err = new Error('test error')
        OError.tag(err, 'test message 1')
        OError.tag(err, 'test message 2')
        OError.tag(err, 'test message 3')
        OError.tag(err, 'test message 4')
        OError.tag(err, 'test message 5')
        expectFullStackWithoutStackFramesToEqual(err, [
          'Error: test error',
          'TaggedError: test message 1',
          'TaggedError: ... dropped tags',
          'TaggedError: test message 4',
          'TaggedError: test message 5',
        ])
      })

      it('should handle deep recursion', async function () {
        async function recursiveAdd(n) {
          try {
            if (n === 0) throw new Error('deep error')
            const result = await recursiveAdd(n - 1)
            return result + 1
          } catch (err) {
            throw OError.tag(err, `at level ${n}`)
          }
        }

        try {
          await recursiveAdd(10)
        } catch (err) {
          expectFullStackWithoutStackFramesToEqual(err, [
            'Error: deep error',
            'TaggedError: at level 0',
            'TaggedError: ... dropped tags',
            'TaggedError: at level 9',
            'TaggedError: at level 10',
          ])
        }
      })

      it('should handle a singleton error', function (done) {
        const err = new Error('singleton error')

        function endpoint(callback) {
          helper(err => callback(err && OError.tag(err, 'in endpoint')))
        }

        function helper(callback) {
          libraryFunction(err => callback(err && OError.tag(err, 'in helper')))
        }

        function libraryFunction(callback) {
          callback(err)
        }

        endpoint(() => {
          endpoint(err => {
            expect(err).to.exist
            expectFullStackWithoutStackFramesToEqual(err, [
              'Error: singleton error',
              'TaggedError: in helper',
              'TaggedError: ... dropped tags',
              'TaggedError: in helper',
              'TaggedError: in endpoint',
            ])
            done()
          })
        })
      })
    })
  })

  describe('OError.getFullInfo', function () {
    it('works when given null', function () {
      expect(OError.getFullInfo(null)).to.deep.equal({})
    })

    it('works when given a string', function () {
      const err = 'not an error instance'
      expect(OError.getFullInfo(err)).to.deep.equal({})
    })

    it('works on a normal error', function () {
      const err = new Error('foo')
      expect(OError.getFullInfo(err)).to.deep.equal({})
    })

    it('works on an error with tags', function () {
      const err = OError.tag(new Error('foo'), 'bar', { userId: 123 })
      expect(OError.getFullInfo(err)).to.deep.equal({ userId: 123 })
    })

    it('merges info from an error and its tags', function () {
      const err = new OError('foo').withInfo({ projectId: 456 })
      OError.tag(err, 'failed to foo', { userId: 123 })
      expect(OError.getFullInfo(err)).to.deep.equal({
        projectId: 456,
        userId: 123,
      })
    })

    it('merges info from a cause', function () {
      const err1 = new Error('foo')
      const err2 = new Error('bar')
      err1.cause = err2
      err2.info = { userId: 123 }
      expect(OError.getFullInfo(err1)).to.deep.equal({ userId: 123 })
    })

    it('merges info from a nested cause', function () {
      const err1 = new Error('foo')
      const err2 = new Error('bar')
      const err3 = new Error('baz')
      err1.cause = err2
      err2.info = { userId: 123 }
      err2.cause = err3
      err3.info = { foo: 42 }
      expect(OError.getFullInfo(err1)).to.deep.equal({
        userId: 123,
        foo: 42,
      })
    })

    it('merges info from cause with duplicate keys', function () {
      const err1 = new Error('foo')
      const err2 = new Error('bar')
      err1.info = { userId: 42, foo: 1337 }
      err1.cause = err2
      err2.info = { userId: 1 }
      expect(OError.getFullInfo(err1)).to.deep.equal({
        userId: 42,
        foo: 1337,
      })
    })

    it('merges info from tags with duplicate keys', function () {
      const err1 = OError.tag(new Error('foo'), 'bar', { userId: 123 })
      const err2 = OError.tag(err1, 'bat', { userId: 456 })
      expect(OError.getFullInfo(err2)).to.deep.equal({ userId: 456 })
    })

    it('works on an error with .info set to a string', function () {
      const err = new Error('foo')
      err.info = 'test'
      expect(OError.getFullInfo(err)).to.deep.equal({})
    })
  })

  describe('OError.getFullStack', function () {
    it('works when given null', function () {
      expect(OError.getFullStack(null)).to.equal('')
    })

    it('works on a normal error', function () {
      const err = new Error('foo')
      const fullStack = OError.getFullStack(err)
      expect(fullStack).to.match(/^Error: foo$/m)
      expect(fullStack).to.match(/^\s+at /m)
    })

    it('works on an error with a cause', function () {
      const err1 = new Error('foo')
      const err2 = new Error('bar')
      err1.cause = err2

      const fullStack = OError.getFullStack(err1)
      expect(fullStack).to.match(/^Error: foo$/m)
      expect(fullStack).to.match(/^\s+at /m)
      expect(fullStack).to.match(/^caused by:\n\s+Error: bar$/m)
    })

    it('works on both tags and causes', async function () {
      // Here's the actual error.
      function tryToFoo() {
        try {
          throw Error('foo')
        } catch (error) {
          throw OError.tag(error, 'failed to foo', { foo: 1 })
        }
      }

      // Inside another function that wraps it.
      function tryToBar() {
        try {
          tryToFoo()
        } catch (error) {
          throw new OError('failed to bar').withCause(error)
        }
      }

      // And it is in another try.
      try {
        try {
          tryToBar()
          expect.fail('should have thrown')
        } catch (error) {
          throw OError.tag(error, 'failed to bat', { bat: 1 })
        }
      } catch (error) {
        // We catch the wrapping error.
        expectError(error, {
          name: 'OError',
          klass: OError,
          message: 'OError: failed to bar',
          firstFrameRx: /tryToBar/,
        })

        // But the stack contains all of the errors and tags.
        expectFullStackWithoutStackFramesToEqual(error, [
          'OError: failed to bar',
          'TaggedError: failed to bat',
          'caused by:',
          '    Error: foo',
          '    TaggedError: failed to foo',
        ])

        // The info from the wrapped cause should be picked up for logging.
        expect(OError.getFullInfo(error)).to.eql({ bat: 1, foo: 1 })

        // But it should still be recorded.
        expect(OError.getFullInfo(error.cause)).to.eql({ foo: 1 })
      }
    })

    it('works when given non Error', function () {
      expect(OError.getFullStack({ message: 'Foo' })).to.equal('Foo')
    })

    it('works when given non Error with tags', function () {
      const error = OError.tag({ message: 'Foo: bar' }, 'baz')
      expectFullStackWithoutStackFramesToEqual(error, [
        'Foo: bar',
        'TaggedError: baz',
      ])
    })
  })
})
