import {linearEvents, ErrorCode, isVastErrorCode} from '../tracker'
import {acceptInvitation, adCollapse} from '../tracker/nonLinearEvents'
import {getClickThrough} from '../vastSelectors'
import type {VastChain, VpaidCreativeAdUnit} from '../types'
import {VideoAdContainer} from '../adContainer'
import {volumeChanged, adProgress} from './adUnitEvents'
import {loadCreative} from './helpers/vpaid/loadCreative'
import {
  adLoaded,
  adStarted,
  adStopped,
  adPlaying,
  adPaused,
  startAd,
  stopAd,
  resumeAd,
  pauseAd,
  skipAd,
  setAdVolume,
  getAdVolume,
  getAdDuration,
  resizeAd,
  adSizeChange,
  adError,
  adVideoComplete,
  adSkipped,
  EVENTS,
  adVolumeChange,
  adImpression,
  adVideoStart,
  adVideoFirstQuartile,
  adVideoMidpoint,
  adVideoThirdQuartile,
  adUserAcceptInvitation,
  adUserMinimize,
  adUserClose,
  adDurationChange,
  adRemainingTimeChange,
  adClickThru,
  getAdIcons,
  getAdRemainingTime
} from './helpers/vpaid/api'
import {waitFor} from './helpers/vpaid/waitFor'
import {callAndWait} from './helpers/vpaid/callAndWait'
import {handshake} from './helpers/vpaid/handshake'
import {initAd} from './helpers/vpaid/initAd'
import {AdUnitError} from './helpers/adUnitError'
import {VideoAdUnit, _protected, type VideoAdUnitOptions} from './VideoAdUnit'

const {
  complete,
  mute,
  unmute,
  skip,
  start,
  firstQuartile,
  pause,
  resume,
  impression,
  midpoint,
  thirdQuartile,
  clickThrough,
  error: errorEvent,
  closeLinear,
  creativeView
} = linearEvents

// NOTE some ads only allow one handler per event and we need to subscribe to the adLoaded to know the creative is loaded.
const VPAID_EVENTS = EVENTS.filter((event) => event !== adLoaded)

const DRAW_ICONS_TIMEOUT = 500
const WAIT_STOPPED_TIMEOUT = 3000

const _private = Symbol('_private')

const vpaidGeneralError = (payload: Error | unknown): AdUnitError => {
  const error: AdUnitError =
    payload instanceof Error ? payload : new AdUnitError('VPAID general error')

  if (!error.code || !isVastErrorCode(error.code)) {
    error.code = ErrorCode.VPAID_ERROR
  }

  return error
}

interface Private {
  evtHandler: Record<string, (...args: any[]) => void>
  handleVpaidEvent(event: string, ...args: any[]): void
  handleClickThrough(url: string): void
  getIcons(): void
  drawIcons(): Promise<void>
  muted: boolean
  paused: boolean
  videoStart?: boolean
  loadCreativePromise?: Promise<VpaidCreativeAdUnit>
}

/**
 * Options map to create a {@link VpaidAdUnit}
 */
export type VpaidAdUnitOptions = VideoAdUnitOptions

/**
 * This class provides everything necessary to run a Vpaid ad.
 */
export class VpaidAdUnit extends VideoAdUnit {
  private [_private]: Private = {
    evtHandler: {
      [adClickThru]: (url: string, _id: string, playerHandles: boolean) => {
        if (playerHandles) {
          this[_private].handleClickThrough(url)
        }

        this.emit(clickThrough, {
          adUnit: this,
          type: clickThrough
        })
      },
      [adDurationChange]: () => {
        this.emit(adProgress, {
          adUnit: this,
          type: adProgress
        })
      },
      [adError]: (payload: Error | unknown) => {
        this.error = vpaidGeneralError(payload)
        this.errorCode = this.error.code

        this[_protected].onErrorCallbacks.forEach((callback) =>
          callback(this.error, {
            adUnit: this,
            vastChain: this.vastChain
          })
        )

        this[_protected].finish()

        this.emit(errorEvent, {
          adUnit: this,
          type: errorEvent
        })
      },
      [adImpression]: () => {
        // NOTE: some ads forget to trigger the adVideoStart event. :(
        if (!this[_private].videoStart) {
          this[_private].handleVpaidEvent(adVideoStart)
        }

        this.emit(impression, {
          adUnit: this,
          type: impression
        })
      },
      [adPaused]: () => {
        this[_private].paused = true
        this.emit(pause, {
          adUnit: this,
          type: pause
        })
      },
      [adPlaying]: () => {
        this[_private].paused = false
        this.emit(resume, {
          adUnit: this,
          type: resume
        })
      },
      [adRemainingTimeChange]: () => {
        this.emit(adProgress, {
          adUnit: this,
          type: adProgress
        })
      },
      [adSkipped]: () => {
        this.cancel()
        this.emit(skip, {
          adUnit: this,
          type: skip
        })
      },
      [adStarted]: () => {
        this.emit(creativeView, {
          adUnit: this,
          type: creativeView
        })
      },
      [adStopped]: () => {
        this.emit(adStopped, {
          adUnit: this,
          type: adStopped
        })

        this[_protected].finish()
      },
      [adUserAcceptInvitation]: () => {
        this.emit(acceptInvitation, {
          adUnit: this,
          type: acceptInvitation
        })
      },
      [adUserClose]: () => {
        this.emit(closeLinear, {
          adUnit: this,
          type: closeLinear
        })

        this[_protected].finish()
      },
      [adUserMinimize]: () => {
        this.emit(adCollapse, {
          adUnit: this,
          type: adCollapse
        })
      },
      [adVideoComplete]: () => {
        this.emit(complete, {
          adUnit: this,
          type: complete
        })
      },
      [adVideoFirstQuartile]: () => {
        this.emit(firstQuartile, {
          adUnit: this,
          type: firstQuartile
        })
      },
      [adVideoMidpoint]: () => {
        this.emit(midpoint, {
          adUnit: this,
          type: midpoint
        })
      },
      [adVideoStart]: () => {
        if (!this[_private].videoStart) {
          this[_private].videoStart = true
          this[_private].paused = false
          this.emit(start, {
            adUnit: this,
            type: start
          })
        }
      },
      [adVideoThirdQuartile]: () => {
        this.emit(thirdQuartile, {
          adUnit: this,
          type: thirdQuartile
        })
      },
      [adVolumeChange]: () => {
        const volume = this.getVolume()

        this.emit(volumeChanged, {
          adUnit: this,
          type: volumeChanged
        })

        if (volume === 0 && !this[_private].muted) {
          this[_private].muted = true
          this.emit(mute, {
            adUnit: this,
            type: mute
          })
        }

        if (volume > 0 && this[_private].muted) {
          this[_private].muted = false
          this.emit(unmute, {
            adUnit: this,
            type: unmute
          })
        }
      }
    },
    handleVpaidEvent: (event, ...args) => {
      const handler = this[_private].evtHandler[event]

      if (handler) {
        handler(...args)
      }

      this.emit(event, {
        adUnit: this,
        type: event
      })
    },
    handleClickThrough: (url) => {
      if (this.paused() && this.pauseOnAdClick) {
        this.resume()

        return
      }

      const inlineAd = this.vastChain[0].ad
      const clickThroughUrl =
        typeof url === 'string' && url.length > 0
          ? url
          : inlineAd && getClickThrough(inlineAd)

      if (this.pauseOnAdClick) {
        this.pause()
      }

      if (clickThroughUrl) {
        window.open(clickThroughUrl, '_blank')
      }
    },
    getIcons: (): void => {
      if (this.creativeAd?.[getAdIcons]) {
        try {
          if (!this.creativeAd[getAdIcons]()) {
            delete this.icons
          }
        } catch {
          delete this.icons
        }
      }
    },
    drawIcons: async (): Promise<void> => {
      if (this.isFinished()) {
        return
      }

      await this[_protected].drawIcons?.()

      if (this[_protected].hasPendingIconRedraws?.() && !this.isFinished()) {
        setTimeout(this[_private].drawIcons, DRAW_ICONS_TIMEOUT)
      }
    },
    muted: false,
    paused: true
  }

  /** Ad unit type. Will be `VPAID` for VpaidAdUnit */
  public type = 'VPAID'

  /** Reference to the Vpaid Creative ad unit. Will be null before the ad unit starts. */
  public creativeAd?: VpaidCreativeAdUnit

  /**
   * Creates a {@link VpaidAdUnit}.
   *
   * @param vastChain The {@link VastChain} with all the {@link VastResponse}
   * @param videoAdContainer container instance to place the ad
   * @param options Options Map
   */
  constructor(
    vastChain: VastChain,
    videoAdContainer: VideoAdContainer,
    options: VpaidAdUnitOptions = {}
  ) {
    super(vastChain, videoAdContainer, options)

    this[_private].loadCreativePromise = loadCreative(
      vastChain,
      videoAdContainer
    )
  }

  /**
   * Starts the ad unit.
   *
   * @throws if called twice.
   * @throws if ad unit is finished.
   */
  public async start(): Promise<void> {
    this[_protected].throwIfFinished()

    if (this.isStarted()) {
      throw new Error('VpaidAdUnit already started')
    }

    try {
      this.creativeAd = (await this[_private]
        .loadCreativePromise) as VpaidCreativeAdUnit

      const adLoadedPromise = waitFor(this.creativeAd, adLoaded)

      for (const creativeEvent of VPAID_EVENTS) {
        this.creativeAd.subscribe(
          this[_private].handleVpaidEvent.bind(this, creativeEvent),
          creativeEvent
        )
      }

      this[_private].getIcons()

      handshake(this.creativeAd, '2.0')
      initAd(this.creativeAd, this.videoAdContainer, this.vastChain)

      await adLoadedPromise

      // if the ad timed out while trying to load the videoAdContainer will be destroyed
      if (this.videoAdContainer.isDestroyed()) {
        return
      }

      try {
        const {videoElement} = this.videoAdContainer

        if (videoElement.muted) {
          this[_private].muted = true
          this.setVolume(0)
        } else {
          this.setVolume(videoElement.volume)
        }

        await callAndWait(this.creativeAd, startAd, adStarted)

        if (this.icons) {
          await this[_private].drawIcons()
        }

        this[_protected].started = true
      } catch {
        this.cancel()
      }
    } catch (error) {
      this[_private].handleVpaidEvent(adError, error)
      throw error
    }
  }

  /**
   * Resumes a previously paused ad unit.
   *
   * @throws if ad unit is not started.
   * @throws if ad unit is finished.
   */
  public resume(): void {
    this.creativeAd?.[resumeAd]()
  }

  /**
   * Pauses the ad unit.
   *
   * @throws if ad unit is not started.
   * @throws if ad unit is finished.
   */
  public pause(): void {
    this.creativeAd?.[pauseAd]()
  }

  /**
   * Skip the ad unit.
   *
   * @throws if ad unit is not started.
   * @throws if ad unit is finished.
   */
  public skip(): void {
    this.creativeAd?.[skipAd]()
  }

  /**
   * Returns true if the ad is paused and false otherwise
   */
  public paused(): boolean {
    return this.isFinished() || this[_private].paused
  }

  /**
   * Sets the volume of the ad unit.
   *
   * @throws if ad unit is not started.
   * @throws if ad unit is finished.
   *
   * @param volume must be a value between 0 and 1;
   */
  public setVolume(volume: number): void {
    this.creativeAd?.[setAdVolume](volume)
  }

  /**
   * Gets the volume of the ad unit.
   *
   * @throws if ad unit is not started.
   * @throws if ad unit is finished.
   *
   * @returns the volume of the ad unit.
   */
  public getVolume(): number {
    if (!this.creativeAd) {
      return 0
    }

    return this.creativeAd[getAdVolume]()
  }

  /**
   * Cancels the ad unit.
   *
   * @throws if ad unit is finished.
   */
  public async cancel(): Promise<void> {
    this[_protected].throwIfFinished()

    try {
      const adStoppedPromise =
        this.creativeAd &&
        waitFor(this.creativeAd, adStopped, WAIT_STOPPED_TIMEOUT)

      this.creativeAd?.[stopAd]()
      await adStoppedPromise
    } catch {
      this[_protected].finish()
    }
  }

  /**
   * Returns the duration of the ad Creative or 0 if there is no creative.
   *
   * Note: if the user has engaged with the ad, the duration becomes unknown and it will return 0;
   *
   * @returns the duration of the ad unit.
   */
  public duration(): number {
    if (!this.creativeAd) {
      return 0
    }

    const duration = this.creativeAd[getAdDuration]()

    if (duration < 0) {
      return 0
    }

    return duration
  }

  /**
   * Returns the current time of the ad Creative or 0 if there is no creative.
   *
   * Note: if the user has engaged with the ad, the currentTime becomes unknown and it will return 0;
   *
   * @returns the current time of the ad unit.
   */
  public currentTime(): number {
    if (!this.creativeAd) {
      return 0
    }

    const remainingTime = this.creativeAd[getAdRemainingTime]()

    if (remainingTime < 0) {
      return 0
    }

    return this.duration() - remainingTime
  }

  /**
   * This method resizes the ad unit to fit the available space in the passed {@link VideoAdContainer}
   *
   * @throws if ad unit is not started.
   * @throws if ad unit is finished.
   *
   * @returns Promise that resolves once the unit was resized
   */
  public async resize(
    width: number,
    height: number,
    viewmode: string
  ): Promise<void> {
    await super.resize(width, height, viewmode)

    if (!this.creativeAd) {
      return
    }

    if (this.isStarted() && !this.isFinished()) {
      const slot = this.videoAdContainer.slotElement

      if (slot) {
        slot.style.height = `${height}px`
        slot.style.width = `${width}px`
      }
    }

    return callAndWait(
      this.creativeAd,
      resizeAd,
      adSizeChange,
      width,
      height,
      viewmode
    )
  }
}
