'use strict';

// #239: the playlist preview had no way to skip, so reviewing item 8 of a playlist meant sitting
// through items 1–7 in real time. The fix drives the player the preview ALREADY embeds (an iframe
// of /player?preview=1) over postMessage, so there is no second copy of the playback logic.
//
// Two things can go wrong, and neither shows up in a screenshot:
//
//   1. The index maths. A negative modulo or an off-by-one lands on an index that isn't in the
//      playlist, and playlist[idx] === undefined mounts nothing — the operator sees a black frame
//      and assumes the content is broken. "Previous" is the dangerous direction: JS's % returns a
//      negative for a negative left operand.
//   2. The blast radius. A control that reached a REAL display would let an operator skip content
//      on a wall in front of customers — far worse than the bug being fixed. So the channel is
//      asserted here at the source level: only a player that booted in preview mode may be steered,
//      and the dashboard addresses exactly one iframe rather than broadcasting.

const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');

const PLAYER = fs.readFileSync(path.join(__dirname, '..', 'player', 'index.html'), 'utf8');
const DASHBOARD = fs.readFileSync(
  path.join(__dirname, '..', '..', 'frontend', 'js', 'views', 'playlists.js'), 'utf8');

// Same lift as the other player tests: pull one pure function out of the single-file player and
// run it directly, so the maths is testable without a browser.
function lift(name) {
  const start = PLAYER.indexOf(`function ${name}(`);
  assert.notEqual(start, -1, `${name}() should exist in the player`);
  let depth = 0;
  for (let j = PLAYER.indexOf('{', start); j < PLAYER.length; j++) {
    if (PLAYER[j] === '{') depth++;
    else if (PLAYER[j] === '}' && --depth === 0) {
      return new Function(`${PLAYER.slice(start, j + 1)} return ${name};`)();
    }
  }
  throw new Error('unbalanced braces reading ' + name);
}
const previewStepIndex = lift('previewStepIndex');

const NEXT = 1;
const PREV = -1;

test('next walks the playlist in order', () => {
  assert.equal(previewStepIndex(0, NEXT, 7), 1);
  assert.equal(previewStepIndex(5, NEXT, 7), 6);
});

test('next wraps past the last item back to the first', () => {
  assert.equal(previewStepIndex(6, NEXT, 7), 0);
});

test('THE BUG previous must not reproduce: stepping back from item 1 wraps to the LAST item', () => {
  // (0 - 1) % 7 is -1 in JavaScript, and playlist[-1] is undefined — a black frame.
  assert.equal(previewStepIndex(0, PREV, 7), 6);
});

test('previous walks backwards', () => {
  assert.equal(previewStepIndex(6, PREV, 7), 5);
  assert.equal(previewStepIndex(1, PREV, 7), 0);
});

test('a one-item playlist lands on that item in either direction', () => {
  // Re-rendering the single item is the honest answer: there is nowhere else to go, and returning
  // -1 would idle a preview that has perfectly good content.
  assert.equal(previewStepIndex(0, NEXT, 1), 0);
  assert.equal(previewStepIndex(0, PREV, 1), 0);
});

test('an empty playlist has nowhere to go', () => {
  assert.equal(previewStepIndex(0, NEXT, 0), -1);
  assert.equal(previewStepIndex(-1, PREV, 0), -1);
});

test('a player that has not started yet still lands on a real item', () => {
  // currentIndex is -1 until the first item mounts; a press during that window must not produce
  // -2 or NaN.
  assert.equal(previewStepIndex(-1, NEXT, 4), 0);
  assert.equal(previewStepIndex(-1, PREV, 4), 2);
});

test('a corrupt or out-of-range index is normalised, never propagated', () => {
  assert.equal(previewStepIndex(NaN, NEXT, 4), 1);
  assert.equal(previewStepIndex(undefined, NEXT, 4), 1);
  assert.equal(previewStepIndex(9, NEXT, 4), 2);      // 9 % 4 === 1 -> next is 2
  assert.equal(previewStepIndex(-9, NEXT, 4), 0);     // -9 normalises to 3 -> wraps to 0
  assert.equal(previewStepIndex(1.7, NEXT, 4), 2);
});

test('a non-numeric length is treated as no playlist rather than looping forever', () => {
  assert.equal(previewStepIndex(0, NEXT, NaN), -1);
  assert.equal(previewStepIndex(0, NEXT, undefined), -1);
  assert.equal(previewStepIndex(0, NEXT, -3), -1);
});

test('dayparted items are skipped IN THE DIRECTION OF TRAVEL', () => {
  // Falling forward past a filtered item would make "previous" walk forwards, which reads to the
  // operator as the button being broken.
  const off = new Set([1, 2]);
  const allows = (i) => !off.has(i);
  assert.equal(previewStepIndex(0, NEXT, 5, allows), 3);
  assert.equal(previewStepIndex(3, PREV, 5, allows), 0);
  assert.equal(previewStepIndex(0, PREV, 5, allows), 4);
});

test('every item outside its schedule window means nowhere to go, not item 0', () => {
  // The caller idles on -1. Returning an index here would mount content the schedule says must not
  // be on screen — the preview would then be lying about what the display will show.
  assert.equal(previewStepIndex(2, NEXT, 5, () => false), -1);
  assert.equal(previewStepIndex(2, PREV, 5, () => false), -1);
});

test('a step lands on an allowed item even when it has to wrap the whole list', () => {
  const allows = (i) => i === 0;
  assert.equal(previewStepIndex(0, NEXT, 6, allows), 0);
  assert.equal(previewStepIndex(4, PREV, 6, allows), 0);
});

// ---- Blast radius: the control must never reach a live display ----

test('only a preview instance can be steered', () => {
  // previewNavigate is the single entry point for a skip, and PREVIEW_MODE is set exactly once,
  // in the ?preview=1 boot path (renderPreviewFromUrl) — a paired display never sets it.
  const nav = PLAYER.slice(PLAYER.indexOf('function previewNavigate('));
  assert.match(nav.slice(0, 200), /if \(!PREVIEW_MODE\) return;/,
    'previewNavigate must refuse to act outside preview mode');
  assert.equal((PLAYER.match(/PREVIEW_MODE = true;/g) || []).length, 1,
    'preview mode should have exactly one assignment — the preview boot path');
});

test('the message listener exists only in preview mode and only for our own origin', () => {
  // A live player never installs the listener at all, so a page that iframes a real display cannot
  // even attempt a skip; the origin check stops a third-party framer of the preview itself.
  const calls = PLAYER.match(/installPreviewControlChannel\(\)/g) || [];
  assert.equal(calls.length, 2, 'expected one definition call site plus the preview boot call');
  const boot = PLAYER.slice(PLAYER.indexOf('PREVIEW_MODE = true;'),
    PLAYER.indexOf('PREVIEW_MODE = true;') + 200);
  assert.match(boot, /installPreviewControlChannel\(\)/,
    'the channel must be installed by the preview boot, not at load time');
  const channel = PLAYER.slice(PLAYER.indexOf('function installPreviewControlChannel('));
  assert.match(channel.slice(0, 800), /ev\.origin !== window\.location\.origin/,
    'cross-origin messages must be rejected');
  assert.match(channel.slice(0, 800), /d\.source !== 'screentinker-preview'/,
    'unrelated postMessage traffic (extensions, embeds) must be ignored');
});

test('preview state is posted to our origin only — never "*"', () => {
  // "*" would hand the workspace's playlist contents to any page that framed the player.
  const post = PLAYER.slice(PLAYER.indexOf('function postPreviewState('));
  assert.match(post.slice(0, 900), /window\.location\.origin\)/);
  assert.doesNotMatch(post.slice(0, 900), /postMessage\([^)]*'\*'/);
});

test('the dashboard addresses the preview iframe, not a broadcast', () => {
  // Broadcasting would still not reach a display (they hold a server socket, not a window handle),
  // but addressing one contentWindow keeps the intent unambiguous and pins the target origin.
  assert.match(DASHBOARD, /frame\.contentWindow\?\.postMessage\(\{ source: 'screentinker-preview', action \}, window\.location\.origin\)/);
  assert.match(DASHBOARD, /ev\.source !== frame\.contentWindow/);
  assert.match(DASHBOARD, /ev\.origin !== window\.location\.origin/);
});

test('the preview modal cleans up its window-level listeners on close', () => {
  // They outlive the overlay otherwise, and a leaked keydown handler keeps posting arrow-key skips
  // at a preview the operator already closed.
  const modal = DASHBOARD.slice(DASHBOARD.indexOf('function showPlaylistPreview('),
    DASHBOARD.indexOf('function layoutMockup('));
  assert.match(modal, /window\.removeEventListener\('message', onPlayerMessage\)/);
  assert.match(modal, /document\.removeEventListener\('keydown', onKey\)/);
});
