/**
 * --------------------------------------------------------------------------
 * consent.js
 * Tracking Consent Overlay.
 * --------------------------------------------------------------------------
 */

import { LitElement, html } from 'lit'
import Modal from 'bootstrap/js/src/modal.js'
import Backdrop from 'bootstrap/js/src/util/backdrop.js'
import EventHandler from 'bootstrap/js/src/dom/event-handler.js'
import SelectorEngine from 'bootstrap/js/src/dom/selector-engine.js'
import TemplateFactory from 'bootstrap/js/src/util/template-factory.js'
import { escapeHtml } from './helpers.js'

import styles from './templates/uoe-consent-css.js'
import tplErrors from './templates/uoe-consent-errors.js'
import tplLid from './templates/uoe-consent-lid.js'
import tplPreferences from './templates/uoe-consent-preferences.js'
import tplIntro from './templates/uoe-consent-intro.js'

/**
 * Constants
 */
const EdGELVersion = '5.3.1'

const NAME = 'consent'
const DATA_KEY = 'uoe.consent'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
const VERSION = 4
const EVENT_MODAL_SHOWN = 'shown.bs.modal'

const CLASS_NAME_COOKIE_JAR = 'uoe-jar'
const CLASS_NAME_COOKIE_LID = 'uoe-lid'
const CLASS_NAME_SHOW = 'show'
const CLASS_NAME_COLLAPSING = 'uoe-collapsing'
const CLASS_NAME_COLLAPSE = 'uoe-collapse'
const CLASS_NAME_BACKDROP = 'uoe-backdrop'
const CLASS_NAME_LINK = 'uoe-link'
const TRANSITION_DURATION = 410

const COOKIE_DOMAIN = '.ed.ac.uk'
const PREFS_HEADING = 'Cookies on the University of Edinburgh website'
const NECESSARY_URL = 'https://www.ed.ac.uk/about/website/privacy/cookies-we-use/types-of-cookies/strictly-necessary-cookies'
const PERFORMANCE_URL = 'https://www.ed.ac.uk/about/website/privacy/cookies-we-use/types-of-cookies/performance-cookies'
const ADVERTS_URL = 'https://www.ed.ac.uk/about/website/privacy/cookies-we-use/types-of-cookies/advertising-cookies'
const COOKIE_NAME = `_uoeCookiePrefsV${VERSION}`
const COOKIE_EXPIRES_DAYS = 365
const COOKIE_EXPIRES = COOKIE_EXPIRES_DAYS * 24 * 60 * 60 * 1000
const DEFUNCT_COOKIES = [
  '_uoeCookiePrefs',
  '_uoeCookiePrefsV1',
  '_uoeCookiePrefsV2',
  '_uoeCookiePrefsV3'
]

const SELECTOR_BTN_SAVE = 'button[data-consent-save]'
const SELECTOR_BTN_ACCEPT = 'button[data-consent-accept]'
const SELECTOR_BTN_REJECT = 'button[data-consent-reject]'
const SELECTOR_BTN_SETTINGS = 'button[data-consent-settings]'
const SELECTOR_CHOICES = 'input[data-consent-choice]'
const SELECTOR_CHOICES_SELECTED = `${SELECTOR_CHOICES}:checked`
const SELECTOR_CHOICE_ACTIVE_AREA = '.uoe-check'
const SELECTOR_DIALOG = '.uoe-container'

const TPL_SELECTOR_HEADING = 'h2.uoe-heading'
const TPL_SELECTOR_ERROR_LIST = 'ul'
const TPL_SELECTOR_NECESSARY_LINK = '.consent-necessary-url'
const TPL_SELECTOR_PERFORMANCE_LINK = '.consent-performance-url'
const TPL_SELECTOR_ADVERTS_LINK = '.consent-adverts-url'

const EVENT_CLICK = `click${EVENT_KEY}`
const EVENT_CHANGE = `change${EVENT_KEY}`
const EVENT_CHANGED = `changed${EVENT_KEY}`
const EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}`

const Permissions = {
  necessary: {
    mandatory: true,
    isDefault: true
  },
  performance: {
    mandatory: false,
    isDefault: false
  },
  advertising: {
    mandatory: false,
    isDefault: false
  }
}

const Choices = {
  advertising: {
    permissions: ['advertising'],
    yes: 'I\'m OK with personalised ads',
    no: 'I do not want to see personalised ads'
  },
  performance: {
    permissions: ['performance'],
    yes: 'I\'m OK with analytics data being recorded',
    no: 'I do not want my website usage included in analytics data'
  }
}

const Default = {
  cookieDomain: COOKIE_DOMAIN,
  prefsHeading: PREFS_HEADING,
  necessaryUrl: NECESSARY_URL,
  performanceUrl: PERFORMANCE_URL,
  advertsUrl: ADVERTS_URL,
  unlocked: false,
  disabled: false
}

const DefaultType = {
  cookieDomain: 'string',
  prefsHeading: 'string',
  necessaryUrl: 'string',
  performanceUrl: 'string',
  advertsUrl: 'string',
  unlocked: 'boolean',
  disabled: 'boolean'
}

/**
 * Class definition
 */

class Consent extends Modal {
  constructor(element, config) {
    super(element, {
      ...config,
      backdrop: 'static',
      focus: true,
      keyboard: false
    })

    this._cookieJar = this._element
    this._cookieLid = config.lid
    this._returnFocus = document.activeElement

    this._loaded = false
    this._errors ||= []

    if (this._config.disabled) {
      this.dispose()
      return
    }

    this._templateFactories = {}
    this._templateConfigs = {
      errors: {
        sanitize: false,
        html: true,
        template: tplErrors,
        content: {
          [TPL_SELECTOR_ERROR_LIST]: this._renderErrors.bind(this)
        }
      },
      lid: {
        sanitize: false,
        html: false,
        template: tplLid,
        content: {}
      },
      preferences: {
        sanitize: false,
        html: true,
        template: tplPreferences,
        content: {
          [TPL_SELECTOR_HEADING]: escapeHtml(this._config.prefsHeading),
          [TPL_SELECTOR_NECESSARY_LINK]: this._renderNecessaryLink.bind(this),
          [TPL_SELECTOR_PERFORMANCE_LINK]: this._renderPerformanceLink.bind(this),
          [TPL_SELECTOR_ADVERTS_LINK]: this._renderAdvertsLink.bind(this)
        }
      },
      intro: {
        sanitize: false,
        html: true,
        template: tplIntro,
        content: {
          [TPL_SELECTOR_HEADING]: escapeHtml(this._config.prefsHeading)
        }
      }
    }

    this._selections = {}
    this._choices = { ...Choices }

    // Add the cookie lid
    this._cookieLid.append(this._getTemplateFactory('lid').toHtml())

    // If we are on the wrong domain for the cookie consent to work, don't
    // block access to the page. Needed for local development.
    if (!`.${globalThis.location.hostname}`.includes(`.${this._config.cookieDomain}`)) {
      this._config.unlocked = true
    }

    this._displayBanner(false)
    this._addConsentEventListeners()
    this._notifyChoices()
  }

  // Getters
  static get Default() {
    return Default
  }

  static get DefaultType() {
    return DefaultType
  }

  static get NAME() {
    return NAME
  }

  static get VERSION() {
    const bsVersion = super.VERSION
    return `${bsVersion}+edgel-${EdGELVersion}`
  }

  // Public

  allowed(permission) {
    const prefs = this._getSelections()
    return prefs[permission]
  }

  allowedList() {
    const prefs = this._getSelections()
    const allowed = []

    for (const pref of Object.getOwnPropertyNames(prefs)) {
      if (prefs[pref]) {
        allowed.push(pref)
      }
    }

    return allowed.sort().join(',')
  }

  bannerRequired() {
    return !(this.hasSavedSettings() || this.isUnlocked())
  }

  hasSavedSettings() {
    return this._loaded
  }

  // Private
  _configAfterMerge(config) {
    if (config.cookieDomain.startsWith('.')) {
      config.cookieDomain = config.cookieDomain.slice(1)
    }

    this._errors ||= []
    this._validateUrl(config.necessaryUrl, config.cookieDomain)
    this._validateUrl(config.performanceUrl, config.cookieDomain)
    this._validateUrl(config.advertsUrl, config.cookieDomain)

    return config
  }

  _validateUrl(testUrl, cookieDomain) {
    // Validate URL
    let url
    try {
      url = new URL(testUrl)
    } catch {
      this._setError(`Invalid URL provided in "${testUrl}"`)
      return
    }

    if (url.protocol !== 'https:') {
      this._setError(`Only https protocol accepted for "${testUrl}"`)
    }

    const domain = `.${url.hostname}`
    if (!domain.endsWith(`.${cookieDomain}`)) {
      this._setError(`Mismatch between cookie domain and link hostname "${testUrl}"`)
    }
  }

  _notifyChoices() {
    const args = {
      permissions: { ...Permissions },
      allowed: permission => this.allowed(permission),
      allowedList: this.allowedList()
    }

    EventHandler.trigger(document, EVENT_CHANGED, args)
  }

  _addConsentEventListeners() {
    EventHandler.on(this._element, EVENT_MODAL_SHOWN, () => {
      // Ensure focus is moved into dialog.  This is delayed to ensure everything
      // is fully rendered and make sure screen readers pick up on it.
      this._focustrap._handleFocusin({})
    })

    EventHandler.on(this._cookieLid, EVENT_CLICK, event => {
      event.preventDefault()
      event.stopPropagation()

      // Reload preferences from _uoeCookiePrefsV* cookie, if present.
      this._load()
      this._editPrefs()
      this._returnFocus = event.target
    })

    EventHandler.on(this._cookieJar, EVENT_CLICK, event => {
      if (event.target.matches(SELECTOR_BTN_SAVE)) {
        event.preventDefault()
        event.stopPropagation()

        this._savePrefs({})
        // Re-open the banner; it should close to the lid
        this._displayBanner()
      }

      if (event.target.matches(SELECTOR_BTN_REJECT)) {
        event.preventDefault()
        event.stopPropagation()

        this._savePrefs({ defaults: true })
        // Re-open the banner; it should close to the lid
        this._displayBanner()
      }

      if (event.target.matches(SELECTOR_BTN_ACCEPT)) {
        event.preventDefault()
        event.stopPropagation()

        this._savePrefs({ all: true })
        // Re-open the banner; it should close to the lid
        this._displayBanner()
      }

      if (event.target.matches(SELECTOR_BTN_SETTINGS)) {
        event.preventDefault()
        event.stopPropagation()

        this._editPrefs()
      }

      if (event.target.matches(SELECTOR_CHOICE_ACTIVE_AREA)) {
        event.preventDefault()
        event.stopPropagation()

        // clicked on surround div, so click on the radio button
        const checkbox = SelectorEngine.findOne(SELECTOR_CHOICES, event.target)
        checkbox.checked = true
        this._enableSaveButton()
      }
    })

    EventHandler.on(this._cookieJar, EVENT_CHANGE, SELECTOR_CHOICES, () => {
      this._enableSaveButton()
    })
  }

  _enableSaveButton() {
    const selections = SelectorEngine.find(SELECTOR_CHOICES_SELECTED, this._cookieJar)
    const selectionsMade = new Set(selections.map(e => e.name.slice(e.name.lastIndexOf('-') + 1)))
    const selectionsAvailable = Object.entries(Permissions)
      .filter(p => !p[1].mandatory)
      .map(p => p[0])

    const selectionsComplete = selectionsAvailable.every(
      o => selectionsMade.has(o) || Permissions[o].mandatory
    )

    SelectorEngine.findOne(SELECTOR_BTN_SAVE, this._cookieJar).disabled = selectionsComplete ? '' : 'disabled'
  }

  _applyStyle(element, styles) {
    for (const prop of Object.getOwnPropertyNames(styles)) {
      element.style[prop] = styles[prop]
    }
  }

  _showLid(animate) {
    const element = this._cookieLid

    if (!animate) {
      element.classList.add(CLASS_NAME_SHOW)
    }

    if (!element.classList.contains(CLASS_NAME_SHOW)) {
      const { paddingTop, paddingBottom } = globalThis.getComputedStyle(element)
      element.classList.remove(CLASS_NAME_COLLAPSE)
      element.classList.add(CLASS_NAME_COLLAPSING)

      setTimeout(function (element) {
        element.classList.remove(CLASS_NAME_COLLAPSING)
        element.classList.add(CLASS_NAME_COLLAPSE, CLASS_NAME_SHOW)
        element.style.height = ''
        element.style.paddingTop = ''
        element.style.paddingBottom = ''
      }.bind(this, element), TRANSITION_DURATION)

      setTimeout(function (element, paddingTop, paddingBottom) {
        element.style.height = `${element.scrollHeight}px`
        element.style.paddingTop = paddingTop
        element.style.paddingBottom = paddingBottom
      }.bind(this, element, paddingTop, paddingBottom), 10)
    }
  }

  _hideLid(animate) {
    const element = this._cookieLid

    if (!animate) {
      element.classList.remove(CLASS_NAME_SHOW)
    }

    if (element.classList.contains(CLASS_NAME_SHOW)) {
      element.style.height = `${element.scrollHeight}px`
      element.classList.remove(CLASS_NAME_COLLAPSE, CLASS_NAME_SHOW)
      element.classList.add(CLASS_NAME_COLLAPSING)

      setTimeout(function (element) {
        element.classList.remove(CLASS_NAME_COLLAPSING)
        element.classList.add(CLASS_NAME_COLLAPSE)
      }.bind(this, element), TRANSITION_DURATION)

      setTimeout(function (element) {
        element.style.height = ''
      }.bind(this, element), 10)
    }
  }

  _openJar(animate) {
    if (!this._cookieJar || !this._cookieLid) {
      return
    }

    this.show(this)
    this._hideLid(animate)

    // Reset the focus so we don't fall out the focus trap when changing jar content.
    if (!this._isTransitioning) {
      this._cookieJar.focus()
    }
  }

  _initializeBackDrop() {
    return new Backdrop({
      className: CLASS_NAME_BACKDROP,
      isVisible: true,
      isAnimated: true,
      rootElement: this._config.renderRoot
    })
  }

  _showElement(relatedTarget) {
    super._showElement(relatedTarget)
    // Move the Jar back into the custom element (!)
    this._config.renderRoot.append(this._element)
  }

  dispose() {
    if (this._cookieJar) {
      EventHandler.off(this._cookieJar, EVENT_CLICK)
      EventHandler.off(this._cookieJar, EVENT_CHANGE, SELECTOR_CHOICES)
      this._cookieJar.remove()
    }

    if (this._cookieLid) {
      EventHandler.off(this._cookieLid, EVENT_CLICK)
      this._cookieLid.remove()
    }

    super.dispose()
  }

  _closeJar(animate) {
    if (!this._cookieJar || !this._cookieLid) {
      return
    }

    this._showLid(animate)
    this.hide()
    // Return focus for accessibility
    this._returnFocus.focus()
  }

  _getSelections() {
    const values = {}

    for (const p of Object.getOwnPropertyNames(Permissions)) {
      values[p] = p in this._selections ? this._selections[p] : false
    }

    return values
  }

  _reset() {
    this._loaded = false
    this._selections = {}
  }

  _load() {
    this._reset()
    this._selections = this._readCookie(this._config.cookieDomain)
    this._loaded = true

    // If we are missing any mandatory values, we don't have a valid load.
    for (const perm of Object.getOwnPropertyNames(Permissions)) {
      if (Permissions[perm].mandatory && !(perm in this._selections)) {
        this._loaded = false
      }
    }
  }

  _readCookie(update = true) {
    const decodedCookies = decodeURIComponent(document.cookie).split(';')
    let json = ''

    for (let cookie of decodedCookies) {
      cookie = cookie.trim()
      if (cookie.startsWith(`${COOKIE_NAME}=`)) {
        json = cookie.slice(COOKIE_NAME.length + 1)
      }
    }

    if (json !== '') {
      let requiresUpdate = false
      let parsed = {}

      try {
        // If we have raw JSON string encode this since the value can cause
        // problems with other systems: I180531-0649
        if (json.startsWith('{')) {
          requiresUpdate = true
        } else {
          json = globalThis.atob(json)
        }

        parsed = JSON.parse(json)
      } catch { /* leave parsed={} */ }

      if (requiresUpdate && update) {
        this._writeCookie(parsed)
      }

      return parsed
    }

    return {}
  }

  _writeCookie() {
    if (!this._loaded) {
      return
    }

    const d = new Date()
    d.setTime(d.getTime() + COOKIE_EXPIRES)
    const expires = `expires=${d.toUTCString()}`

    const cValue = globalThis.btoa(JSON.stringify(this._selections))
    document.cookie = `${COOKIE_NAME}=${cValue};${expires};domain=${this._config.cookieDomain};path=/`

    // Confirm that the write worked by doing straight comparison of btoa values
    const readBack = globalThis.btoa(JSON.stringify(this._readCookie(false)))
    if (readBack !== cValue) {
      this._setError(`Cookie setting write failed. Check the cookie domain (${this._config.cookieDomain}).`)
      this._loaded = false
    }

    // Notify subscribers that a change has happened.
    this._notifyChoices()
  }

  _expireDefunct() {
    const d = new Date()
    d.setTime(d.getTime() - (60 * 60 * 1000)) // set expired date 1 hour ago
    const expires = `expires=${d.toUTCString()}`

    for (const defunct of DEFUNCT_COOKIES) {
      document.cookie = `${defunct}=;${expires};domain=${this._config.cookieDomain};path=/`
    }
  }

  _savePrefs({ defaults, all }) {
    this._reset()
    // Wipe all previously stored cookie settings.
    this._expireDefunct()

    for (const key of Object.getOwnPropertyNames(Permissions)) {
      this._selections[key] = Permissions[key].mandatory
    }

    if (all) {
      for (const key of Object.getOwnPropertyNames(Permissions)) {
        this._selections[key] = true
      }
    } else if (defaults) {
      for (const key of Object.getOwnPropertyNames(Permissions)) {
        this._selections[key] = Boolean(this._selections[key]) || Permissions[key].isDefault
      }
    } else {
      const items = SelectorEngine.find(SELECTOR_CHOICES_SELECTED, this._cookieJar)
      for (const item of items) {
        this._selections[item.name] = item.value === 'yes'
      }
    }

    this._loaded = true
    this._writeCookie()
  }

  // Detect whether we are on a "further information" page.  If we are we have to hide the
  // overlay to allow users to interact with the page.
  isUnlocked() {
    if (this._config.unlocked) {
      return true
    }

    const urls = [
      this._config.necessaryUrl,
      this._config.performanceUrl,
      this._config.advertsUrl
    ]

    return urls.includes(globalThis.location.href)
  }

  _introScreen() {
    this._cookieJar.innerHTML = ''
    this._cookieJar.append(this._getTemplateFactory('intro').toHtml())
    this._dialog = SelectorEngine.findOne(SELECTOR_DIALOG, this._cookieJar)
    this._openJar(true)
  }

  _editPrefs() {
    this._cookieJar.innerHTML = ''
    this._cookieJar.append(this._getTemplateFactory('preferences').toHtml())
    this._dialog = SelectorEngine.findOne(SELECTOR_DIALOG, this._cookieJar)

    let allOptionsSelected = false
    const selections = this._getSelections()

    if (this._loaded) {
      const selectionsMade = Object.getOwnPropertyNames(selections)
      const selectionsAvailable = Object.getOwnPropertyNames(this._choices).filter(c => c.options)
      allOptionsSelected = selectionsAvailable.every(o => selectionsMade.includes(o))

      // Get a list of selected object keys
      const active = new Set(Object.entries(selections)
        .filter(a => {
          return a[1]
        })
        .map(a => {
          return a[0]
        }))

      for (const option of Object.getOwnPropertyNames(this._choices)) {
        const selected = active.has(option) ? 'yes' : 'no'
        const input = SelectorEngine.findOne(`${SELECTOR_CHOICES}[name=${option}][value=${selected}]`, this._cookieJar)
        if (input) {
          input.checked = true
        }
      }
    }

    const button = SelectorEngine.findOne('button', this._cookieJar)
    button.disabled = !allOptionsSelected

    this._openJar(true)
  }

  _setError(error) {
    // eslint-disable-next-line no-console
    console.error(error)
    this._errors.push(error)
  }

  _displayBanner(animate = true) {
    // Reload preferences from _uoeCookiePrefsV* cookie, if present.
    this._load()

    if (this._errors.length) {
      this._cookieJar.innerHTML = ''
      this._cookieJar.append(this._getTemplateFactory('errors').toHtml())
      this._dialog = SelectorEngine.findOne(SELECTOR_DIALOG, this._cookieJar)

      this._openJar(animate)
    } else if (this.bannerRequired()) {
      if (this.hasSavedSettings()) {
        this._editPrefs()
      } else {
        this._introScreen()
      }
    } else {
      this._closeJar(animate)
    }
  }

  _getTemplateFactory(tpl) {
    this._templateFactories[tpl] ||= new TemplateFactory(this._templateConfigs[tpl])

    return this._templateFactories[tpl]
  }

  _renderNecessaryLink() {
    const { necessaryUrl } = this._config

    if (necessaryUrl) {
      return `<a class="${CLASS_NAME_LINK}" href="${encodeURI(necessaryUrl)}">Read about necessary cookies</a>`
    }
  }

  _renderPerformanceLink() {
    const { performanceUrl } = this._config

    if (performanceUrl) {
      return `<a class="${CLASS_NAME_LINK}" href="${encodeURI(performanceUrl)}">Read about analytics cookies</a>`
    }
  }

  _renderAdvertsLink() {
    const { advertsUrl } = this._config

    if (advertsUrl) {
      return `<a class="${CLASS_NAME_LINK}" href="${encodeURI(advertsUrl)}">Read about marketing cookies</a>`
    }
  }

  _renderErrors() {
    return this._errors.map(e => `<li>${escapeHtml(e)}</li>`).join('')
  }

  // DEPRECATED: Allow extra departmental permissions to be added to the banner.
  addPermission() {
    // eslint-disable-next-line no-console
    console.warn('REMOVED: Cookie Consent addPermission can no longer be used to add extra departmental permissions')
  }
}

/**
 * Data API implementation
 * Add EdGEL cookie consent banner to page
 */

class UoEConsent extends LitElement {
  static shadowRootOptions = { mode: 'closed' }
  static styles = styles

  firstUpdated(_changedProperties) {
    super.firstUpdated(_changedProperties)

    const jar = this.renderRoot.querySelector(`.${CLASS_NAME_COOKIE_JAR}`)
    const lid = this.renderRoot.querySelector(`.${CLASS_NAME_COOKIE_LID}`)
    Consent.getOrCreateInstance(jar, { lid, renderRoot: this.renderRoot })
  }

  render() {
    // We include the fade class here to ensure deferred action for the modal shown event
    // which we pick up on to move focus into the dialog for accessibility.
    const jarClass = `${CLASS_NAME_COOKIE_JAR} ${CLASS_NAME_COLLAPSE} fade`
    const lidClass = `${CLASS_NAME_COOKIE_LID} ${CLASS_NAME_COLLAPSE}`
    return html`
      <div class=${jarClass} tabindex="-1" aria-describedby="uoe-consent-desc"></div>
      <div class=${lidClass}></div>
    `
  }
}

EventHandler.on(globalThis, EVENT_LOAD_DATA_API, () => {
  if (globalThis.EdGEL?.Consent?.Default?.disabled) {
    return
  }

  const hasFont = [...document.fonts].some(f => f.family === 'Source Sans Pro')
  if (!hasFont) {
    const fontTag = document.createElement('link')
    fontTag.rel = 'stylesheet'
    fontTag.href = 'https://fonts.googleapis.com/css2?family=Source+Sans+Pro:wght@300;400;700&display=swap'
    document.head.insertBefore(fontTag, null)
    // eslint-disable-next-line no-console
    console.warn('Source Sans Pro font not found in page.  Loading from Google Fonts...')
  }

  if (!globalThis.customElements.get('uoe-consent')) {
    globalThis.customElements.define('uoe-consent', UoEConsent)
  }

  const uoeConsent = document.createElement('uoe-consent')
  document.body.insertBefore(uoeConsent, null)
})

export default Consent
