import { afterEach, describe, expect, it, vi } from 'vitest';
import { createBannerRotator, createScheduler, defaultBannerTimings, linkify, type BannerHost } from './banner';
import { attachBannerHost, bannerCurrent, configureBanner, detachBannerHost, remeasureBanner } from './bannerEngine';
import type { BannerTimings } from './model';

describe('linkify', () => {
  it('escapes html and renders markdown links', () => {
    expect(linkify('a < b & c')).toBe('a &lt; b &amp; c');
    expect(linkify('see [docs](https://x.com) now')).toBe(
      'see <a href="https://x.com" target="_blank" rel="noopener noreferrer">docs</a> now',
    );
  });
  it('drops a non-http(s) link target (keeps the label)', () => {
    expect(linkify('[x](ftp://evil)')).toBe('x');
    expect(linkify('[y](javascript:boom)')).toBe('y');
  });
  it('keeps root-relative links and renders several in one string', () => {
    expect(linkify('go [home](/lobby) or [docs](https://x.com)')).toBe(
      'go <a href="/lobby" target="_blank" rel="noopener noreferrer">home</a> or ' +
        '<a href="https://x.com" target="_blank" rel="noopener noreferrer">docs</a>',
    );
  });
});

describe('createScheduler', () => {
  it('serves each campaign its weight share over a cycle, evenly spread', () => {
    const sched = createScheduler([
      { weight: 3, messages: ['a'] },
      { weight: 7, messages: ['b'] },
    ]);
    expect(sched.total).toBe(2);
    const cycle = Array.from({ length: 10 }, () => sched.next().md);
    const a = cycle.filter((m) => m === 'a').length;
    const b = cycle.filter((m) => m === 'b').length;
    expect(a).toBe(3);
    expect(b).toBe(7);
    // Smoothly interleaved, not clustered: the minority campaign appears in both halves.
    expect(cycle.slice(0, 5)).toContain('a');
    expect(cycle.slice(5)).toContain('a');
  });

  it('round-robins messages within a campaign', () => {
    const sched = createScheduler([{ weight: 1, messages: ['m0', 'm1', 'm2'] }]);
    expect(sched.total).toBe(3);
    expect([sched.next().md, sched.next().md, sched.next().md, sched.next().md]).toEqual(['m0', 'm1', 'm2', 'm0']);
  });

  it('drops empty and non-positive-weight campaigns', () => {
    const sched = createScheduler([
      { weight: 0, messages: ['skip-zero'] },
      { weight: 5, messages: [] },
      { weight: 2, messages: ['keep'] },
    ]);
    expect(sched.total).toBe(1);
    expect(sched.next().md).toBe('keep');
  });

  it('is empty for no campaigns', () => {
    const sched = createScheduler([]);
    expect(sched.total).toBe(0);
    expect(sched.next().md).toBe('');
  });

  it('carries each campaign colour override with its message', () => {
    const red = { bg: '#aa0000', fg: '#ffffff', link: '#ffdd00' };
    const darkRed = { bg: '#330000', fg: '#eeeeee', link: '#ffcc00' };
    const sched = createScheduler([
      { weight: 1, messages: ['plain'] },
      { weight: 1, messages: ['branded'], overrideAll: red, overrideDark: darkRed },
    ]);
    const two = [sched.next(), sched.next()];
    const branded = two.find((it) => it.md === 'branded');
    const plain = two.find((it) => it.md === 'plain');
    expect(branded?.colors).toEqual({ all: red, dark: darkRed });
    expect(plain?.colors).toEqual({ all: null, dark: null });
  });
});

// recordingHost captures the rotator's host calls as a sequence, with a fixed overflow.
function recordingHost(overflow = 0): { host: BannerHost; calls: string[]; shown: string[] } {
  const calls: string[] = [];
  const shown: string[] = [];
  return {
    calls,
    shown,
    host: {
      show: (md) => {
        shown.push(md);
        calls.push('show');
      },
      resetScroll: () => calls.push('reset'),
      hide: () => calls.push('hide'),
      overflowPx: () => overflow,
      scrollTo: () => calls.push('scroll'),
      resumeScroll: () => calls.push('resume'),
    },
  };
}

const cfg: BannerTimings = {
  holdMs: 1000,
  edgePauseMs: 100,
  scrollPxPerSec: 200,
  fadeOutMs: 300,
  gapMs: 50,
  fadeInMs: 200,
};

describe('createBannerRotator', () => {
  afterEach(() => vi.useRealTimers());

  it('runs the fade-out / gap / fade-in sequence between two messages', () => {
    vi.useFakeTimers();
    const { host, calls, shown } = recordingHost(0);
    const r = createBannerRotator([{ weight: 1, messages: ['a'] }, { weight: 1, messages: ['b'] }], host, cfg);
    r.start();
    expect(shown).toEqual(['a']); // first message shown immediately
    vi.advanceTimersByTime(cfg.fadeInMs); // fade in -> measure (fits)
    vi.advanceTimersByTime(cfg.holdMs - 1);
    expect(calls).not.toContain('hide'); // still holding
    vi.advanceTimersByTime(1); // hold elapsed -> fade out
    expect(calls).toContain('hide');
    expect(shown).toEqual(['a']); // next not shown yet (during fade-out + gap)
    vi.advanceTimersByTime(cfg.fadeOutMs + cfg.gapMs); // -> show the next message
    expect(shown).toEqual(['a', 'b']);
    r.stop();
  });

  it('scrolls an overflowing message', () => {
    vi.useFakeTimers();
    const { host, calls } = recordingHost(200); // overflow 200px, 200px/s -> 1000ms scroll
    const r = createBannerRotator([{ weight: 1, messages: ['long'] }, { weight: 1, messages: ['x'] }], host, cfg);
    r.start();
    vi.advanceTimersByTime(cfg.fadeInMs + cfg.edgePauseMs); // fade in + edge pause -> scrollTo
    expect(calls).toContain('scroll');
    r.stop();
  });

  it('pulses a lone message through the fade cycle (fades out, then back in)', () => {
    vi.useFakeTimers();
    const { host, calls, shown } = recordingHost(0);
    const r = createBannerRotator([{ weight: 1, messages: ['solo'] }], host, cfg);
    r.start();
    expect(shown).toEqual(['solo']);
    vi.advanceTimersByTime(cfg.fadeInMs + cfg.holdMs); // hold elapses -> fade out
    expect(calls).toContain('hide');
    vi.advanceTimersByTime(cfg.fadeOutMs + cfg.gapMs + 1); // -> the same message fades back in
    expect(shown).toEqual(['solo', 'solo']);
    r.stop();
  });

  it('fades the scroll loop of a long message (out, rewind, in) instead of a hard reset', () => {
    vi.useFakeTimers();
    const c: BannerTimings = { holdMs: 30000, edgePauseMs: 100, scrollPxPerSec: 1000, fadeOutMs: 100, gapMs: 50, fadeInMs: 100 };
    const { host, calls } = recordingHost(200); // overflows -> scroll dur = 200ms
    const r = createBannerRotator([{ weight: 1, messages: ['long'] }], host, c);
    r.start();
    // fadeIn -> measure -> edgePause -> scrollTo -> (dur + edgePause) -> loop boundary
    vi.advanceTimersByTime(c.fadeInMs + c.edgePauseMs + 200 + c.edgePauseMs + 5);
    expect(calls).toContain('scroll');
    expect(calls).toContain('hide'); // the loop fades out at the right edge (not a hard reset)
    vi.advanceTimersByTime(c.fadeOutMs + c.gapMs + 5); // -> the same message fades back in
    expect(calls.filter((x) => x === 'show').length).toBeGreaterThanOrEqual(2);
    r.stop();
  });

  it('swaps instantly when fade timings are zero (reduce-motion)', () => {
    vi.useFakeTimers();
    const zero: BannerTimings = { ...cfg, fadeOutMs: 0, gapMs: 0, fadeInMs: 0 };
    const { host, shown } = recordingHost(0); // reduce-motion host reports no overflow
    const r = createBannerRotator([{ weight: 1, messages: ['a'] }, { weight: 1, messages: ['b'] }], host, zero);
    r.start();
    // fadeIn(0) + hold -> fadeOut(0) + gap(0) -> next; the +5 flushes the trailing
    // zero-delay timer chain that lands on the hold boundary.
    vi.advanceTimersByTime(zero.holdMs + 5);
    expect(shown).toEqual(['a', 'b']);
    r.stop();
  });

  it('stop() halts further advancement', () => {
    vi.useFakeTimers();
    const { host, shown } = recordingHost(0);
    const r = createBannerRotator([{ weight: 1, messages: ['a'] }, { weight: 1, messages: ['b'] }], host, cfg);
    r.start();
    vi.advanceTimersByTime(cfg.fadeInMs);
    r.stop();
    vi.advanceTimersByTime(60_000);
    expect(shown).toEqual(['a']);
  });

  it('does not start with no campaigns', () => {
    vi.useFakeTimers();
    const { host, shown } = recordingHost(0);
    const r = createBannerRotator([], host, defaultBannerTimings);
    r.start();
    vi.advanceTimersByTime(60_000);
    expect(shown).toEqual([]);
    r.stop();
  });
});

describe('bannerEngine', () => {
  afterEach(() => vi.useRealTimers());

  // A minimal host that records the messages it is told to show.
  function engineHost(): { host: BannerHost; shown: string[] } {
    const shown: string[] = [];
    return {
      shown,
      host: { show: (md) => shown.push(md), resetScroll() {}, hide() {}, overflowPx: () => 0, scrollTo() {}, resumeScroll() {} },
    };
  }

  const period = (c: BannerTimings) => c.fadeInMs + c.holdMs + c.fadeOutMs + c.gapMs + 5;

  const cfg: BannerTimings = { holdMs: 1000, edgePauseMs: 100, scrollPxPerSec: 200, fadeOutMs: 100, gapMs: 50, fadeInMs: 100 };

  it('keeps the live message across a host detach/attach, and keeps advancing (continuity)', () => {
    vi.useFakeTimers();
    const camps = [{ weight: 1, messages: ['x0'] }, { weight: 1, messages: ['x1'] }];
    configureBanner(camps, cfg);
    const a = engineHost();
    attachBannerHost(a.host);
    // Advance mid-sequence so the live message is no longer the first.
    vi.advanceTimersByTime(period(cfg));
    expect(bannerCurrent()).toBe('x1');

    // Remount the view (as navigation does): the live message is preserved (not reset to 'x0'),
    // and a fresh view receives no replayed show() on attach (it reads bannerCurrent() itself).
    detachBannerHost(a.host);
    const b = engineHost();
    attachBannerHost(b.host);
    expect(bannerCurrent()).toBe('x1');
    expect(b.shown).toEqual([]);

    // The engine keeps advancing and drives the new host — the cycle continued, not restarted.
    vi.advanceTimersByTime(period(cfg));
    expect(b.shown).toContain('x0');
    expect(bannerCurrent()).toBe('x0');
    detachBannerHost(b.host);
  });

  it('does not restart on a no-op reconfigure with unchanged data', () => {
    vi.useFakeTimers();
    const camps = [{ weight: 1, messages: ['y0'] }, { weight: 1, messages: ['y1'] }];
    configureBanner(camps, cfg);
    const a = engineHost();
    attachBannerHost(a.host);
    vi.advanceTimersByTime(period(cfg));
    expect(bannerCurrent()).toBe('y1');

    // Re-configuring with identical data keeps the running cycle (the live message is unchanged).
    configureBanner(camps, cfg);
    vi.advanceTimersByTime(5);
    expect(bannerCurrent()).toBe('y1');
    detachBannerHost(a.host);
  });

  it('re-presents the current message on remeasure, engaging the scroll on a size change', () => {
    vi.useFakeTimers();
    const big: BannerTimings = { ...cfg, holdMs: 5000 };
    const shown: string[] = [];
    let scrolled = 0;
    let overflow = 0;
    const host: BannerHost = {
      show: (md) => shown.push(md),
      resetScroll() {},
      hide() {},
      overflowPx: () => overflow,
      scrollTo: () => scrolled++,
      resumeScroll() {},
    };
    configureBanner([{ weight: 1, messages: ['z0'] }], big);
    attachBannerHost(host);
    vi.advanceTimersByTime(big.fadeInMs + 5); // initial measure: fits, no scroll
    expect(scrolled).toBe(0);

    // The viewport shrank so the message now overflows; remeasure re-presents it and the scroll
    // engages (the owner accepts the cycle restarting on a size change).
    overflow = 300;
    remeasureBanner();
    expect(shown).toContain('z0');
    vi.advanceTimersByTime(big.fadeInMs + big.edgePauseMs + 5);
    expect(scrolled).toBeGreaterThan(0);
    detachBannerHost(host);
  });

  it('resumes a long message scroll mid-flight on a new host (navigation)', () => {
    vi.useFakeTimers();
    const big: BannerTimings = { holdMs: 10000, edgePauseMs: 100, scrollPxPerSec: 100, fadeOutMs: 100, gapMs: 50, fadeInMs: 100 };
    const noop: BannerHost = { show() {}, resetScroll() {}, hide() {}, overflowPx: () => 500, scrollTo() {}, resumeScroll() {} };
    let resumed: { fromTx: number; toPx: number; dur: number } | null = null;
    const b: BannerHost = { ...noop, resumeScroll: (fromTx, toPx, dur) => (resumed = { fromTx, toPx, dur }) };

    configureBanner([{ weight: 1, messages: ['scrollme'] }], big);
    attachBannerHost(noop);
    // fadeIn(100) + edgePause(100) -> scrollTo (dur = 500/100*1000 = 5000ms); then 2000ms into it.
    vi.advanceTimersByTime(big.fadeInMs + big.edgePauseMs + 2000);

    // Remount mid-scroll: the new host resumes from ~40% of the way, not from the start.
    detachBannerHost(noop);
    attachBannerHost(b);
    expect(resumed).not.toBeNull();
    expect(resumed!.toPx).toBe(500);
    expect(resumed!.fromTx).toBeLessThan(0); // already scrolled left of 0
    expect(resumed!.fromTx).toBeGreaterThan(-500); // but not yet at the end
    expect(resumed!.dur).toBeGreaterThan(0);
    expect(resumed!.dur).toBeLessThan(5000); // only the remaining time
    detachBannerHost(b);
  });
});
