<script lang="ts">
  import { fade } from 'svelte/transition';
  import {
    attachBannerHost,
    bannerCurrent,
    bannerCurrentColors,
    configureBanner,
    detachBannerHost,
    remeasureBanner,
  } from '../lib/bannerEngine';
  import { defaultBannerTimings, linkify, type BannerHost } from '../lib/banner';
  import { resolveBannerColors, type ThemeMode } from '../lib/bannerColors';
  import type { BannerCampaign, BannerColors, BannerTimings } from '../lib/model';
  import { onExternalLinkClick } from '../lib/links';

  let {
    campaigns,
    timings = defaultBannerTimings,
    reduceMotion = false,
  }: { campaigns: BannerCampaign[]; timings?: BannerTimings; reduceMotion?: boolean } = $props();

  // Initialise from the persistent engine's live message: a view mounted by navigation shows the
  // current message and is visible at once (no fade — see inFade), so a screen change does not
  // replay the fade. Empty on the very first mount (engine not yet started).
  let current = $state(bannerCurrent());
  let currentColors = $state<BannerColors>(bannerCurrentColors());
  let visible = $state(bannerCurrent() !== '');
  let tx = $state(0);
  let txDur = $state(0);
  let track = $state<HTMLElement>();
  let viewport = $state<HTMLElement>();

  // The rendered theme (light/dark), tracked so a campaign's colour override resolves against the
  // theme actually on screen and re-resolves live when the operator/OS flips it. It follows the
  // [data-theme] attribute (theme.ts), or the OS preference when unset ('auto').
  let themeMode = $state<ThemeMode>(resolveThemeMode());
  function resolveThemeMode(): ThemeMode {
    if (typeof document === 'undefined') return 'light';
    const attr = document.documentElement.getAttribute('data-theme');
    if (attr === 'dark' || attr === 'light') return attr;
    return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
  }

  // The strip's colours for the current campaign + theme (null = the neutral tokens). Applied as
  // inline CSS variables scoped to .ad, so an override never leaks to the rest of the page.
  const adColors = $derived(resolveBannerColors(currentColors, themeMode));
  const adStyle = $derived(
    adColors
      ? `--ad-bg:${adColors.bg};--text-muted:${adColors.fg};--accent:${adColors.link};--ad-border:${adColors.border}`
      : '',
  );

  // The first appearance after mounting onto an already-running cycle is instant; every later
  // message change fades. (Consumed by the first in:fade.)
  let instantOnce = bannerCurrent() !== '';

  // Effective timings: reduce-motion collapses the fades (instant swap) and, via overflowPx, the
  // scroll. The engine is configured with the same effective timings, so the fade durations stay in
  // step with the rotator's hold/transition scheduling.
  const eff = $derived<BannerTimings>(
    reduceMotion ? { ...timings, fadeOutMs: 0, gapMs: 0, fadeInMs: 0 } : timings,
  );

  // in:fade with a reliable from-state (Svelte forces opacity 0 → 1), so even the first message
  // fades — unlike a bare opacity toggle, which can paint 0 and 1 in one frame and skip the fade.
  // The resync mount renders the live message instantly (duration 0).
  function inFade(node: Element) {
    const duration = instantOnce ? 0 : eff.fadeInMs;
    instantOnce = false;
    return fade(node, { duration });
  }

  // The DOM host the engine drives. The fade lives on the {#if} layer (transition:fade), the scroll
  // on the inner track's transform, so a long message's scroll never blocks its fade in/out.
  const host: BannerHost = {
    show(md, colors) {
      current = md;
      currentColors = colors;
      tx = 0;
      txDur = 0;
      visible = true;
    },
    resetScroll() {
      tx = 0;
      txDur = 0;
    },
    hide() {
      visible = false;
    },
    overflowPx() {
      return reduceMotion ? 0 : Math.max(0, (track?.scrollWidth ?? 0) - (viewport?.clientWidth ?? 0));
    },
    scrollTo(toPx, durationMs) {
      txDur = durationMs;
      tx = -toPx;
    },
    resumeScroll(fromTx, toPx, durationMs) {
      // Jump to the carried-over offset instantly, then continue to the end over the remaining
      // time — so a long message keeps its scroll position across a navigation.
      txDur = 0;
      tx = fromTx;
      requestAnimationFrame(() => {
        txDur = durationMs;
        tx = -toPx;
      });
    },
  };

  // Attach to the persistent engine on mount, detach on unmount (without stopping it). Declared
  // before configure so the host is attached when the rotator first measures overflow.
  $effect(() => {
    attachBannerHost(host);
    return () => detachBannerHost(host);
  });
  // (Re)configure the engine when the campaigns or effective timings change; a no-op when
  // unchanged, so a remount continues the running cycle rather than restarting it.
  $effect(() => {
    configureBanner(campaigns, eff);
  });
  // Track the rendered theme so a colour override re-resolves when the theme flips: watch the
  // [data-theme] attribute (Settings toggle / Telegram) and, for 'auto', the OS colour scheme.
  $effect(() => {
    const update = () => (themeMode = resolveThemeMode());
    update();
    const obs = new MutationObserver(update);
    obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
    const mq = window.matchMedia?.('(prefers-color-scheme: dark)');
    mq?.addEventListener('change', update);
    return () => {
      obs.disconnect();
      mq?.removeEventListener('change', update);
    };
  });
  // Re-measure on a viewport size change (e.g. a portrait↔landscape rotation): a message that fit
  // may now overflow, or vice versa, so the scroll must be re-evaluated. Debounced.
  $effect(() => {
    let t: ReturnType<typeof setTimeout>;
    const onResize = () => {
      clearTimeout(t);
      t = setTimeout(remeasureBanner, 250);
    };
    window.addEventListener('resize', onResize);
    window.addEventListener('orientationchange', onResize);
    return () => {
      clearTimeout(t);
      window.removeEventListener('resize', onResize);
      window.removeEventListener('orientationchange', onResize);
    };
  });
</script>

<!-- The banner links are rendered via {@html}; a delegated click routes any of them through the
     platform opener (onExternalLinkClick uses closest('a')): the Telegram SDK skips the WebView
     confirmation, the VK away-redirect keeps the Android WebView on the game. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="ad" bind:this={viewport} style={adStyle} onclick={onExternalLinkClick}>
  <!-- An always-present, invisible spacer reserves exactly one line of height, so the strip never
       collapses while the message layer is absent during the fade gap (the message is overlaid
       absolutely, so its presence/absence does not change the strip height). -->
  <span class="reserve" aria-hidden="true">&nbsp;</span>
  {#if visible}
    <div class="fadewrap" in:inFade out:fade={{ duration: eff.fadeOutMs }}>
      <div
        class="track"
        bind:this={track}
        style="transform:translateX({tx}px); transition:transform {txDur}ms linear"
      >
        {@html linkify(current)}
      </div>
    </div>
  {/if}
</div>

<style>
  .ad {
    position: relative;
    overflow: hidden;
    white-space: nowrap;
    padding: 6px 0;
    background: var(--ad-bg);
    color: var(--text-muted);
    font-size: 0.85rem;
    line-height: 1.2;
    box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18);
    border-top: 1px solid var(--ad-border, var(--border));
    border-bottom: 1px solid var(--ad-border, var(--border));
    user-select: none;
  }
  /* Reserves one line of height inside the padding so .ad keeps a constant height even during the
     fade gap; invisible and non-interactive. */
  .reserve {
    visibility: hidden;
  }
  /* The message layer is overlaid on the reserved line, so showing/hiding it never resizes .ad. */
  .fadewrap {
    position: absolute;
    top: 6px;
    left: 0;
    right: 0;
  }
  .track {
    display: inline-block;
    /* The side inset lives on the track (not the clipping .ad) so the scroll distance
       (scrollWidth - viewport.clientWidth) reaches the very end of a long message. */
    padding: 0 var(--pad);
    will-change: transform;
  }
  .track :global(a) {
    color: var(--accent);
    text-decoration: underline;
  }
</style>
