// Advertising banner — the server-driven rotation engine plus a tiny markdown
// linkifier. The scheduler and rotator are DOM-agnostic (the host measures overflow
// and applies the visual effects through callbacks), so their timing and fairness are
// unit-testable with fake timers. The campaigns + timings come from the profile's
// banner block (see lib/model, ARCHITECTURE §10); the host is AdBanner.svelte.

import type { BannerCampaign, BannerColors, BannerTimings } from './model';

/** Fallback display timings, matching the seeded ad_settings defaults. */
export const defaultBannerTimings: BannerTimings = {
  holdMs: 60_000,
  edgePauseMs: 5_000,
  scrollPxPerSec: 40,
  fadeOutMs: 1_000,
  gapMs: 250,
  fadeInMs: 1_000,
};

/** BannerItem is one scheduled message plus its campaign's colour override (for the strip). */
export interface BannerItem {
  md: string;
  colors: BannerColors;
}

/** Scheduler yields the next message to show, fairly across weighted campaigns. */
export interface Scheduler {
  next(): BannerItem;
  /** Total messages across all (non-empty, positive-weight) campaigns. */
  readonly total: number;
}

/**
 * createScheduler builds a smooth weighted round-robin over campaigns: each call to
 * next() picks the campaign whose running weight is highest (then decremented by the
 * total weight), so over one cycle of `sum(weights)` picks every campaign appears
 * exactly its weight share, evenly interleaved rather than clustered (the nginx/envoy
 * algorithm). Within a campaign its messages advance round-robin. Campaigns with no
 * messages or a non-positive weight are dropped.
 */
export function createScheduler(campaigns: BannerCampaign[]): Scheduler {
  const cs = campaigns.filter((c) => c.weight > 0 && c.messages.length > 0);
  const colors: BannerColors[] = cs.map((c) => ({ all: c.overrideAll ?? null, dark: c.overrideDark ?? null }));
  const current = cs.map(() => 0); // SWRR running weights
  const cursor = cs.map(() => 0); // per-campaign round-robin position
  const totalWeight = cs.reduce((sum, c) => sum + c.weight, 0);
  const total = cs.reduce((sum, c) => sum + c.messages.length, 0);
  const empty: BannerColors = { all: null, dark: null };

  return {
    total,
    next(): BannerItem {
      if (cs.length === 0) return { md: '', colors: empty };
      let best = 0;
      for (let i = 0; i < cs.length; i++) {
        current[i] += cs[i].weight;
        if (current[i] > current[best]) best = i;
      }
      current[best] -= totalWeight;
      const c = cs[best];
      const md = c.messages[cursor[best] % c.messages.length];
      cursor[best]++;
      return { md, colors: colors[best] };
    },
  };
}

/** The host the rotator drives; AdBanner.svelte supplies the DOM measurements and effects. */
export interface BannerHost {
  /** Render md and fade it in (over fadeInMs), with the scroll reset to the start. colors is the
   *  source campaign's colour override, applied to the strip (its neutral tokens when unset). */
  show(md: string, colors: BannerColors): void;
  /** Reset the scroll to the start without re-fading (to loop a long message). */
  resetScroll(): void;
  /** Fade the current message out over durationMs. */
  hide(durationMs: number): void;
  /** Overflow width of the currently-shown message in px (0 when it fits or motion is reduced). */
  overflowPx(): number;
  /** Animate the horizontal scroll to toPx over durationMs. */
  scrollTo(toPx: number, durationMs: number): void;
  /** Resume an in-flight scroll on a freshly-mounted view: jump to fromTx (px, signed) instantly,
   *  then continue to toPx over durationMs. Used by the engine on attach to carry the scroll
   *  position across a navigation; the rotator itself never calls it. */
  resumeScroll(fromTx: number, toPx: number, durationMs: number): void;
}

export interface Rotator {
  start(): void;
  stop(): void;
  /** Re-present the current message (re-measure overflow + restart its scroll), e.g. after the
   *  viewport size changed. A no-op before start(). */
  restart(): void;
}

/**
 * createBannerRotator rotates the scheduled messages: each message fades in, holds
 * `holdMs` (an overflowing one scrolls to its right edge and back while under holdMs),
 * then fades out over `fadeOutMs`, waits `gapMs`, and fades the next one in. A lone
 * message pulses (the same message fades back in) so the cycle keeps its rhythm.
 * Reduce-motion is handled by the host: pass zeroed fade timings and an overflowPx that
 * returns 0, and messages swap instantly without scrolling.
 */
export function createBannerRotator(
  campaigns: BannerCampaign[],
  host: BannerHost,
  config: BannerTimings = defaultBannerTimings,
): Rotator {
  const sched = createScheduler(campaigns);
  let running = false;
  let cycleStart = 0;
  let lastShown: BannerItem | null = null; // the item currently presented, for restart() (re-measure)
  const timers: ReturnType<typeof setTimeout>[] = [];

  const at = (ms: number, fn: () => void) => {
    timers.push(setTimeout(fn, ms));
  };
  const clear = () => {
    for (const t of timers) clearTimeout(t);
    timers.length = 0;
  };

  function present(item: BannerItem) {
    if (!running) return;
    clear();
    lastShown = item;
    host.show(item.md, item.colors);
    at(config.fadeInMs, measure);
  }

  function measure() {
    if (!running) return;
    cycleStart = Date.now();
    const over = host.overflowPx();
    if (over > 0) {
      scrollCycle(over);
      return;
    }
    // Hold, then run the fade cycle. A lone message pulses (fades out, then back in) rather than
    // sitting frozen, so the banner keeps its rhythm even with a single campaign message.
    at(config.holdMs, advance);
  }

  function scrollCycle(over: number) {
    const dur = (over / config.scrollPxPerSec) * 1000;
    at(config.edgePauseMs, () => {
      host.scrollTo(over, dur);
      at(dur + config.edgePauseMs, () => {
        if (Date.now() - cycleStart >= config.holdMs) {
          advance();
        } else {
          // Loop the same message with a fade: fade out at the right edge, rewind to the start
          // while hidden, fade the same message back in, then scroll again — so the rewind gets
          // the same fade as a message change, not a hard jump.
          host.hide(config.fadeOutMs);
          at(config.fadeOutMs + config.gapMs, () => {
            if (lastShown) host.show(lastShown.md, lastShown.colors);
            at(config.fadeInMs, () => scrollCycle(over));
          });
        }
      });
    });
  }

  function advance() {
    if (!running) return;
    host.hide(config.fadeOutMs);
    at(config.fadeOutMs + config.gapMs, () => present(sched.next()));
  }

  return {
    start() {
      if (running || sched.total === 0) return;
      running = true;
      present(sched.next());
    },
    stop() {
      running = false;
      clear();
    },
    restart() {
      if (running && lastShown) present(lastShown);
    },
  };
}

const URL_RE = /^(https?:\/\/|\/)/i;

function escapeHtml(s: string): string {
  return s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]!);
}

/**
 * linkify renders minimal markdown to a safe HTML string: everything is escaped, then
 * `[label](url)` becomes a link (only http(s):// or root-relative URLs are allowed).
 */
export function linkify(md: string): string {
  const parts: string[] = [];
  const re = /\[([^\]]+)\]\(([^)]+)\)/g;
  let last = 0;
  let m: RegExpExecArray | null;
  while ((m = re.exec(md)) !== null) {
    parts.push(escapeHtml(md.slice(last, m.index)));
    const label = escapeHtml(m[1]);
    const url = m[2].trim();
    if (URL_RE.test(url)) {
      parts.push(`<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${label}</a>`);
    } else {
      parts.push(label);
    }
    last = re.lastIndex;
  }
  parts.push(escapeHtml(md.slice(last)));
  return parts.join('');
}
