/* ==========================================================================
   Havenwatt — behaviour
   --------------------------------------------------------------------------
   Twelve independent blocks. Each one starts by looking for its elements and
   returns early when they are missing, so you can delete any section of the
   HTML without breaking the rest.

     1.  splitHeadings      cut a heading into visual lines
     2.  splitButtonLabels  cut a button label into letters
     3.  revealOnScroll     play a reveal when a block enters the screen
     4.  parallax           drift the media inside its frame
     5.  stickyHero         fade and lift the hero as the page slides over it
     6.  headerScroll       tuck the announcement strip away
     7.  invertHeader       light bar over dark sections
     8.  promoPanel         expand the announcement
     9.  megaMenus          desktop panels + mobile menu
     10. solutionsSlider    arrows for the scroll-snap track
     11. tabsSlider         centre the selected card
     12. faqAccordion       animate the native <details>
     13. processSteps       highlight the step in view
     14. videoToggles       pause / play a background video

   Nothing here is required to read the page: every animated state lives
   under `html.js`, added by enhance.js.
   ========================================================================== */

(function () {
  "use strict";

  var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  /* ========================================================================
     1. Split headings into visual lines

     Each line ends up as .line-mask > .line. The mask clips, the line slides
     up from 32% (see "Split headings" in the stylesheet).

     Why measure instead of splitting on <br>: a heading wraps differently at
     every width, so the lines have to be read from the rendered text. The
     original markup is kept in memory and re-split on resize.
     ======================================================================== */

  var resplitHeadings = function () {};

  function splitHeadings() {
    var targets = document.querySelectorAll("[data-split]");
    if (!targets.length) return;

    var sources = [];
    targets.forEach(function (el) {
      sources.push(el.innerHTML);
    });

    function wrapWords(node, inheritedClass, out) {
      // Walk the tree, replace every text node by one <span> per word.
      Array.prototype.slice.call(node.childNodes).forEach(function (child) {
        if (child.nodeType === 3) {
          var words = child.textContent.split(/(\s+)/);
          var fragment = document.createDocumentFragment();
          words.forEach(function (word) {
            if (!word.trim()) {
              // Keep single spaces so words do not glue together.
              if (word) fragment.appendChild(document.createTextNode(" "));
              return;
            }
            var span = document.createElement("span");
            span.className = "split-word" + (inheritedClass ? " " + inheritedClass : "");
            span.textContent = word;
            fragment.appendChild(span);
            out.push(span);
          });
          node.replaceChild(fragment, child);
        } else if (child.nodeType === 1) {
          if (child.tagName === "BR") return; // line breaks are read from the layout
          // An inline wrapper such as .heading__muted: pass its class down so
          // the colour survives the reflow into lines.
          wrapWords(child, child.className || inheritedClass, out);
        }
      });
    }

    function split(el) {
      var words = [];
      wrapWords(el, "", words);
      if (!words.length) return;

      // Group words by their vertical position: same top = same line.
      var lines = [];
      var currentTop = null;
      words.forEach(function (word) {
        var top = Math.round(word.offsetTop);
        if (currentTop === null || Math.abs(top - currentTop) > 4) {
          lines.push([]);
          currentTop = top;
        }
        lines[lines.length - 1].push(word);
      });

      // Rebuild: one mask per line, the words moved inside.
      var rebuilt = document.createDocumentFragment();
      lines.forEach(function (lineWords, index) {
        var mask = document.createElement("span");
        mask.className = "line-mask";
        var line = document.createElement("span");
        line.className = "line";
        line.style.setProperty("--line", index);
        lineWords.forEach(function (word, i) {
          if (i > 0) line.appendChild(document.createTextNode(" "));
          line.appendChild(word);
        });
        mask.appendChild(line);
        rebuilt.appendChild(mask);
      });

      el.innerHTML = "";
      el.appendChild(rebuilt);
    }

    function run() {
      targets.forEach(function (el, i) {
        el.innerHTML = sources[i];
        split(el);
      });
    }

    run();

    // Re-splitting drops the .is-in classes, so put them back on anything
    // that was already revealed.
    resplitHeadings = function () {
      var revealed = [];
      targets.forEach(function (el) {
        revealed.push(el.classList.contains("is-in") || !!el.closest(".is-in"));
      });
      run();
      targets.forEach(function (el, i) {
        if (!revealed[i]) return;
        el.classList.add("is-in");
        el.querySelectorAll(".line").forEach(function (line) {
          line.classList.add("is-in");
        });
      });
    };

    // Re-split after a resize: the line breaks have moved.
    var timer;
    window.addEventListener("resize", function () {
      clearTimeout(timer);
      timer = setTimeout(resplitHeadings, 200);
    });
  }

  /* ========================================================================
     2. Split button labels into letters

     --letter goes from 0 in the middle of the word to 1 at both ends, so the
     centre leads and the edges follow. The whole stagger fits in 20ms: it is
     meant to be felt, not seen.
     ======================================================================== */

  function splitButtonLabels() {
    document.querySelectorAll(".rise__label").forEach(function (label) {
      var text = label.textContent;
      if (text.length > 40) return; // long labels move as one block

      var middle = (text.length - 1) / 2;
      var fragment = document.createDocumentFragment();

      for (var i = 0; i < text.length; i++) {
        var letter = document.createElement("span");
        letter.className = "rise__letter";
        letter.textContent = text[i];
        letter.style.setProperty("--letter", (Math.abs(i - middle) / middle).toFixed(3));
        fragment.appendChild(letter);
      }

      label.textContent = "";
      label.appendChild(fragment);

      // Tells the stylesheet that letters are available: without this class
      // the label slides as one block, which is the no-JavaScript fallback.
      var rise = label.parentElement;
      if (rise) rise.classList.add("is-split");
    });
  }

  /* ========================================================================
     3. Reveal on scroll

     Threshold at 15%: a reveal triggered by the first pixel entering the
     screen is over before anyone sees it. Blocks already visible on load are
     revealed straight away instead of waiting for a scroll that may never come.
     ======================================================================== */

  function revealOnScroll() {
    var blocks = document.querySelectorAll("[data-reveal], [data-split]");
    if (!blocks.length) return;

    function show(el) {
      el.classList.add("is-in");
      el.querySelectorAll(".line").forEach(function (line) {
        line.classList.add("is-in");
      });
      // Stagger containers number their children for the CSS delay.
      if (el.getAttribute("data-reveal") === "stagger") {
        Array.prototype.slice.call(el.children).forEach(function (child, i) {
          child.style.setProperty("--index", i);
        });
      }
    }

    if (reduceMotion || !("IntersectionObserver" in window)) {
      blocks.forEach(show);
      return;
    }

    // Number the children before observing, so the delay is already in place.
    document.querySelectorAll('[data-reveal="stagger"]').forEach(function (el) {
      Array.prototype.slice.call(el.children).forEach(function (child, i) {
        child.style.setProperty("--index", i);
      });
    });

    // The trigger line sits at 88% of the screen height: a block reveals when
    // its top crosses it, never on the first pixel entering from the bottom.
    //
    // Why a margin and not a 15% threshold: a block taller than the screen can
    // never show 15% OF ITSELF, so it would stay hidden forever. The footer is
    // exactly that case.
    var observer = new IntersectionObserver(
      function (entries) {
        entries.forEach(function (entry) {
          if (!entry.isIntersecting) return;
          show(entry.target);
          observer.unobserve(entry.target);
        });
      },
      { threshold: 0, rootMargin: "0px 0px -12% 0px" }
    );

    var pending = [];

    blocks.forEach(function (el) {
      // Already on screen when the page loads: show it now instead of waiting
      // for a scroll that may never happen.
      var rect = el.getBoundingClientRect();
      if (rect.top < window.innerHeight * 0.88 && rect.bottom > 0) {
        show(el);
        return;
      }
      pending.push(el);
      observer.observe(el);
    });

    // Safety net for the bottom of the page. On a tall screen the last block
    // can sit below the 88% line even when the page is scrolled all the way
    // down — it would stay invisible forever. Reaching the bottom reveals
    // whatever is left.
    onScroll(function () {
      if (!pending.length) return;
      var atBottom =
        window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2;
      if (!atBottom) return;
      pending.forEach(function (el) {
        show(el);
        observer.unobserve(el);
      });
      pending = [];
    });
  }

  /* ========================================================================
     4. Parallax

     --parallax runs from -1 (element entering at the bottom) to +1 (leaving
     at the top). The stylesheet turns that into a translate, so the maths
     stays here and the look stays there.
     ======================================================================== */

  function parallax() {
    var blocks = document.querySelectorAll("[data-parallax]");
    if (!blocks.length || reduceMotion) return;

    function update() {
      var viewport = window.innerHeight;
      blocks.forEach(function (el) {
        var rect = el.getBoundingClientRect();
        if (rect.bottom < 0 || rect.top > viewport) return;
        // -1 when the block enters from the bottom, 0 when it is centred,
        // +1 when it leaves through the top.
        var centre = rect.top + rect.height / 2;
        var progress = (viewport / 2 - centre) / ((viewport + rect.height) / 2);
        el.style.setProperty("--parallax", Math.max(-1, Math.min(1, progress)).toFixed(4));
      });
    }

    onScroll(update);
    update();
  }

  /* ========================================================================
     5. Sticky hero

     --hero-progress goes 0 → 1 while the hero is being covered. The video
     fades to 32% and scales to 1.08, the text drifts up 200px.
     ======================================================================== */

  function stickyHero() {
    var hero = document.querySelector("[data-hero]");
    if (!hero || reduceMotion) return;

    function update() {
      var rect = hero.getBoundingClientRect();
      // The hero is pinned: its own top stays at 0 and the bottom rises.
      var travelled = Math.min(1, Math.max(0, (rect.height - rect.bottom) / rect.height));
      hero.style.setProperty("--hero-progress", travelled.toFixed(4));
    }

    onScroll(update);
    update();
  }

  /* ========================================================================
     6. Header: tuck the announcement strip away after 120px
     ======================================================================== */

  function headerScroll() {
    var header = document.querySelector("[data-header]");
    if (!header) return;

    function update() {
      header.classList.toggle("is-scrolled", window.scrollY > 120);
    }

    onScroll(update);
    update();
  }

  /* ========================================================================
     7. Invert the header over dark sections

     The bar is glass: on a dark section its text has to turn light. Sections
     opt in with data-nav="invert" — the switch happens when one of them
     crosses 8% of the screen height, which is where the bar sits.
     ======================================================================== */

  function invertHeader() {
    var header = document.querySelector("[data-header]");
    var sections = document.querySelectorAll('[data-nav="invert"]');
    if (!header || !sections.length) return;

    function update() {
      var line = window.innerHeight * 0.08;
      var dark = false;
      sections.forEach(function (section) {
        var rect = section.getBoundingClientRect();
        if (rect.top <= line && rect.bottom >= line) dark = true;
      });
      header.classList.toggle("is-inverted", dark);
    }

    onScroll(update);
    update();
  }

  /* ========================================================================
     8. Announcement panel

     Height is animated from 0 to the natural height, then released to `auto`
     so the panel keeps working if the text changes.
     ======================================================================== */

  function promoPanel() {
    var toggle = document.querySelector("[data-promo-toggle]");
    var panel = document.querySelector("[data-promo-panel]");
    if (!toggle || !panel) return;

    var promo = panel.parentElement;
    var open = false;

    toggle.addEventListener("click", function () {
      open = !open;
      promo.classList.toggle("is-open", open);
      toggle.setAttribute("aria-expanded", String(open));
      panel.style.height = open ? panel.scrollHeight + "px" : "0px";
    });

    // Keep the height honest when the layout changes.
    window.addEventListener("resize", function () {
      if (open) panel.style.height = panel.scrollHeight + "px";
    });
  }

  /* ========================================================================
     9. Mega-menus and mobile menu

     Desktop: hovering a trigger opens its panel, the bar turns solid and a
     veil dims the page. Leaving the bar closes everything after 120ms, so
     crossing a gap between two triggers does not flicker.
     Keyboard and touch: the trigger is a real <button>, a click toggles.
     ======================================================================== */

  function megaMenus() {
    var bar = document.querySelector("[data-nav-content]");
    var panelsWrap = document.querySelector("[data-panels]");
    var triggers = document.querySelectorAll("[data-menu]");
    var panels = document.querySelectorAll("[data-panel]");
    var veil = document.querySelector("[data-nav-veil]");
    var burger = document.querySelector("[data-menu-toggle]");
    var mobileMenu = document.querySelector("[data-mobile-menu]");
    if (!bar || !panelsWrap) return;

    var desktop = window.matchMedia("(min-width: 992px)");
    var closeTimer;
    var openName = null;

    function openPanel(name) {
      var next = document.querySelector('[data-panel="' + name + '"]');
      if (!next || openName === name) return;
      clearTimeout(closeTimer);

      panels.forEach(function (panel) {
        panel.classList.remove("is-open", "is-visible");
      });
      next.classList.add("is-open");

      bar.classList.add("is-solid");
      panelsWrap.style.height = next.offsetHeight + "px";
      if (veil) veil.classList.add("is-visible");

      // One frame later, so the fade has a starting point.
      requestAnimationFrame(function () {
        next.classList.add("is-visible");
      });

      triggers.forEach(function (trigger) {
        trigger.setAttribute("aria-expanded", String(trigger.getAttribute("data-menu") === name));
      });
      openName = name;
    }

    function closePanel() {
      clearTimeout(closeTimer);
      if (!openName) return;
      openName = null;
      panelsWrap.style.height = "0px";
      bar.classList.remove("is-solid");
      if (veil) veil.classList.remove("is-visible");
      triggers.forEach(function (trigger) {
        trigger.setAttribute("aria-expanded", "false");
      });
      panels.forEach(function (panel) {
        panel.classList.remove("is-visible");
        setTimeout(function () {
          if (!openName) panel.classList.remove("is-open");
        }, 220);
      });
    }

    triggers.forEach(function (trigger) {
      var name = trigger.getAttribute("data-menu");
      trigger.addEventListener("mouseenter", function () {
        if (desktop.matches) openPanel(name);
      });
      trigger.addEventListener("click", function () {
        if (openName === name) closePanel();
        else openPanel(name);
      });
    });

    document.querySelectorAll("[data-menu-close]").forEach(function (link) {
      link.addEventListener("mouseenter", closePanel);
    });

    bar.addEventListener("mouseleave", function () {
      if (!desktop.matches) return;
      closeTimer = setTimeout(closePanel, 120);
    });

    bar.addEventListener("mouseenter", function () {
      clearTimeout(closeTimer);
    });

    if (veil) veil.addEventListener("click", closePanel);

    document.addEventListener("keydown", function (event) {
      if (event.key === "Escape") {
        closePanel();
        closeMobile();
      }
    });

    // --- Mobile ---
    var mobileOpen = false;

    function openMobile() {
      if (mobileOpen || !mobileMenu || !burger) return;
      mobileOpen = true;
      burger.setAttribute("aria-expanded", "true");
      burger.setAttribute("aria-label", "Close menu");
      bar.classList.add("is-solid"); // glass alone is too thin behind a menu
      mobileMenu.classList.add("is-open");
      // display:none cannot be transitioned: show first, fade one frame later.
      requestAnimationFrame(function () {
        mobileMenu.classList.add("is-visible");
      });
    }

    function closeMobile() {
      if (!mobileOpen || !mobileMenu || !burger) return;
      mobileOpen = false;
      burger.setAttribute("aria-expanded", "false");
      burger.setAttribute("aria-label", "Open menu");
      bar.classList.remove("is-solid");
      mobileMenu.classList.remove("is-visible");
      setTimeout(function () {
        if (!mobileOpen) mobileMenu.classList.remove("is-open");
      }, 220);
    }

    if (burger && mobileMenu) {
      burger.addEventListener("click", function () {
        if (mobileOpen) closeMobile();
        else openMobile();
      });

      mobileMenu.addEventListener("click", function (event) {
        if (event.target.closest("a")) closeMobile();
      });
    }

    // Leaving the desktop range with a panel open would leave a gap.
    desktop.addEventListener("change", function () {
      closePanel();
      closeMobile();
    });
  }

  /* ========================================================================
     10. Solutions slider

     The track is a native scroll-snap container: dragging, swiping and the
     keyboard already work. The arrows scroll by exactly one card.
     ======================================================================== */

  function solutionsSlider() {
    var slider = document.querySelector("[data-slider]");
    if (!slider) return;

    var track = slider.querySelector("[data-slider-track]");
    var prev = slider.querySelector("[data-slider-prev]");
    var next = slider.querySelector("[data-slider-next]");
    var slides = track ? track.querySelectorAll(".slider__slide") : [];
    if (!track || !slides.length) return;

    function step() {
      var gap = parseFloat(getComputedStyle(track).gap) || 0;
      return slides[0].getBoundingClientRect().width + gap;
    }

    function updateArrows() {
      // scrollWidth - clientWidth is the furthest the track can go.
      var max = track.scrollWidth - track.clientWidth;
      if (prev) prev.disabled = track.scrollLeft <= 1;
      if (next) next.disabled = track.scrollLeft >= max - 1;
    }

    if (prev) {
      prev.addEventListener("click", function () {
        track.scrollBy({ left: -step(), behavior: reduceMotion ? "auto" : "smooth" });
      });
    }
    if (next) {
      next.addEventListener("click", function () {
        track.scrollBy({ left: step(), behavior: reduceMotion ? "auto" : "smooth" });
      });
    }

    track.addEventListener("scroll", updateArrows, { passive: true });
    window.addEventListener("resize", updateArrows);
    updateArrows();
  }

  /* ========================================================================
     11. Tabs slider

     The strip is a native scroll container (see the stylesheet): dragging and
     swiping work on their own. A tab scrolls its card to the centre, and
     scrolling by hand updates the tabs back.
     ======================================================================== */

  function tabsSlider() {
    var root = document.querySelector("[data-tabs]");
    if (!root) return;

    var viewport = root.querySelector("[data-tabs-viewport]");
    var buttons = root.querySelectorAll("[data-tab-btn]");
    var slides = root.querySelectorAll("[data-tab-slide]");
    if (!viewport || !slides.length) return;

    var active = 0;

    function mark(index) {
      active = index;
      buttons.forEach(function (button, i) {
        button.classList.toggle("is-active", i === active);
        button.setAttribute("aria-selected", String(i === active));
      });
      slides.forEach(function (slide, i) {
        slide.classList.toggle("is-active", i === active);
      });
    }

    function goTo(index) {
      index = Math.max(0, Math.min(slides.length - 1, index));
      var slide = slides[index];
      // offsetLeft is measured inside the scroll container, so this is the
      // scroll position that puts the card in the middle of the strip.
      var target = slide.offsetLeft - (viewport.clientWidth - slide.offsetWidth) / 2;
      viewport.scrollTo({ left: target, behavior: reduceMotion ? "auto" : "smooth" });
      mark(index);
    }

    buttons.forEach(function (button, i) {
      button.addEventListener("click", function () {
        goTo(i);
      });
    });

    slides.forEach(function (slide, i) {
      slide.addEventListener("click", function () {
        if (i !== active) goTo(i);
      });
    });

    // Dragged by hand: highlight whichever card sits closest to the centre.
    var scrollTimer;
    viewport.addEventListener(
      "scroll",
      function () {
        clearTimeout(scrollTimer);
        scrollTimer = setTimeout(function () {
          var centre = viewport.scrollLeft + viewport.clientWidth / 2;
          var closest = 0;
          var best = Infinity;
          slides.forEach(function (slide, i) {
            var distance = Math.abs(slide.offsetLeft + slide.offsetWidth / 2 - centre);
            if (distance < best) {
              best = distance;
              closest = i;
            }
          });
          if (closest !== active) mark(closest);
        }, 120);
      },
      { passive: true }
    );

    mark(0);
  }

  /* ========================================================================
     12. FAQ

     <details> already opens, closes and answers the keyboard. This only adds
     the height animation and closes the other entries.
     ======================================================================== */

  function faqAccordion() {
    var list = document.querySelector("[data-faq]");
    if (!list) return;

    var items = list.querySelectorAll("details");

    items.forEach(function (item) {
      var answer = item.querySelector(".qa__answer");
      var summary = item.querySelector("summary");
      if (!answer || !summary) return;

      summary.addEventListener("click", function (event) {
        if (reduceMotion) return; // let the browser do its thing
        event.preventDefault();

        if (item.open) {
          collapse(item, answer);
        } else {
          items.forEach(function (other) {
            if (other !== item && other.open) collapse(other, other.querySelector(".qa__answer"));
          });
          expand(item, answer);
        }
      });
    });

    function expand(item, answer) {
      item.open = true;
      var height = answer.scrollHeight;
      answer.animate(
        [
          { height: "0px", opacity: 0 },
          { height: height + "px", opacity: 1 },
        ],
        { duration: 320, easing: "cubic-bezier(0.645, 0.045, 0.355, 1)" }
      );
    }

    function collapse(item, answer) {
      if (!answer) {
        item.open = false;
        return;
      }
      var height = answer.scrollHeight;
      var animation = answer.animate(
        [
          { height: height + "px", opacity: 1 },
          { height: "0px", opacity: 0 },
        ],
        { duration: 300, easing: "cubic-bezier(0.645, 0.045, 0.355, 1)" }
      );
      animation.onfinish = function () {
        item.open = false;
      };
    }
  }

  /* ========================================================================
     13. Process steps: highlight the card in the middle of the screen
     ======================================================================== */

  function processSteps() {
    var nav = document.querySelector("[data-steps-nav]");
    var steps = document.querySelectorAll("[data-step]");
    if (!nav || !steps.length) return;

    var links = nav.querySelectorAll(".step-link");

    function update() {
      var middle = window.innerHeight / 2;
      var closest = 0;
      var closestDistance = Infinity;

      steps.forEach(function (step, i) {
        var rect = step.getBoundingClientRect();
        var distance = Math.abs(rect.top + rect.height / 2 - middle);
        if (distance < closestDistance) {
          closestDistance = distance;
          closest = i;
        }
      });

      links.forEach(function (link, i) {
        link.classList.toggle("is-active", i === closest);
      });
    }

    onScroll(update);
    update();
  }

  /* ========================================================================
     14. Background videos

     Autoplay can be refused (data saver, battery, reduced motion). The button
     reflects the real state instead of assuming it plays.
     ======================================================================== */

  function videoToggles() {
    document.querySelectorAll("[data-video-toggle]").forEach(function (button) {
      var media = button.parentElement.querySelector("[data-hero-video]");
      if (!media) return;

      function sync() {
        button.classList.toggle("is-paused", media.paused);
        button.setAttribute(
          "aria-label",
          media.paused ? "Play the background video" : "Pause the background video"
        );
      }

      button.addEventListener("click", function () {
        if (media.paused) media.play();
        else media.pause();
      });

      media.addEventListener("play", sync);
      media.addEventListener("pause", sync);

      if (reduceMotion) {
        // The markup carries `autoplay`, so the video is already running by
        // the time this line is reached: pausing is the whole point here.
        media.pause();
      } else {
        // A refused autoplay resolves as a rejected promise: ignore it and let
        // sync() show the play button instead.
        var attempt = media.play();
        if (attempt && attempt.catch) attempt.catch(function () {});
      }
      sync();
    });
  }

  /* ========================================================================
     Shared scroll listener

     One rAF-throttled listener for every scroll effect, instead of five
     competing ones.
     ======================================================================== */

  var scrollCallbacks = [];
  var ticking = false;

  function onScroll(callback) {
    scrollCallbacks.push(callback);
  }

  window.addEventListener(
    "scroll",
    function () {
      if (ticking) return;
      ticking = true;
      requestAnimationFrame(function () {
        scrollCallbacks.forEach(function (callback) {
          callback();
        });
        ticking = false;
      });
    },
    { passive: true }
  );

  window.addEventListener("resize", function () {
    scrollCallbacks.forEach(function (callback) {
      callback();
    });
  });

  /* ========================================================================
     Start
     ======================================================================== */

  function init() {
    splitHeadings();
    splitButtonLabels();
    revealOnScroll();
    parallax();
    stickyHero();
    headerScroll();
    invertHeader();
    promoPanel();
    megaMenus();
    solutionsSlider();
    tabsSlider();
    faqAccordion();
    processSteps();
    videoToggles();
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init);
  } else {
    init();
  }

  // Web fonts change where the text wraps: measure the lines again once the
  // real font is in place, otherwise the masks are cut on fallback metrics.
  if (document.fonts && document.fonts.ready) {
    document.fonts.ready.then(function () {
      resplitHeadings();
    });
  }
})();
