import {
  vpaidInlineAd,
  vpaidInlineParsedXML,
  vastVpaidInlineXML
} from '../../../fixtures'
import {VideoAdContainer} from '../../adContainer/VideoAdContainer'
import {loadCreative} from '../helpers/vpaid/loadCreative'
import {handshake} from '../helpers/vpaid/handshake'
import {initAd} from '../helpers/vpaid/initAd'
import {callAndWait} from '../helpers/vpaid/callAndWait'
import {AdUnitError} from '../helpers/adUnitError'
import {VpaidAdUnit} from '../VpaidAdUnit'
import {
  adLoaded,
  adStarted,
  adStopped,
  adPlaying,
  resumeAd,
  adPaused,
  pauseAd,
  skipAd,
  resizeAd,
  adSizeChange,
  adVideoComplete,
  adError,
  EVENTS,
  adSkipped,
  adVolumeChange,
  adImpression,
  adVideoStart,
  adVideoFirstQuartile,
  adVideoMidpoint,
  adVideoThirdQuartile,
  adUserAcceptInvitation,
  adUserMinimize,
  adUserClose,
  adClickThru,
  getAdIcons,
  getAdDuration,
  getAdRemainingTime,
  adDurationChange,
  adRemainingTimeChange
} from '../helpers/vpaid/api'
import {
  linearEvents,
  skip,
  start,
  mute,
  unmute,
  impression,
  midpoint,
  complete,
  firstQuartile,
  thirdQuartile,
  pause,
  resume,
  clickThrough,
  iconClick,
  closeLinear,
  iconView,
  creativeView
} from '../../tracker/linearEvents'
import {acceptInvitation, adCollapse} from '../../tracker/nonLinearEvents'
import {ErrorCode} from '../../tracker'
import type {VastChain} from '../../types'
import {addIcons} from '../helpers/icons/addIcons'
import {retrieveIcons} from '../helpers/icons/retrieveIcons'
import {volumeChanged, adProgress} from '../adUnitEvents'
import {MockVpaidCreativeAd} from './MockVpaidCreativeAd'

jest.mock('../helpers/vpaid/loadCreative')
jest.mock('../helpers/vpaid/handshake')
jest.mock('../helpers/vpaid/initAd')
jest.mock('../helpers/vpaid/callAndWait')

const mockDrawIcons = jest.fn()
const mockRemoveIcons = jest.fn()
const mockHasPendingRedraws = jest.fn(() => false)

jest.mock('../helpers/icons/addIcons', () => ({
  addIcons: jest.fn(() => ({
    drawIcons: mockDrawIcons,
    hasPendingIconRedraws: mockHasPendingRedraws,
    removeIcons: mockRemoveIcons
  }))
}))
jest.mock('../helpers/icons/retrieveIcons', () => ({retrieveIcons: jest.fn()}))

describe('VpaidAdUnit', () => {
  let vpaidChain: VastChain
  let videoAdContainer: VideoAdContainer

  beforeEach(() => {
    ;(callAndWait as jest.Mock).mockImplementation(
      jest.requireActual('../helpers/vpaid/callAndWait').callAndWait
    )
    vpaidChain = [
      {
        ad: vpaidInlineAd,
        parsedXML: vpaidInlineParsedXML,
        requestTag: 'https://test.example.com/vastadtaguri',
        XML: vastVpaidInlineXML
      }
    ]
    videoAdContainer = new VideoAdContainer(document.createElement('DIV'))
    videoAdContainer.slotElement = document.createElement('DIV')
  })

  afterEach(() => {
    jest.restoreAllMocks()
    jest.clearAllMocks()
  })

  test('must load the creative and publish the passed vastChain and container', () => {
    const adUnit = new VpaidAdUnit(vpaidChain, videoAdContainer)

    expect(adUnit.vastChain).toBe(vpaidChain)
    expect(adUnit.videoAdContainer).toBe(videoAdContainer)
    expect(loadCreative).toHaveBeenCalledTimes(1)
    expect(loadCreative).toHaveBeenCalledWith(vpaidChain, videoAdContainer)
  })

  describe('start', () => {
    let mockCreativeAd: MockVpaidCreativeAd
    let adUnit: VpaidAdUnit

    beforeEach(() => {
      mockCreativeAd = new MockVpaidCreativeAd()
      ;(initAd as jest.Mock).mockImplementation(() => {
        mockCreativeAd.emit(adLoaded)
      })
      ;(mockCreativeAd.startAd as jest.Mock).mockImplementation(() => {
        mockCreativeAd.emit(adStarted)
      })
      ;(mockCreativeAd.stopAd as jest.Mock).mockImplementationOnce(() => {
        mockCreativeAd.emit(adStopped)
      })
      ;(loadCreative as jest.Mock).mockReturnValue(
        Promise.resolve(mockCreativeAd)
      )
      ;(retrieveIcons as jest.Mock).mockReturnValue(undefined)

      adUnit = new VpaidAdUnit(vpaidChain, videoAdContainer)
    })

    test('must mute the creative if the videoElement is muted', async () => {
      const {videoElement} = videoAdContainer

      videoElement.muted = true

      await adUnit.start()

      expect(mockCreativeAd.setAdVolume).toHaveBeenCalledTimes(1)
      expect(mockCreativeAd.setAdVolume).toHaveBeenCalledWith(0)
    })

    test('must set the volume of the creative to match the volume of the videoElement', async () => {
      const {videoElement} = videoAdContainer

      videoElement.muted = false
      videoElement.volume = 0.6

      await adUnit.start()

      expect(mockCreativeAd.setAdVolume).toHaveBeenCalledTimes(1)
      expect(mockCreativeAd.setAdVolume).toHaveBeenCalledWith(0.6)
    })

    test('must start the ad', async () => {
      expect(adUnit.isStarted()).toBe(false)

      await adUnit.start()

      expect(adUnit.isStarted()).toBe(true)
      expect(adUnit.creativeAd).toBe(mockCreativeAd)
      expect(handshake).toHaveBeenCalledTimes(1)
      expect(handshake).toHaveBeenCalledWith(mockCreativeAd, '2.0')
      expect(initAd).toHaveBeenCalledTimes(1)
      expect(initAd).toHaveBeenCalledWith(
        mockCreativeAd,
        videoAdContainer,
        vpaidChain
      )
      expect(mockCreativeAd.startAd).toHaveBeenCalledTimes(1)
      expect(mockCreativeAd.stopAd).toHaveBeenCalledTimes(0)
    })

    test('must not call startAd if the videoContainer was destroyed while loading the ad', async () => {
      expect(adUnit.isStarted()).toBe(false)
      videoAdContainer.destroy()

      await adUnit.start()

      expect(adUnit.isStarted()).toBe(false)
      expect(adUnit.creativeAd).toBe(mockCreativeAd)
      expect(handshake).toHaveBeenCalledTimes(1)
      expect(handshake).toHaveBeenCalledWith(mockCreativeAd, '2.0')
      expect(initAd).toHaveBeenCalledTimes(1)
      expect(initAd).toHaveBeenCalledWith(
        mockCreativeAd,
        videoAdContainer,
        vpaidChain
      )
      expect(mockCreativeAd.startAd).toHaveBeenCalledTimes(0)
      expect(mockCreativeAd.stopAd).toHaveBeenCalledTimes(0)
    })

    test('must call stopAd if adStarted event does not get acknowledged', async () => {
      mockCreativeAd.startAd.mockImplementation(() => {
        throw new Error('Error starting ad')
      })

      adUnit = new VpaidAdUnit(vpaidChain, videoAdContainer)

      expect(adUnit.isStarted()).toBe(false)

      await adUnit.start()

      expect(adUnit.isStarted()).toBe(false)
      expect(adUnit.isFinished()).toBe(true)
      expect(adUnit.creativeAd).toBe(mockCreativeAd)
      expect(handshake).toHaveBeenCalledTimes(1)
      expect(handshake).toHaveBeenCalledWith(mockCreativeAd, '2.0')
      expect(initAd).toHaveBeenCalledTimes(1)
      expect(initAd).toHaveBeenCalledWith(
        mockCreativeAd,
        videoAdContainer,
        vpaidChain
      )
      expect(mockCreativeAd.startAd).toHaveBeenCalledTimes(1)
      expect(mockCreativeAd.stopAd).toHaveBeenCalledTimes(1)
    })

    test('must throw if the adUnit is started', async () => {
      await adUnit.start()

      try {
        await adUnit.start()
      } catch (error: any) {
        expect(error.message).toBe('VpaidAdUnit already started')
      }
    })

    describe("with creativeAd's adIcon property", () => {
      test('undefined (VPAID 1) must not render the icons', async () => {
        delete (mockCreativeAd as any)[getAdIcons]
        await adUnit.start()

        expect(adUnit.icons).toBeUndefined()
      })

      test('false, must not render the icons', async () => {
        mockCreativeAd[getAdIcons].mockReturnValue(false)
        await adUnit.start()

        expect(adUnit.icons).toBeUndefined()
      })

      describe('true,', () => {
        test('without vast icons, must not add the icons', async () => {
          mockCreativeAd[getAdIcons].mockReturnValue(true)
          await adUnit.start()

          expect(retrieveIcons).toHaveBeenCalledTimes(1)
          expect(retrieveIcons).toHaveBeenCalledWith(adUnit.vastChain)
          expect(addIcons).not.toHaveBeenCalled()
        })

        test('with vast icons, must render the icons', async () => {
          ;(addIcons as jest.Mock).mockClear()
          ;(retrieveIcons as jest.Mock).mockClear()

          const icons = [
            {
              height: 20,
              width: 20,
              xPosition: 'left',
              yPosition: 'top'
            }
          ]

          ;(retrieveIcons as jest.Mock).mockReturnValue(icons)
          mockCreativeAd[getAdIcons].mockReturnValue(true)
          adUnit = new VpaidAdUnit(vpaidChain, videoAdContainer)

          await adUnit.start()

          expect(retrieveIcons).toHaveBeenCalledTimes(1)
          expect(retrieveIcons).toHaveBeenCalledWith(adUnit.vastChain)
          expect(addIcons).toHaveBeenCalledTimes(1)
          expect(addIcons).toHaveBeenCalledWith(icons, {
            logger: adUnit.logger,
            onIconClick: expect.any(Function),
            onIconView: expect.any(Function),
            videoAdContainer
          })

          expect(mockDrawIcons).toHaveBeenCalledTimes(1)
        })
      })
    })
  })

  describe('with icons', () => {
    let mockCreativeAd: MockVpaidCreativeAd
    let adUnit: VpaidAdUnit

    beforeEach(() => {
      jest.useFakeTimers()

      mockCreativeAd = new MockVpaidCreativeAd()
      ;(initAd as jest.Mock).mockImplementation(() => {
        mockCreativeAd.emit(adLoaded)
      })

      mockCreativeAd.startAd.mockImplementationOnce(() => {
        mockCreativeAd.emit(adStarted)
      })

      mockCreativeAd.stopAd.mockImplementationOnce(() => {
        mockCreativeAd.emit(adStopped)
      })

      mockCreativeAd.resizeAd.mockImplementationOnce(() => {
        mockCreativeAd.emit(adSizeChange)
      })
      ;(loadCreative as jest.Mock).mockReturnValue(
        Promise.resolve(mockCreativeAd)
      )
      adUnit = new VpaidAdUnit(vpaidChain, videoAdContainer)
    })

    afterEach(() => {
      jest.clearAllTimers()
    })

    test('must remove the icons on ad finish', async () => {
      const icons = [
        {
          height: 20,
          width: 20,
          xPosition: 'left',
          yPosition: 'top'
        }
      ]

      ;(retrieveIcons as jest.Mock).mockReturnValue(icons)
      mockCreativeAd[getAdIcons].mockReturnValue(true)
      adUnit = new VpaidAdUnit(vpaidChain, videoAdContainer)

      await adUnit.start()

      await adUnit.cancel()

      expect(mockRemoveIcons).toHaveBeenCalledTimes(1)
    })

    test('must redraw the icons on adUnit resize', async () => {
      const icons = [
        {
          height: 20,
          width: 20,
          xPosition: 'left',
          yPosition: 'top'
        }
      ]

      ;(retrieveIcons as jest.Mock).mockReturnValue(icons)
      mockCreativeAd[getAdIcons].mockReturnValue(true)
      adUnit = new VpaidAdUnit(vpaidChain, videoAdContainer)

      await adUnit.start()

      expect(mockDrawIcons).toHaveBeenCalledTimes(1)

      await adUnit.resize(640, 480, 'normal')

      expect(mockDrawIcons).toHaveBeenCalledTimes(2)
    })

    test(`must emit '${iconClick}' event on click`, async () => {
      ;(addIcons as jest.Mock).mockClear()

      const icons = [
        {
          height: 20,
          width: 20,
          xPosition: 'left',
          yPosition: 'top'
        }
      ]

      ;(retrieveIcons as jest.Mock).mockReturnValue(icons)
      mockCreativeAd[getAdIcons].mockReturnValue(true)
      adUnit = new VpaidAdUnit(vpaidChain, videoAdContainer)

      await adUnit.start()

      expect(adUnit.icons).toBe(icons)

      const passedConfig = (addIcons as jest.Mock).mock.calls[0][1]

      const promise = new Promise((resolve) => {
        adUnit.on(iconClick, (...args) => {
          resolve(args)
        })
      })

      passedConfig.onIconClick(icons[0])

      const passedArguments = await promise

      expect(passedArguments).toEqual([
        {
          adUnit,
          data: icons[0],
          type: iconClick
        }
      ])
    })

    test(`must emit '${iconView}' event on view`, async () => {
      ;(addIcons as jest.Mock).mockClear()

      const icons = [
        {
          height: 20,
          width: 20,
          xPosition: 'left',
          yPosition: 'top'
        }
      ]

      ;(retrieveIcons as jest.Mock).mockReturnValue(icons)
      mockCreativeAd[getAdIcons].mockReturnValue(true)
      adUnit = new VpaidAdUnit(vpaidChain, videoAdContainer)
      await adUnit.start()

      expect(adUnit.icons).toBe(icons)

      const passedConfig = (addIcons as jest.Mock).mock.calls[0][1]

      const promise = new Promise((resolve) => {
        adUnit.on(iconView, (...args) => {
          resolve(args)
        })
      })

      passedConfig.onIconView(icons[0])

      const passedArguments = await promise

      expect(passedArguments).toEqual([
        {
          adUnit,
          data: icons[0],
          type: iconView
        }
      ])
    })

    test('must periodically redraw the icons while it has pendingIconRedraws', async () => {
      const icons = [
        {
          height: 20,
          width: 20,
          xPosition: 'left',
          yPosition: 'top'
        }
      ]

      ;(retrieveIcons as jest.Mock).mockReturnValue(icons)
      mockCreativeAd[getAdIcons].mockReturnValue(true)
      mockHasPendingRedraws.mockReturnValueOnce(true)
      mockHasPendingRedraws.mockReturnValueOnce(false)
      adUnit = new VpaidAdUnit(vpaidChain, videoAdContainer)

      await adUnit.start()
      expect(mockDrawIcons).toHaveBeenCalledTimes(1)
      jest.runOnlyPendingTimers()
      expect(mockDrawIcons).toHaveBeenCalledTimes(2)
      jest.runOnlyPendingTimers()
      expect(mockDrawIcons).toHaveBeenCalledTimes(2)
    })

    test('must avoid redraw the icons if the adUnit is finished', async () => {
      const icons = [
        {
          height: 20,
          width: 20,
          xPosition: 'left',
          yPosition: 'top'
        }
      ]

      ;(retrieveIcons as jest.Mock).mockReturnValue(icons)
      mockCreativeAd[getAdIcons].mockReturnValue(true)
      mockHasPendingRedraws.mockReturnValueOnce(true)
      mockHasPendingRedraws.mockReturnValueOnce(false)
      adUnit = new VpaidAdUnit(vpaidChain, videoAdContainer)

      await adUnit.start()
      expect(mockDrawIcons).toHaveBeenCalledTimes(1)
      await adUnit.cancel()

      expect(mockDrawIcons).toHaveBeenCalledTimes(1)

      jest.runAllTimers()
      expect(mockDrawIcons).toHaveBeenCalledTimes(1)
    })
  })

  describe('method', () => {
    let mockCreativeAd: MockVpaidCreativeAd
    let adUnit: VpaidAdUnit

    beforeEach(() => {
      mockCreativeAd = new MockVpaidCreativeAd()
      ;(initAd as jest.Mock).mockImplementation(() => {
        mockCreativeAd.emit(adLoaded)
      })

      mockCreativeAd.startAd.mockImplementationOnce(() => {
        mockCreativeAd.emit(adStarted)
      })

      mockCreativeAd.stopAd.mockImplementationOnce(() => {
        mockCreativeAd.emit(adStopped)
      })

      mockCreativeAd.resumeAd.mockImplementationOnce(() => {
        mockCreativeAd.emit(adPlaying)
      })

      mockCreativeAd.pauseAd.mockImplementationOnce(() => {
        mockCreativeAd.emit(adPaused)
      })

      mockCreativeAd.skipAd.mockImplementationOnce(() => {
        mockCreativeAd.emit(adSkipped)
      })

      mockCreativeAd.resizeAd.mockImplementationOnce(() => {
        mockCreativeAd.emit(adSizeChange)
      })
      ;(loadCreative as jest.Mock).mockReturnValue(
        Promise.resolve(mockCreativeAd)
      )
      adUnit = new VpaidAdUnit(vpaidChain, videoAdContainer)
    })

    describe('resume', () => {
      test('must call resumeAd', async () => {
        await adUnit.start()
        adUnit.resume()

        expect(adUnit.creativeAd?.[resumeAd]).toHaveBeenCalledTimes(1)
      })
    })

    describe('pause', () => {
      test('must call pauseAd', async () => {
        await adUnit.start()
        adUnit.pause()

        expect(adUnit.creativeAd?.[pauseAd]).toHaveBeenCalledTimes(1)
      })
    })

    describe('skip', () => {
      test('must call skipAd', async () => {
        await adUnit.start()
        adUnit.skip()

        expect(adUnit.creativeAd?.[skipAd]).toHaveBeenCalledTimes(1)
      })
    })

    describe('getVolume', () => {
      test('must call getAdVolume', async () => {
        await adUnit.start()
        mockCreativeAd.getAdVolume.mockClear()
        adUnit.getVolume()

        expect(mockCreativeAd.getAdVolume).toHaveBeenCalledTimes(1)
      })
    })

    describe('setVolume', () => {
      test('must call setAdVolume', async () => {
        await adUnit.start()
        mockCreativeAd.setAdVolume.mockClear()
        adUnit.setVolume(0.5)

        expect(mockCreativeAd.setAdVolume).toHaveBeenCalledTimes(1)
        expect(mockCreativeAd.setAdVolume).toHaveBeenCalledWith(0.5)
      })
    })

    describe('resize', () => {
      test('must call resizeAd', async () => {
        await adUnit.start()
        await adUnit.resize(100, 150, 'normal')

        expect(callAndWait).toHaveBeenCalledWith(
          mockCreativeAd,
          resizeAd,
          adSizeChange,
          100,
          150,
          'normal'
        )
      })
    })

    describe('cancel', () => {
      jest.useFakeTimers()

      test('must throw if the adUnit is finished', async () => {
        expect.assertions(1)
        await adUnit.start()
        await adUnit.cancel()

        try {
          await adUnit.cancel()
        } catch (error: any) {
          expect(error.message).toBe('VideoAdUnit is finished')
        }
      })

      test('must call stopAd and finish the adUnit', async () => {
        await adUnit.start()
        await adUnit.cancel()

        expect(mockCreativeAd.stopAd).toHaveBeenCalledTimes(1)
        expect(adUnit.isFinished()).toBe(true)
      })

      it('must finish the adUnit if the creative does not emit adStopped event after some time', async () => {
        mockCreativeAd.stopAd = jest.fn()
        mockCreativeAd.stopAd.mockImplementationOnce(() => {
          // empty on purpose
        })

        await adUnit.start()

        const cancelPromise = adUnit.cancel()

        expect(mockCreativeAd.stopAd).toHaveBeenCalledTimes(1)
        expect(adUnit.isFinished()).toBe(false)
        jest.runOnlyPendingTimers()
        await cancelPromise
        expect(adUnit.isFinished()).toBe(true)
      })
    })

    describe('onFinish', () => {
      test("must throw if you don't pass a callback function ", async () => {
        await adUnit.start()

        expect(() => adUnit.onFinish(undefined as any)).toThrow(
          'Expected a callback function'
        )
      })

      test('must be called if the ad unit gets canceled', async () => {
        const callback = jest.fn()

        adUnit.onFinish(callback)

        await adUnit.start()

        expect(callback).not.toHaveBeenCalled()

        await adUnit.cancel()

        expect(callback).toHaveBeenCalledTimes(1)
      })

      test('must be called once the ad unit completes', async () => {
        const callback = jest.fn()

        adUnit.onFinish(callback)

        await adUnit.start()

        expect(callback).not.toHaveBeenCalled()
        ;(adUnit.creativeAd as any).emit(adVideoComplete)
        ;(adUnit.creativeAd as any).emit(adStopped)

        expect(callback).toHaveBeenCalledTimes(1)
      })

      test('must be called once the ad unit stops', async () => {
        const callback = jest.fn()

        adUnit.onFinish(callback)

        await adUnit.start()

        expect(callback).not.toHaveBeenCalled()
        ;(adUnit.creativeAd as any).emit(adStopped)

        expect(callback).toHaveBeenCalledTimes(1)
      })

      test('must be called if the user closes the ad unit', async () => {
        const callback = jest.fn()

        adUnit.onFinish(callback)

        await adUnit.start()

        expect(callback).not.toHaveBeenCalled()
        ;(adUnit.creativeAd as any).emit(adUserClose)

        expect(callback).toHaveBeenCalledTimes(1)
      })
    })

    describe('onError', () => {
      test("must throw if you don't pass a callback function ", async () => {
        await adUnit.start()

        expect(() => adUnit.onError(undefined as any)).toThrow(
          'Expected a callback function'
        )
      })

      test('must call the callback if there is a problem starting the ad', async () => {
        expect.assertions(3)

        const handshakeVersionError = new Error(
          'Handshake version not supported'
        )
        const callback = jest.fn()

        adUnit.onError(callback)
        ;(handshake as jest.Mock).mockImplementationOnce(() => {
          throw handshakeVersionError
        })

        try {
          await adUnit.start()
        } catch (error) {
          expect(error).toBe(handshakeVersionError)
          expect(callback).toHaveBeenCalledTimes(1)
          expect(callback).toHaveBeenCalledWith(handshakeVersionError, {
            adUnit,
            vastChain: adUnit.vastChain
          })
        }
      })

      test('must call the callback if there is an error with the creativeAd', async () => {
        const callback = jest.fn()

        adUnit.onError(callback)

        await adUnit.start()

        expect(callback).not.toHaveBeenCalled()
        ;(adUnit.creativeAd as any).emit(adError)

        expect(callback).toHaveBeenCalledTimes(1)
        expect(callback).toHaveBeenCalledWith(
          expect.objectContaining({
            message: 'VPAID general error'
          }),
          {
            adUnit,
            vastChain: adUnit.vastChain
          }
        )
      })
    })
  })

  describe('creative vpaid event', () => {
    let mockCreativeAd: MockVpaidCreativeAd
    let adUnit: VpaidAdUnit

    beforeEach(() => {
      mockCreativeAd = new MockVpaidCreativeAd()
      ;(initAd as jest.Mock).mockImplementation(() => {
        mockCreativeAd.emit(adLoaded)
      })

      mockCreativeAd.startAd.mockImplementationOnce(() => {
        mockCreativeAd.emit(adStarted)
      })

      mockCreativeAd.stopAd.mockImplementationOnce(() => {
        mockCreativeAd.emit(adStopped)
      })
      ;(loadCreative as jest.Mock).mockReturnValue(
        Promise.resolve(mockCreativeAd)
      )
      adUnit = new VpaidAdUnit(vpaidChain, videoAdContainer)
    })

    for (const vpaidEvent of EVENTS.filter((event) => event !== adLoaded)) {
      test(`${vpaidEvent} must be emitted by the ad unit`, async () => {
        const callback = jest.fn()

        adUnit.on(vpaidEvent, callback)
        await adUnit.start()
        ;(adUnit.creativeAd as any).emit(vpaidEvent)

        expect(callback).toHaveBeenCalledWith({
          adUnit,
          type: vpaidEvent
        })
      })
    }

    ;[
      {
        vastEvt: skip,
        vpaidEvt: adSkipped
      },
      {
        vastEvt: adProgress,
        vpaidEvt: adDurationChange
      },
      {
        vastEvt: adProgress,
        vpaidEvt: adRemainingTimeChange
      },
      {
        vastEvt: creativeView,
        vpaidEvt: adStarted
      },
      {
        vastEvt: impression,
        vpaidEvt: adImpression
      },
      {
        vastEvt: skip,
        vpaidEvt: adSkipped
      },
      {
        vastEvt: start,
        vpaidEvt: adVideoStart
      },
      {
        vastEvt: firstQuartile,
        vpaidEvt: adVideoFirstQuartile
      },
      {
        vastEvt: midpoint,
        vpaidEvt: adVideoMidpoint
      },
      {
        vastEvt: thirdQuartile,
        vpaidEvt: adVideoThirdQuartile
      },
      {
        vastEvt: complete,
        vpaidEvt: adVideoComplete
      },
      {
        vastEvt: acceptInvitation,
        vpaidEvt: adUserAcceptInvitation
      },
      {
        vastEvt: adCollapse,
        vpaidEvt: adUserMinimize
      },
      {
        vastEvt: closeLinear,
        vpaidEvt: adUserClose
      },
      {
        vastEvt: pause,
        vpaidEvt: adPaused
      },
      {
        vastEvt: resume,
        vpaidEvt: adPlaying
      },
      {
        payload: {
          data: {
            playerHandles: true,
            url: 'https://test.example.com/clickThrough'
          }
        },
        vastEvt: clickThrough,
        vpaidEvt: adClickThru
      }
    ].forEach(({vpaidEvt: vpaidEvent, vastEvt: vastEvent, payload}) => {
      describe(vpaidEvent, () => {
        test(`must emit ${vastEvent} event`, async () => {
          const callback = jest.fn()

          adUnit.on(vastEvent, callback)
          await adUnit.start()
          ;(adUnit.creativeAd as any).emit(vpaidEvent, payload)
          expect(callback).toHaveBeenCalledWith({
            adUnit,
            type: vastEvent
          })
        })
      })
    })

    test('must emit start event once', async () => {
      const callback = jest.fn()

      adUnit.on(start, callback)
      await adUnit.start()
      ;(adUnit.creativeAd as any).emit(adVideoStart)
      ;(adUnit.creativeAd as any).emit(adVideoStart)
      ;(adUnit.creativeAd as any).emit(adVideoStart)
      expect(callback).toHaveBeenCalledTimes(1)
    })

    test('must fake `adVideoStarted` on adImpression if not called already', async () => {
      const callback = jest.fn()

      adUnit.on(start, callback)
      await adUnit.start()
      ;(adUnit.creativeAd as any).emit(adImpression)
      expect(callback).toHaveBeenCalledTimes(1)
      expect(callback).toHaveBeenCalledWith({
        adUnit,
        type: start
      })
    })

    test('must not fake `adVideoStarted` on adImpression if called already', async () => {
      const callback = jest.fn()

      adUnit.on(start, callback)
      await adUnit.start()
      ;(adUnit.creativeAd as any).emit(adVideoStart)
      expect(callback).toHaveBeenCalledTimes(1)
      ;(adUnit.creativeAd as any).emit(adImpression)
      expect(callback).toHaveBeenCalledTimes(1)
    })

    describe('paused', () => {
      it('must return true if the creative is paused and false otherwise', async () => {
        await adUnit.start()
        expect(adUnit.paused()).toBe(true)
        ;(adUnit.creativeAd as any).emit(adVideoStart)
        expect(adUnit.paused()).toBe(false)
        ;(adUnit.creativeAd as any).emit(adPaused)
        expect(adUnit.paused()).toBe(true)
        ;(adUnit.creativeAd as any).emit(adPlaying)
        expect(adUnit.paused()).toBe(false)

        await adUnit.cancel()
        expect(adUnit.paused()).toBe(true)
      })
    })

    describe(adClickThru, () => {
      let origOpen: any

      beforeEach(() => {
        origOpen = window.open
        window.open = jest.fn()
      })

      afterEach(() => {
        window.open = origOpen
      })

      test('must not open a new tab if `playerHandles` is false', async () => {
        await adUnit.start()
        ;(adUnit.creativeAd as any).emit(
          adClickThru,
          'https://test.example.com/clickUrl',
          undefined,
          false
        )
        expect(window.open).not.toHaveBeenCalled()
      })

      describe('with `playerHandles` true', () => {
        beforeEach(async () => {
          await adUnit.start()
        })

        test('if paused, must resume the adUnit', () => {
          ;(adUnit.creativeAd as any).emit(adVideoStart)
          ;(adUnit.creativeAd as any).emit(adPaused)
          expect(adUnit.paused()).toBe(true)
          ;(adUnit.creativeAd as any).emit(
            adClickThru,
            'https://test.example.com/clickUrl',
            undefined,
            true
          )
          expect(window.open).not.toHaveBeenCalled()
          expect(adUnit.creativeAd?.pauseAd).toHaveBeenCalledTimes(0)
          expect(adUnit.creativeAd?.resumeAd).toHaveBeenCalledTimes(1)
        })

        describe('if playing', () => {
          test('must pause the adUnit', () => {
            ;(adUnit.creativeAd as any).emit(adVideoStart)
            ;(adUnit.creativeAd as any).emit(
              adClickThru,
              'https://test.example.com/clickUrl',
              undefined,
              true
            )
            expect(window.open).toHaveBeenCalled()
            expect(adUnit.creativeAd?.pauseAd).toHaveBeenCalledTimes(1)
            expect(adUnit.creativeAd?.resumeAd).toHaveBeenCalledTimes(0)
          })

          test('must open the provided url in a new tab', () => {
            ;(adUnit.creativeAd as any).emit(adVideoStart)
            ;(adUnit.creativeAd as any).emit(
              adClickThru,
              'https://test.example.com/clickUrl',
              undefined,
              true
            )
            expect(window.open).toHaveBeenCalledTimes(1)
            expect(window.open).toHaveBeenCalledWith(
              'https://test.example.com/clickUrl',
              '_blank'
            )
          })

          test('must use vast clickthrough url if no url is provided', () => {
            ;(adUnit.creativeAd as any).emit(adVideoStart)
            ;(adUnit.creativeAd as any).emit(adClickThru, '', '', true)
            expect(window.open).toHaveBeenCalledTimes(1)
            expect(window.open).toHaveBeenCalledWith(
              'https://test.example.com/clickthrough',
              '_blank'
            )
          })
        })

        test('if paused, must not resume the adUnit and open the provided url in a new tab', () => {
          adUnit.pauseOnAdClick = false
          ;(adUnit.creativeAd as any).emit(adVideoStart)
          ;(adUnit.creativeAd as any).emit(adPaused)
          expect(adUnit.paused()).toBe(true)
          ;(adUnit.creativeAd as any).emit(
            adClickThru,
            'https://test.example.com/clickUrl',
            undefined,
            true
          )
          expect(window.open).toHaveBeenCalledTimes(1)
          expect(window.open).toHaveBeenCalledWith(
            'https://test.example.com/clickUrl',
            '_blank'
          )
          expect(adUnit.creativeAd?.pauseAd).not.toHaveBeenCalled()
          expect(adUnit.creativeAd?.resumeAd).not.toHaveBeenCalled()
        })

        test('if playing, must not pause the adUnit and open the provided url in a new tab', () => {
          adUnit.pauseOnAdClick = false
          ;(adUnit.creativeAd as any).emit(adVideoStart)
          expect(adUnit.paused()).toBe(false)
          ;(adUnit.creativeAd as any).emit(
            adClickThru,
            'https://test.example.com/clickUrl',
            undefined,
            true
          )
          expect(window.open).toHaveBeenCalledTimes(1)
          expect(window.open).toHaveBeenCalledWith(
            'https://test.example.com/clickUrl',
            '_blank'
          )
          expect(adUnit.creativeAd?.pauseAd).not.toHaveBeenCalled()
          expect(adUnit.creativeAd?.resumeAd).not.toHaveBeenCalled()
        })
      })
    })

    describe(adError, () => {
      const vastEvent = linearEvents.error

      test(`must emit ${vastEvent} event`, async () => {
        const callback = jest.fn()

        adUnit.on(vastEvent, callback)
        await adUnit.start()
        ;(adUnit.creativeAd as any).emit(adError)

        expect(callback).toHaveBeenCalledWith({
          adUnit,
          type: vastEvent
        })

        const {error} = adUnit

        expect(error?.message).toBe('VPAID general error')
        expect(error?.code).toBe(ErrorCode.VPAID_ERROR)
        expect(adUnit.errorCode).toBe(ErrorCode.VPAID_ERROR)
      })

      it('must use the emitted error if provided', async () => {
        const callback = jest.fn()

        adUnit.on(vastEvent, callback)
        await adUnit.start()

        const creativeError = new AdUnitError('test error')

        creativeError.code = ErrorCode.VAST_TOO_MANY_REDIRECTS
        ;(adUnit.creativeAd as any).emit(adError, creativeError)

        expect(callback).toHaveBeenCalledWith({
          adUnit,
          type: vastEvent
        })

        const {error} = adUnit

        expect(error).toBe(creativeError)
        expect(error?.code).toBe(ErrorCode.VAST_TOO_MANY_REDIRECTS)
        expect(adUnit.errorCode).toBe(ErrorCode.VAST_TOO_MANY_REDIRECTS)
      })
    })

    describe(adVolumeChange, () => {
      test(`must emit ${volumeChanged} event`, async () => {
        const callback = jest.fn()

        await adUnit.start()

        adUnit.on(volumeChanged, callback)
        adUnit.creativeAd?.setAdVolume(0)
        expect(callback).toHaveBeenCalledTimes(1)
        expect(callback).toHaveBeenCalledWith({
          adUnit,
          type: volumeChanged
        })
      })

      test(`must emit ${mute} event if it becomes muted`, async () => {
        const callback = jest.fn()

        adUnit.on(mute, callback)
        await adUnit.start()

        adUnit.creativeAd?.setAdVolume(0)
        expect(callback).toHaveBeenCalledTimes(1)
        expect(callback).toHaveBeenCalledWith({
          adUnit,
          type: mute
        })
      })

      test(`must emit ${unmute} event if it becomes unmuted`, async () => {
        const callback = jest.fn()

        adUnit.on(unmute, callback)
        await adUnit.start()

        adUnit.creativeAd?.setAdVolume(0)
        expect(callback).not.toHaveBeenCalled()
        adUnit.creativeAd?.setAdVolume(0.5)
        expect(callback).toHaveBeenCalledTimes(1)
        expect(callback).toHaveBeenCalledWith({
          adUnit,
          type: unmute
        })
      })

      test('must not emit any event on normal volume change', async () => {
        const callback = jest.fn()

        adUnit.on(unmute, callback)
        await adUnit.start()

        adUnit.creativeAd?.setAdVolume(0.5)
        adUnit.creativeAd?.setAdVolume(0.5)
        expect(callback).not.toHaveBeenCalled()
      })
    })

    describe('duration', () => {
      it('must return 0 if there is no creative', () => {
        expect(adUnit.duration()).toBe(0)
      })

      it('must return the creative duration', async () => {
        await adUnit.start()
        ;(adUnit.creativeAd?.[getAdDuration] as jest.Mock).mockReturnValue(30)

        expect(adUnit.duration()).toBe(30)
      })

      it('must return 0 if the creative returns a negative duration', async () => {
        await adUnit.start()
        ;(adUnit.creativeAd?.[getAdDuration] as jest.Mock).mockReturnValue(-1)

        expect(adUnit.duration()).toBe(0)
        ;(adUnit.creativeAd?.[getAdDuration] as jest.Mock).mockReturnValue(-2)

        expect(adUnit.duration()).toBe(0)
      })
    })

    describe('currentTime', () => {
      it('must return 0 if there is no creative', () => {
        expect(adUnit.currentTime()).toBe(0)
      })

      it('must return the creative current time', async () => {
        await adUnit.start()
        ;(adUnit.creativeAd?.[getAdDuration] as jest.Mock).mockReturnValue(30)
        ;(adUnit.creativeAd?.[getAdRemainingTime] as jest.Mock).mockReturnValue(
          25
        )

        expect(adUnit.currentTime()).toBe(5)
        ;(adUnit.creativeAd?.[getAdRemainingTime] as jest.Mock).mockReturnValue(
          5
        )

        expect(adUnit.currentTime()).toBe(25)
      })

      it('must return 0 if the creative returns a negative adRemainingTime', async () => {
        await adUnit.start()
        ;(adUnit.creativeAd?.[getAdDuration] as jest.Mock).mockReturnValue(-1)
        ;(adUnit.creativeAd?.[getAdRemainingTime] as jest.Mock).mockReturnValue(
          -1
        )

        expect(adUnit.currentTime()).toBe(0)
        ;(adUnit.creativeAd?.[getAdDuration] as jest.Mock).mockReturnValue(-2)
        ;(adUnit.creativeAd?.[getAdRemainingTime] as jest.Mock).mockReturnValue(
          -2
        )

        expect(adUnit.currentTime()).toBe(0)
      })
    })
  })
})
