/**
 * Pedalio — behaviours
 * =======================
 *
 * Twelve independent blocks, in this order:
 *
 *   1.  Reduced motion            reads the preference once, everything asks it
 *   2.  Word splitting            headings marked [data-words] become <span>s
 *   3.  Scroll reveals            one IntersectionObserver for the whole page
 *   4.  Scroll loop               one rAF that feeds the stack, the parallax
 *                                 and the footer — never several listeners
 *   5.  Stacking panels           writes --stack-progress on each panel
 *   6.  Parallax                  writes --shift in pixels
 *   7.  Footer curtain            writes --shift in percent
 *   8.  Dropdown menus            hover with two delays, click everywhere
 *   9.  Mobile menu
 *   10. Process auto-advance      the radios already work without this
 *   11. Quotes carousel           arrows, counter, 8s auto-advance
 *   12. FAQ                       closes the <details> that ship open
 *   13. CO2 calculator            the formula and its six constants
 *
 * Nothing here is required to read the page: every block enhances markup that
 * already works. If this file fails to load, the site stays usable.
 */

;(() => {
  'use strict'

  /* ======================================================================
     1. Reduced motion
     ====================================================================== */

  const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
  const reducedMotion = () => motionQuery.matches

  // The hero video is decoration; nobody who asked for less movement wants it.
  const heroVideo = document.querySelector('[data-hero-video]')
  if (heroVideo && reducedMotion()) {
    heroVideo.removeAttribute('autoplay')
    heroVideo.pause()
  }

  /* ======================================================================
     2. Word splitting
     ----------------------------------------------------------------------
     A heading marked [data-words] is rewritten as
       <span class="word"><span class="word__in">Mot</span></span>
     The outer span clips, the inner one slides up. Inline elements (the pink
     asterisk of the hero) are kept whole and treated as one word.

     Done in JS on purpose: the markup stays a plain heading, which is what a
     search engine, a screen reader and a printer read.
     ====================================================================== */

  const DELAI_PAR_MOT = 0.03 // seconds between two words

  const decouperEnMots = (titre) => {
    const morceaux = []

    for (const noeud of [...titre.childNodes]) {
      if (noeud.nodeType === Node.TEXT_NODE) {
        for (const mot of noeud.textContent.split(/(\s+)/)) {
          if (mot.trim()) morceaux.push({ texte: mot })
          else if (mot) morceaux.push({ espace: true })
        }
      } else if (noeud.nodeType === Node.ELEMENT_NODE) {
        // A <br> is a line break, not a word: wrapping it would turn it into
        // an inline-block and stop it from breaking anything.
        if (noeud.tagName === 'BR') morceaux.push({ saut: true })
        else morceaux.push({ element: noeud })
      }
    }

    titre.textContent = ''
    let rang = 0

    for (const morceau of morceaux) {
      if (morceau.espace) {
        titre.append(' ')
        continue
      }
      if (morceau.saut) {
        titre.append(document.createElement('br'))
        continue
      }
      const boite = document.createElement('span')
      boite.className = 'word'
      const interieur = document.createElement('span')
      interieur.className = 'word__in'
      interieur.style.setProperty('--word-delay', `${(rang * DELAI_PAR_MOT).toFixed(3)}s`)
      if (morceau.element) interieur.append(morceau.element)
      else interieur.textContent = morceau.texte
      boite.append(interieur)
      titre.append(boite)
      rang += 1
    }
  }

  for (const titre of document.querySelectorAll('[data-words]')) decouperEnMots(titre)

  /* ======================================================================
     3. Scroll reveals
     ----------------------------------------------------------------------
     One observer for [data-reveal], [data-words] and [data-shape].

     The threshold is 15% of the WINDOW height, written as a negative bottom
     root margin. Measuring a percentage of the element instead would leave
     every block taller than the screen hidden forever — the stat panels and
     the footer are exactly that.
     ====================================================================== */

  const aReveler = document.querySelectorAll('[data-reveal], [data-words], [data-shape]')

  // Each shape can delay its own drawing; the CSS reads --shape-delay.
  for (const forme of document.querySelectorAll('[data-shape][data-shape-delay]')) {
    forme.style.setProperty('--shape-delay', `${forme.dataset.shapeDelay}s`)
  }

  const montrer = (element) => element.classList.add('is-in')

  if (!('IntersectionObserver' in window) || reducedMotion()) {
    aReveler.forEach(montrer)
  } else {
    const observateur = new IntersectionObserver(
      (entrees) => {
        for (const entree of entrees) {
          if (!entree.isIntersecting) continue
          montrer(entree.target)
          observateur.unobserve(entree.target) // a reveal plays once
        }
      },
      { rootMargin: '0px 0px -15% 0px', threshold: 0 }
    )
    aReveler.forEach((element) => observateur.observe(element))

    // Safety net: whatever is left hidden when the page bottom is reached is
    // shown anyway. A sticky section can scroll past an element without ever
    // intersecting it.
    const filet = () => {
      if (window.scrollY + window.innerHeight < document.documentElement.scrollHeight - 200) return
      aReveler.forEach(montrer)
      window.removeEventListener('scroll', filet)
    }
    window.addEventListener('scroll', filet, { passive: true })
  }

  /* ======================================================================
     4. Scroll loop
     ----------------------------------------------------------------------
     Three effects need the scroll position. One listener, one rAF, three
     callbacks: adding a `scroll` listener per effect is the classic way to
     make a page stutter.
     ====================================================================== */

  const surDefilement = []
  let planifie = false

  const jouer = () => {
    planifie = false
    for (const effet of surDefilement) effet()
  }

  const demander = () => {
    if (planifie) return
    planifie = true
    requestAnimationFrame(jouer)
  }

  window.addEventListener('scroll', demander, { passive: true })
  window.addEventListener('resize', demander, { passive: true })

  /* ======================================================================
     5. Stacking panels
     ----------------------------------------------------------------------
     Four panels, each `position: sticky`. The CSS already stacks them; this
     adds the tilt of the one underneath.

     For panel N, progress is how far panel N+1 has travelled from the bottom
     of the window to the top: 0 when it enters, 1 when it covers. The CSS
     turns that single number into rotate + rotateX + scale, with the numbers
     of the reference (4°, 36°, 0.8).
     ====================================================================== */

  const panneaux = [...document.querySelectorAll('[data-stack-panel]')]

  if (panneaux.length > 1) {
    const majPile = () => {
      const hauteurEcran = window.innerHeight
      for (let i = 0; i < panneaux.length - 1; i += 1) {
        const suivant = panneaux[i + 1].getBoundingClientRect()
        // suivant.top goes from hauteurEcran (just entering) to 0 (covering).
        const avancement = 1 - Math.min(1, Math.max(0, suivant.top / hauteurEcran))
        panneaux[i].style.setProperty('--stack-progress', avancement.toFixed(4))
      }
    }
    if (!reducedMotion()) {
      surDefilement.push(majPile)
      majPile()
    }
  }

  /* ======================================================================
     6. Parallax
     ----------------------------------------------------------------------
     The tilted price card and the two round stickers drift a little slower
     than the page. We write a number of pixels into --shift and let the CSS
     decide what to do with it: those elements are also rotated, and a
     transform written from JS would wipe the rotation out.
     ====================================================================== */

  const flottants = [...document.querySelectorAll('[data-parallax]')]

  if (flottants.length && !reducedMotion()) {
    const AMPLITUDE = 60 // px of travel, top to bottom of the window

    const majParallax = () => {
      const hauteurEcran = window.innerHeight
      for (const element of flottants) {
        const boite = element.getBoundingClientRect()
        if (boite.bottom < 0 || boite.top > hauteurEcran) continue
        const vitesse = parseFloat(element.dataset.parallaxSpeed ?? '1')
        // -1 at the bottom of the window, +1 at the top.
        const position = 1 - (2 * (boite.top + boite.height / 2)) / hauteurEcran
        element.style.setProperty('--shift', `${(position * AMPLITUDE * (vitesse - 1) * 8).toFixed(1)}px`)
      }
    }
    surDefilement.push(majParallax)
    majParallax()
  }

  /* ======================================================================
     7. Footer curtain
     ----------------------------------------------------------------------
     The footer starts 25% of its height above its slot and slides down to 0
     as the last screen scrolls in. It is the reference's `yPercent: -25`,
     written the other way round so that a page without JavaScript shows the
     footer in place instead of a quarter of empty space.
     ====================================================================== */

  const pied = document.querySelector('[data-footer]')

  if (pied && !reducedMotion()) {
    const majPied = () => {
      const boite = pied.parentElement.getBoundingClientRect()
      const hauteurEcran = window.innerHeight
      if (boite.top > hauteurEcran) {
        pied.style.setProperty('--shift', '-25%')
        return
      }
      // 0 when the wrapper enters the window, 1 when it is fully in.
      const avancement = Math.min(1, Math.max(0, 1 - boite.top / hauteurEcran))
      pied.style.setProperty('--shift', `${(-25 + 25 * avancement).toFixed(2)}%`)
    }
    surDefilement.push(majPied)
    majPied()
  }

  /* ======================================================================
     8. Dropdown menus
     ----------------------------------------------------------------------
     Hover opens, but with two delays: a short one before opening so brushing
     past does nothing, and a longer one before closing that covers the empty
     gap between the entry and its panel. Click works too, which is the only
     thing a touch screen has.
     ====================================================================== */

  const DELAI_OUVERTURE = 80
  const DELAI_FERMETURE = 320

  for (const item of document.querySelectorAll('[data-dropdown]')) {
    const bouton = item.querySelector('[data-dropdown-toggle]')
    let minuterie = null

    const ouvrir = () => {
      clearTimeout(minuterie)
      item.setAttribute('data-open', '')
      bouton.setAttribute('aria-expanded', 'true')
    }

    const fermer = () => {
      clearTimeout(minuterie)
      item.removeAttribute('data-open')
      bouton.setAttribute('aria-expanded', 'false')
    }

    const differer = (action, delai) => {
      clearTimeout(minuterie)
      minuterie = setTimeout(action, delai)
    }

    item.addEventListener('mouseenter', () => differer(ouvrir, DELAI_OUVERTURE))
    item.addEventListener('mouseleave', () => differer(fermer, DELAI_FERMETURE))

    bouton.addEventListener('click', () => {
      if (item.hasAttribute('data-open')) fermer()
      else ouvrir()
    })

    item.addEventListener('focusout', (evenement) => {
      if (!item.contains(evenement.relatedTarget)) fermer()
    })

    item.addEventListener('keydown', (evenement) => {
      if (evenement.key === 'Escape') {
        fermer()
        bouton.focus()
      }
    })
  }

  /* ======================================================================
     9. Mobile menu
     ====================================================================== */

  const burger = document.querySelector('[data-burger]')
  const menuMobile = document.querySelector('[data-mobile-menu]')

  if (burger && menuMobile) {
    const basculer = (ouvert) => {
      menuMobile.toggleAttribute('data-open', ouvert)
      burger.setAttribute('aria-expanded', String(ouvert))
      burger.setAttribute('aria-label', ouvert ? 'Close menu' : 'Open menu')
      document.body.style.overflow = ouvert ? 'hidden' : ''
    }

    burger.addEventListener('click', () => basculer(!menuMobile.hasAttribute('data-open')))

    // Following a link closes the sheet; otherwise it hides the anchor target.
    for (const lien of menuMobile.querySelectorAll('a')) {
      lien.addEventListener('click', () => basculer(false))
    }

    document.addEventListener('keydown', (evenement) => {
      if (evenement.key === 'Escape' && menuMobile.hasAttribute('data-open')) basculer(false)
    })
  }

  /* ======================================================================
     10. Process auto-advance
     ----------------------------------------------------------------------
     The three radios already switch the panel, the picture and the colours
     through `:has()`. This only moves to the next step every 4 seconds — the
     duration the progress bar takes to fill in the stylesheet — and stops
     for good as soon as somebody chooses a step.
     ====================================================================== */

  const process = document.querySelector('[data-process]')
  const etapes = [...document.querySelectorAll('[data-process-radio]')]

  if (process && etapes.length > 1 && !reducedMotion()) {
    const DUREE_ETAPE = 4000
    let horloge = null

    const avancer = () => {
      const courant = etapes.findIndex((radio) => radio.checked)
      etapes[(courant + 1) % etapes.length].checked = true
    }

    const demarrer = () => {
      if (horloge) return
      horloge = setInterval(avancer, DUREE_ETAPE)
    }

    const arreter = () => {
      clearInterval(horloge)
      horloge = null
    }

    for (const radio of etapes) radio.addEventListener('change', arreter)

    // Only run while the section is on screen: a carousel spinning three
    // screens away burns battery for nobody.
    if ('IntersectionObserver' in window) {
      new IntersectionObserver(
        ([entree]) => (entree.isIntersecting ? demarrer() : arreter()),
        { threshold: 0.3 }
      ).observe(process)
    } else {
      demarrer()
    }
  }

  /* ======================================================================
     11. Quotes carousel
     ----------------------------------------------------------------------
     The track is a native scroll container with snap points, so dragging,
     swiping and the keyboard work without this. Here: the two arrows, the
     counter announced to screen readers, and an 8s auto-advance that stops
     on the first interaction.
     ====================================================================== */

  const carrousel = document.querySelector('[data-carousel]')

  if (carrousel) {
    const piste = carrousel.querySelector('[data-carousel-track]')
    const fiches = [...piste.children]
    const compteur = carrousel.querySelector('[data-carousel-counter]')
    const precedent = carrousel.querySelector('[data-carousel-prev]')
    const suivant = carrousel.querySelector('[data-carousel-next]')
    const DUREE_FICHE = 8000

    const rangCourant = () => Math.round(piste.scrollLeft / piste.clientWidth)

    const majCompteur = () => {
      const rang = Math.min(fiches.length - 1, Math.max(0, rangCourant()))
      compteur.textContent = `${String(rang + 1).padStart(2, '0')} / ${String(fiches.length).padStart(2, '0')}`
    }

    const allerA = (rang) => {
      const cible = ((rang % fiches.length) + fiches.length) % fiches.length
      piste.scrollTo({
        left: cible * piste.clientWidth,
        behavior: reducedMotion() ? 'auto' : 'smooth',
      })
    }

    precedent.addEventListener('click', () => {
      allerA(rangCourant() - 1)
      arreterDefilement()
    })
    suivant.addEventListener('click', () => {
      allerA(rangCourant() + 1)
      arreterDefilement()
    })
    piste.addEventListener('scroll', majCompteur, { passive: true })
    window.addEventListener('resize', majCompteur)

    let horlogeFiches = null
    const arreterDefilement = () => {
      clearInterval(horlogeFiches)
      horlogeFiches = null
    }
    const demarrerDefilement = () => {
      if (horlogeFiches || reducedMotion()) return
      horlogeFiches = setInterval(() => allerA(rangCourant() + 1), DUREE_FICHE)
    }

    piste.addEventListener('pointerdown', arreterDefilement)
    piste.addEventListener('keydown', arreterDefilement)

    if ('IntersectionObserver' in window) {
      new IntersectionObserver(
        ([entree]) => (entree.isIntersecting ? demarrerDefilement() : arreterDefilement()),
        { threshold: 0.4 }
      ).observe(carrousel)
    }

    majCompteur()
  }

  /* ======================================================================
     12. FAQ
     ----------------------------------------------------------------------
     The eight <details> ship open so that a page without JavaScript shows
     every answer. With JavaScript, we close them — an accordion that starts
     fully unfolded is not an accordion — except the first, which the
     reference also leaves open.
     ====================================================================== */

  for (const item of document.querySelectorAll('[data-faq-item]')) {
    item.open = item.hasAttribute('data-faq-first')
  }

  /* ======================================================================
     13. CO2 calculator
     ----------------------------------------------------------------------
     Same six constants as the reference. They are the whole model, so they
     are named rather than inlined:

       a cyclist rides KM_PAR_JOUR kilometres a day, JOURS_TRAVAILLES days a
       year minus the share spent working from home, and each of those
       kilometres saves GRAMMES_PAR_KM of CO2 against a car.

     Default sliders (200 employees, 20% cyclists, 80% remote) give 1,613 kg,
     which is the number written in index.html.
     ====================================================================== */

  const simulateur = document.querySelector('[data-calc]')

  if (simulateur) {
    const JOURS_TRAVAILLES = 168 // commuting days in a year
    const KM_PAR_JOUR = 10 // round trip, home to office
    const GRAMMES_PAR_KM = 120 // what a car emits over the same kilometre

    // One kilogram of CO2 saved, expressed in three familiar units.
    const KM_VOITURE_PAR_KG = 5.181
    const KM_AVION_PAR_KG = 9.804
    const JOURS_CHAUFFAGE_PAR_KG = 0.054

    const champs = {
      salaries: simulateur.querySelector('[data-calc-input="salaries"]'),
      velo: simulateur.querySelector('[data-calc-input="velo"]'),
      teletravail: simulateur.querySelector('[data-calc-input="teletravail"]'),
    }

    const sorties = {}
    for (const sortie of simulateur.querySelectorAll('[data-calc-output]')) {
      sorties[sortie.dataset.calcOutput] = sortie
    }

    const nombre = (valeur) => Math.round(valeur).toLocaleString('en-GB')

    const calculer = () => {
      const salaries = Number(champs.salaries.value)
      const partVelo = Number(champs.velo.value)
      const partTeletravail = Number(champs.teletravail.value)

      const cyclistes = Math.round((salaries * partVelo) / 100)
      const joursSurPlace = JOURS_TRAVAILLES * (1 - partTeletravail / 100)
      const kilos = (cyclistes * joursSurPlace * KM_PAR_JOUR * GRAMMES_PAR_KM) / 1000

      sorties.salaries.textContent = nombre(salaries)
      sorties.velo.textContent = `${partVelo}%`
      sorties.teletravail.textContent = `${partTeletravail}%`
      sorties.co2.textContent = nombre(kilos)
      sorties.voiture.textContent = nombre(kilos * KM_VOITURE_PAR_KG)
      sorties.avion.textContent = nombre(kilos * KM_AVION_PAR_KG)
      sorties.chauffage.textContent = nombre(kilos * JOURS_CHAUFFAGE_PAR_KG)
    }

    for (const champ of Object.values(champs)) champ.addEventListener('input', calculer)
    calculer()
  }
})()
