/* global location, customElements, localStorage, crypto */
import { getConsent } from '#amidala/main/shared/simple-consent-api.js'
import { isPublish } from '#amidala/main/shared/wcm-mode.js'
import deepSelector from '#amidala/main/shared/deep-selector.js'

const DOWNLOADABLE_EXTENSIONS = ['zip', 'pdf', 'docx', 'xlsx', 'pptx', 'txt', 'csv', 'xps']
const SCROLL_DEBOUNCE = 500
const EXPERIMENT_SELECTOR = '[data-experiment]'
const USER_ID_STORAGE_KEY = 'aa_user_id'
const YT_ORIGINS = new Set(['https://www.youtube.com', 'https://www.youtube-nocookie.com'])
const MILESTONES = { event15: 90, event14: 75, event13: 50, event12: 25, event11: 1 }
const FORM_ERROR_TOLERANCE = 100
const DEFAULT_SAMPLE_RATE = 1
const SAMPLE_RATE = Number.parseFloat(localStorage.getItem('amidala_sample_rate') ?? DEFAULT_SAMPLE_RATE)
const SKIP_TRACKING = SAMPLE_RATE < Math.random() // eslint-disable-line sonarjs/pseudo-random

// -----------------------------------------------------------------------------
// Private fields
// -----------------------------------------------------------------------------

let isInitialised = false
let userId
let newOrRepeat
let apiUrl
let pageViewParams = {}
let searchContext = []
let pageViewCount = 0
let scrolled = 0

// -----------------------------------------------------------------------------
// Adobe Analytics
// -----------------------------------------------------------------------------

// Using GET API as per the reference:
// https://github.com/AdobeDocs/analytics.en/blob/main/help/implement/validate/query-parameters.md
export async function send (params = {}) {
  if (!apiUrl || !isPublish || SKIP_TRACKING) {
    return null
  }

  const [resolvedUserId, resolvedNewOrRepeat] = getUserId()

  // Set everything up for a page view.
  if (!params.events) {
    const pageUrl = getUrl(location.href)
    const pageUrlWithoutParams = pageUrl.origin + pageUrl.pathname
    const usp = pageUrl.searchParams
    const campaign = pageUrl.search.includes('utm_')
      ? ['medium', 'source', 'campaign', 'term', 'content'].map(p => usp.get(`utm_${p}`) || 'undefined').join(':')
      : null

    const isVanity = pageUrl.hash.startsWith('#vanity-')
    const referrer = pageViewParams.v26 || document.referrer

    searchContext = [] // Reset search context.
    pageViewParams = {
      // Standard properties.
      bh: window.innerHeight,
      bw: window.innerWidth,
      c: window.screen.colorDepth,
      ce: document.characterSet,
      ch: document.title,
      g: pageUrl.href,
      r: referrer || 'No Referrer',
      s: `${window.screen.availWidth}x${window.screen.availHeight}`,
      server: pageUrl.host,
      vid: resolvedUserId,

      // Context evars.
      v1: document.documentElement.lang.split('-').reverse().join('|'), // Locale.
      v0: campaign,
      v21: params.pageName,
      v22: pageViewParams.pageName, // Get the previous page name.
      v26: pageUrl.href, // Page URL.
      v27: pageUrlWithoutParams,
      v29: pageUrl.host,
      v32: pageUrl.hash.startsWith('#vanity-') ? pageUrlWithoutParams : null, // Redirect URL.
      v34: getTrafficOrigin(referrer),
      v45: campaign,
      v47: navigator.userAgent,
      v49: resolvedNewOrRepeat,
      v53: new Date().toISOString(),
      v87: usp.get('gclid'),
      v116: pageViewCount++,
      v117: getColorScheme(),
      v118: document.querySelector(EXPERIMENT_SELECTOR)?.dataset.experiment,

      // Mapped properties.
      c1: 'D=v1', // Locale
      c9: 'D=v9', // Share medium
      c11: 'D=v11', // Download file name
      c12: 'D=v12', // Form name
      c13: 'D=v13', // Last interacted form field
      c14: 'D=v14', // Time take to complete the form
      c16: 'D=v16', // Onsite search term
      c17: 'D=v17', // Search type
      c18: 'D=v18', // Search result count
      c22: 'D=v22', // Previous page ID
      c24: 'D=v24', // Subsection
      c25: 'D=v25', // Vanity URL
      c27: 'D=v27', // Page URL without params
      c53: 'D=v53', // Timestamp
      c69: 'D=v69' // Consent status
    }

    if (isVanity) {
      pageViewParams.v25 = atob(pageUrl.hash.replace(/^#vanity-(.*)$/, '$1'))
      pageViewParams.events = 'event17'
    }
  }

  // Use previously saved page view params except for v69, which can change at any time.
  params = { ...pageViewParams, v69: getConsentStatus(), ...params }

  // Delete null or undefined parameters. Otherwise, URLSearchParams.toString() will serialize them.
  for (const [k, v] of Object.entries(params)) {
    if (v === null || v === undefined) {
      delete params[k]
    }
  }

  return fetch(`${apiUrl}?${new URLSearchParams(params).toString()}`)
}

// -----------------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------------

// Local-storage session handling.
// https://github.com/AdobeDocs/analytics-1.4-apis/blob/master/docs/data-insertion-api/overview/c_visitor_id.md
export function getUserId () {
  const hasConsent = getConsent()?.statistics
  const storedUserId = hasConsent ? localStorage.getItem(USER_ID_STORAGE_KEY) : localStorage.removeItem(USER_ID_STORAGE_KEY)
  const passedUserId = new URL(location).searchParams.get('vid')
  if (!newOrRepeat) {
    newOrRepeat = storedUserId ? 'repeat' : 'new'
  }
  userId = passedUserId ?? storedUserId ?? userId ?? crypto.randomUUID().replaceAll('-', '') // Adobe does not like hyphens.
  hasConsent && localStorage.setItem(USER_ID_STORAGE_KEY, userId)

  return [userId, newOrRepeat]
}

function getConsentStatus () {
  const c = getConsent() ?? {}

  if (c.statistics && c.preferences && c.marketing) {
    return 'granted all'
  } else if (c.statistics || c.preferences || c.marketing) {
    return 'granted partial'
  } else if (Object.keys(c).length === 0) {
    return 'denied pending'
  } else {
    return 'denied all'
  }
}

export function getUrl (href) {
  const url = new URL(href, location)
  url.searchParams.delete('vid') // Ignore vid parameter.
  return url
}

function isExternalLink (urlObj) {
  return urlObj.origin !== location.origin && urlObj.protocol?.startsWith('http')
}

function isPageLink (urlObj) {
  const { hostname, pathname } = urlObj
  return location.hostname === hostname && (pathname === '/' || pathname?.endsWith('.html'))
}

function isDownloadable (urlObj) {
  return DOWNLOADABLE_EXTENSIONS.some(ext => urlObj.pathname?.endsWith(ext))
}

function getTrafficOrigin (referrer) {
  if (!referrer) {
    return 'No Referrer'
  } else if (isExternalLink(getUrl(referrer))) {
    return 'External'
  }

  return 'Internal'
}

function getColorScheme () {
  return globalThis.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}

function getFileName (link) {
  return link.substring(link.lastIndexOf('/') + 1)
}

function extractLinkData (el) {
  const urlObj = el.href ? getUrl(el.href) : {}

  // Create copy in order to remove SVG and style elements. We only need text content.
  const clone = el.cloneNode(true)
  for (const elem of clone.querySelectorAll('svg, style')) {
    elem.remove()
  }

  const data = {
    pe: 'lnk_o',
    text: clone.title || clone.ariaLabel || clone.textContent.trim(),
    region: el.closest('header,main,footer')?.tagName || 'BODY',
    action: 'click',
    type: 'interaction',
    href: urlObj.href
  }

  // The tracking event fires before the expand/collapse event.
  // This means the expand/collapse attributes will be reversed.
  if (el.ariaExpanded === 'true' || el.parentElement?.matches('details:not([open])')) {
    data.action = 'expand'
  } else if (el.ariaExpanded === 'false' || el.parentElement?.matches('details[open]')) {
    data.action = 'collapse'
  } else if (el.getAttribute('role') === 'tab') {
    data.action = 'tab'
  } else if (el.getAttribute('href')?.startsWith('#')) {
    data.type = 'hash'
  } else if (isExternalLink(urlObj)) {
    data.type = 'external'
    data.pe = 'lnk_e'
    // Add vid to exit link.
    if (userId) {
      const url = new URL(el.href)
      url.searchParams.set('vid', userId)
      el.href = url.toString()
    }
  } else if (isPageLink(urlObj)) {
    data.type = 'internal'
    data.pe = 'lnk_o'
  } else if (isDownloadable(urlObj)) {
    data.type = 'download'
    data.pe = 'lnk_d'
  } else if (urlObj.protocol && !urlObj.protocol.startsWith('http')) {
    data.type = urlObj.protocol.replace(':', '')
  }

  return data
}

function getComponentModel (el) {
  return globalThis?.ami?.componentModels?.get?.(el?.closest('[data-name]')?.dataset?.key)
}

function getPageComponentModel () {
  return getComponentModel(document.querySelector('[data-name="Page"]'))
}

function getPageProps () {
  const model = getPageComponentModel()?.model ?? {}
  const links = model.links ?? []
  const metas = links.reduce((obj, link) => ({ ...obj, [link.name]: link.value }), {})
  const trail = ['homepage', ...(metas?.hierarchy || '').split('/').slice(1)]
  const nameTrail = trail.length === 1 ? trail : trail.slice(1) // Remove homepage from child pages.
  const name = nameTrail.join('|')
  const hierarchy = trail.slice(0, 3).join('|') // Limited to 3 levels
  const [section, subsection] = nameTrail
  return { name, hierarchy, section, subsection }
}

async function trackLinkClick (el, organism, experiment) {
  const { pe, text, href, action, type, region } = extractLinkData(el)

  const vars = {
    events: 'event1',
    pe,
    pev1: href,
    pev2: text,
    v31: href,
    v40: text,
    v41: organism,
    v42: action,
    v43: type,
    v118: experiment,
    c40: 'D=v40', // Action text
    c41: 'D=v41', // Containing organism
    c42: 'D=v42', // Action
    c43: 'D=v43' // Link type
  }

  // Download link.
  if (pe === 'lnk_d') {
    vars.events += ',event3'
    vars.v11 = getFileName(href)
  }

  if (organism === 'SearchResult') {
    const [searchTerm, resultCount] = getSearchContext()

    vars.events += ',event6'
    vars.v16 = `Onsite Search: ${searchTerm}`
    vars.v17 = 'Onsite'
    vars.v18 = `${resultCount}`
  }

  await send({ ...vars, ...getActivityMap(text, region) })
}

function getSearchContext () {
  if (searchContext.length > 0) {
    return searchContext
  }

  const pageComponentModel = getPageComponentModel()
  if (!pageComponentModel) {
    return []
  }

  // Track only if q parameter is present.
  if (!new URLSearchParams(location.search).has('q')) {
    return []
  }

  const componentModels = [...pageComponentModel.children]
  for (const cm of componentModels) {
    // Track only if SearchForm organism is found.
    if (cm.organism === 'SearchForm') {
      const link = cm.model?.links?.[1] ?? {}
      searchContext = [link.name, Number(link.value)]
      return searchContext
    }

    cm.children && componentModels.push(...cm.children)
  }

  return []
}

function getActivityMap (text, region) {
  return {
    'c.': '',
    'a.': '',
    'activitymap.': '',
    page: getPageProps()?.name,
    link: text,
    region,
    pageIDType: 1,
    '.activitymap': '',
    '.a': '',
    '.c': ''
  }
}

// -----------------------------------------------------------------------------
// Tracking API
// -----------------------------------------------------------------------------

export function track (newApiUrl) {
  apiUrl = newApiUrl

  // Track events.
  if (!isInitialised) {
    isInitialised = true
    trackLinks()
    trackScroll()
    trackYouTubeVideos()
    trackMedia()
    trackIframes().catch(console.error)
    trackForms()
    trackPageViews().catch(console.error)
  }
}

async function trackPageViews () {
  await trackPageView() // First time tracking.
  globalThis.addEventListener('route', () => {
    trackPageView()
  })
}

async function trackPageView () {
  scrolled = 0

  const { name, section, subsection, hierarchy } = getPageProps()
  await send({ pageName: name, v23: section, v24: subsection, h1: hierarchy })

  // Track onsite search if applicable.
  const [searchTerm, resultCount] = getSearchContext()
  if (searchTerm) {
    const events = `event4${resultCount === 0 ? ',event5' : ''}`
    await send({
      events,
      pev1: getUrl(location).href,
      pev2: 'Onsite Search',
      pe: 'lnk_o',
      v16: `Onsite Search: ${searchTerm}`,
      v17: 'Onsite',
      v18: `${resultCount}`
    })
  }
}

function trackLinks () {
  document.addEventListener('click', event => {
    // Get all the clickable elements on the path (works inside Shadow DOM).
    // Ignore submit buttons.
    const els = event.composedPath().filter(e => e?.matches?.('.clickable:not([type="submit"])'))

    if (els.length === 0) {
      return
    }

    const tagName = event.target.tagName.toLowerCase()
    const customElementTagName = customElements.get(tagName) ? tagName : null

    const handler = async () => {
      for (const el of els) {
        // Do not track button clicks inside a form.
        if (!(el.closest('form') && el.tagName.toLowerCase() === 'button')) {
          const experiment = el.closest(EXPERIMENT_SELECTOR)?.dataset.experiment
          const organism = getComponentModel(el)?.organism ?? customElementTagName
          await trackLinkClick(el, organism, experiment)
        }
      }
    }
    handler().catch(console.error)
  })
}

function trackScroll () {
  scrolled = 0

  let resizeId
  document.addEventListener('scroll', event => {
    const target = event?.detail?.target ?? event.target
    clearTimeout(resizeId)
    resizeId = setTimeout(async () => {
      if (target === document.body) {
        await trackPageScroll()
      } else if (target && target !== document) {
        await trackSliderScroll(target)
      }
    }, SCROLL_DEBOUNCE)
  }, true)
}

async function trackPageScroll () {
  const el = document.body
  if (el.scrollHeight <= el.clientHeight) {
    return
  }
  const current = Math.round((el.scrollTop + el.clientHeight) / el.scrollHeight * 100)
  const pageHref = getUrl(location).href
  if (scrolled < 75 && current >= 75) {
    await send({ events: 'event102', pe: 'lnk_o', pev1: pageHref, pev2: 'Scroll' })
    scrolled = current
  } else if (scrolled < 50 && current >= 50) {
    await send({ events: 'event101', pe: 'lnk_o', pev1: pageHref, pev2: 'Scroll' })
    scrolled = current
  }
}

async function trackSliderScroll (target) {
  const componentModel = target.closest('[data-name]')?.componentModel ?? {}
  const index = Math.abs(Math.round(target.scrollLeft / target.children[0].offsetWidth)) + 1
  if (index > 0) {
    await send({
      events: 'event97',
      pe: 'lnk_o',
      pev1: getUrl(location).href,
      pev2: index,
      v40: index,
      v41: componentModel.organism,
      v42: 'Slide/Swipe'
    })
  }
}

async function trackIframes () {
  window.addEventListener('message', async event => {
    const { action, actionType, assetName, location, text, url: link } = event.data

    if (!action || !text || location !== 'iframe') {
      return
    }

    // Remap iframe message to analytics parameters according to link tracking.
    const parameterMap = {
      ClickInternal: { v42: 'click', v43: 'internal' },
      ClickExternal: { v42: 'click', v43: 'external', pe: 'lnk_e' },
      ClickHash: { v42: 'click', v43: 'hash' },
      ClickTab: { v42: 'tab', v43: 'interaction' },
      Share: { v42: 'share', v43: 'interaction' },
      Expand: { v42: 'expand', v43: 'interaction' },
      Collapse: { v42: 'collapse', v43: 'interaction' },
      'Slide/SwipeNext': { v42: 'next', v43: 'interaction' },
      'Slide/SwipePrevious': { v42: 'prev', v43: 'interaction' },
      Pagination: { v42: actionType, v43: 'interaction' },
      Download: {
        v42: 'click',
        v43: 'download',
        pe: 'lnk_d',
        events: 'event1,event3',
        pev1: text,
        v31: text,
        v11: getFileName(text)
      }
    }

    const vars = {
      events: 'event1',
      pe: 'lnk_o',
      pev1: link,
      pev2: text,
      v15: assetName,
      v31: link,
      v40: text,
      v41: location,
      c15: 'D=v15', // Asset name
      c40: 'D=v40', // Text
      c41: 'D=v41', // Location
      c42: 'D=v42', // Action
      c43: 'D=v43' // Action type
    }

    const key = Object.keys(parameterMap).find(k => k.startsWith(`${action}${actionType}`))
    if (!key) {
      console.warn(`Unknown iframe analytics action: ${action} ${actionType}`)
      return
    }

    Object.assign(vars, parameterMap[key] || {})
    await send(vars)
  })
}

function sendMilestoneEvent (title, percentage, milestones = []) {
  for (const milestone of milestones) {
    if (percentage >= milestone.percent) {
      if (!milestone.reached) {
        milestone.reached = true
        send({ events: milestone.event, v10: title }).catch(console.error)
      }

      break
    }
  }
}

function getPlaybackPercentage (currentTime, duration) {
  return duration ? Math.round((currentTime / duration) * 100) : 0
}

function trackYouTubeVideos () {
  // Bubbled-up load event.
  document.addEventListener('load', event => {
    const iframe = event.target
    const url = getUrl(iframe.src)

    if (YT_ORIGINS.has(url.origin) && url.searchParams.get('autoplay') !== '1') {
      iframe.contentWindow?.postMessage(JSON.stringify({ event: 'listening', id: iframe.id }), url.origin)
      iframe.milestones = Object.entries(MILESTONES).map(([ev, percent]) => ({ reached: false, percent, event: ev }))
    }
  })

  // Common handler for all YouTube iframes
  window.addEventListener('message', async event => {
    // Only handle message coming from YouTube.
    if (!YT_ORIGINS.has(event.origin)) {
      return
    }

    // If YouTube changes the message format, we want to stop the processing here.
    let data = event.data
    if (typeof event.data === 'string') {
      try {
        data = JSON.parse(event.data)
      } catch {
        console.error('Could not parse YouTube postMessage data, analytics tracking aborted.')
        return
      }
    }

    // Find the corresponding iframe.
    const [iframe] = await deepSelector(`#${data.id}`)
    if (!iframe) {
      return
    }

    if (['infoDelivery', 'initialDelivery'].includes(data.event)) {
      const info = data.info
      iframe.title ||= info.videoData?.title
      const percentage = getPlaybackPercentage(info.currentTime, info.progressState?.duration)
      sendMilestoneEvent(iframe.title, percentage, iframe.milestones)
    }
  })
}

function trackMedia () {
  // Bubbled up timeupdate event.
  document.addEventListener('timeupdate', event => {
    const el = event.target
    if (!el.autoplay) {
      el.milestones ||= Object.entries(MILESTONES).map(([ev, percent]) => ({ reached: false, percent, event: ev }))
      const percentage = getPlaybackPercentage(el.currentTime, el.duration)
      sendMilestoneEvent(el.title, percentage, el.milestones)
    }
  })
}

function isValidForm (form) {
  return form?.name
}

async function abandonmentHandler () {
  const forms = await deepSelector('form')

  for (const form of forms) {
    if (form.lastInteractedField) {
      await send({ events: 'event9', v12: form.name, v13: form.lastInteractedField })
    }
  }
}

function handleAbandonment () {
  abandonmentHandler().catch(console.error)
}

function trackForms () {
  document.addEventListener('input', event => {
    const handler = async () => {
      const input = event.composedPath()[0]
      const form = input.closest('form')

      if (isValidForm(form)) {
        form.lastInteractedField = input.dataset.label
        if (!form.startTime) {
          form.startTime = Date.now()
          await send({ events: 'event7', v12: form.name })
        }
      }
    }
    handler().catch(console.error)
  })

  document.addEventListener('invalid', event => {
    const input = event.composedPath()[0]
    const form = input.closest('form')

    const handler = async () => {
      await send({ events: 'event8', v12: form.name, l1: form.invalidLabels.join(',') })
      delete form.invalidLabels
    }

    if (isValidForm(form)) {
      form.invalidLabels ||= []
      form.invalidLabels.push(input.dataset.label)
      clearTimeout(form.timeout)
      form.timeout = setTimeout(() => {
        handler().catch(console.error)
      }, FORM_ERROR_TOLERANCE)
    }
  })

  document.addEventListener('submit', event => {
    const submitHandler = async () => {
      const form = event.composedPath()[0]

      if (isValidForm(form)) {
        const endTimeInSeconds = Math.ceil((Date.now() - form.startTime) / 1000)
        delete form.startTime
        delete form.lastInteractedField
        delete form.invalidLabels
        await send({ events: 'event10', v12: form.name, v14: endTimeInSeconds })
      }
    }

    submitHandler().catch(console.error)
  })

  window.addEventListener('beforeunload', handleAbandonment)
  document.addEventListener('route', handleAbandonment)
}
