import assert from 'node:assert/strict';
import test from 'node:test';
import {
  buildMediaTimingLineSelection,
  buildMediaTimingWaveformPath,
  constrainMediaTimingSelection,
  createMediaTimingPreviewRequestGuard,
  formatMediaTimingTimestamp,
  mediaTimingTimeFromPointer,
  slideMediaTimingSelection,
  trimMediaTimingSelectionEnd,
} from './media-timing-review';

test('waveform path mirrors normalized peaks around its center line', () => {
  const path = buildMediaTimingWaveformPath([0, 0.5, 1]);

  assert.match(path, /^M 0\.00 50\.00 L 500\.00 28\.00 L 1000\.00 6\.00/);
  assert.match(path, /1000\.00 94\.00 L 500\.00 72\.00 L 0\.00 50\.00 Z$/);
  assert.equal(buildMediaTimingWaveformPath([1]), '');
});

test('formatMediaTimingTimestamp renders stable review readouts', () => {
  assert.equal(formatMediaTimingTimestamp(65.4321), '01:05.432');
  assert.equal(formatMediaTimingTimestamp(65.4321, false), '01:05');
  assert.equal(formatMediaTimingTimestamp(-1), '00:00.000');
  assert.equal(formatMediaTimingTimestamp(119.9999), '02:00.000');
  assert.equal(formatMediaTimingTimestamp(119.6, false), '02:00');
});

test('selection constraints preserve the handle that did not move', () => {
  assert.deepEqual(
    constrainMediaTimingSelection({
      nextStart: 3,
      nextEnd: 2,
      currentStart: 1,
      timelineStart: 0,
      timelineEnd: 10,
      mediaEnd: 10,
      maxMediaDuration: 30,
    }),
    { start: 1.9, end: 2 },
  );
  assert.deepEqual(
    constrainMediaTimingSelection({
      nextStart: 1,
      nextEnd: 0.5,
      currentStart: 1,
      timelineStart: 0,
      timelineEnd: 10,
      mediaEnd: 10,
      maxMediaDuration: 30,
    }),
    { start: 1, end: 1.1 },
  );
});

test('pointer positions map onto the visible timeline and clamp past its edges', () => {
  const track = { trackLeft: 100, trackWidth: 400, timelineStart: 10, timelineEnd: 20 };

  assert.equal(mediaTimingTimeFromPointer({ clientX: 100, ...track }), 10);
  assert.equal(mediaTimingTimeFromPointer({ clientX: 300, ...track }), 15);
  assert.equal(mediaTimingTimeFromPointer({ clientX: -40, ...track }), 10);
  assert.equal(mediaTimingTimeFromPointer({ clientX: 900, ...track }), 20);
  assert.equal(mediaTimingTimeFromPointer({ ...track, clientX: 300, trackWidth: 0 }), 10);
});

test('sliding keeps the clip length and stops at the timeline and media bounds', () => {
  const timeline = { span: 2, timelineStart: 4, timelineEnd: 12, mediaEnd: 10 };

  assert.deepEqual(slideMediaTimingSelection({ nextStart: 6, ...timeline }), { start: 6, end: 8 });
  assert.deepEqual(slideMediaTimingSelection({ nextStart: 1, ...timeline }), { start: 4, end: 6 });
  assert.deepEqual(slideMediaTimingSelection({ nextStart: 99, ...timeline }), {
    start: 8,
    end: 10,
  });
});

test('line selection combines adjacent lines around the mined one and tracks their range', () => {
  const base = {
    previousLines: [
      { text: '一行目', startTime: 0, endTime: 2 },
      { text: '二行目', startTime: 2.5, endTime: 4 },
    ],
    nextLines: [
      { text: '四行目', startTime: 7.5, endTime: 9 },
      { text: '五行目', startTime: 9.5, endTime: 11 },
    ],
    text: '採掘行',
    originalStartTime: 5,
    originalEndTime: 7,
  };

  const none = buildMediaTimingLineSelection({ ...base, previousCount: 0, nextCount: 0 });
  assert.deepEqual(none.lineTexts, ['採掘行']);
  assert.equal(none.currentLineIndex, 0);
  assert.equal(none.rangeStart, 5);
  assert.equal(none.rangeEnd, 7);

  const expanded = buildMediaTimingLineSelection({ ...base, previousCount: 1, nextCount: 2 });
  assert.deepEqual(expanded.lineTexts, ['二行目', '採掘行', '四行目', '五行目']);
  assert.equal(expanded.currentLineIndex, 1);
  assert.equal(expanded.sentence, '二行目 採掘行 四行目 五行目');
  assert.equal(expanded.rangeStart, 2.5);
  assert.equal(expanded.rangeEnd, 11);
});

test('preview request guard blocks overlap and invalidates stale responses', () => {
  const guard = createMediaTimingPreviewRequestGuard();
  const first = guard.begin();

  assert.equal(typeof first, 'number');
  assert.equal(guard.begin(), null);
  assert.equal(guard.isCurrent(first!), true);

  guard.invalidate();
  assert.equal(guard.isCurrent(first!), false);
  assert.equal(guard.isInFlight(), false);

  const second = guard.begin();
  assert.equal(typeof second, 'number');
  guard.finish(first!);
  assert.equal(guard.isCurrent(second!), true);
  guard.finish(second!);
  assert.equal(guard.isInFlight(), false);
});

test('trailing silence trim follows the last speech slice and keeps cut-off or silent lines', () => {
  // 10 slices over a 10 s timeline: one slice per second, speech in seconds 2-4 only.
  const peaks = [0, 0, 0.9, 0.8, 0.7, 0.1, 0.2, 0, 0, 0];
  const base = { peaks, timelineStart: 0, timelineEnd: 10, lineStart: 2, lineEnd: 8 };

  const trimmed = trimMediaTimingSelectionEnd({ ...base, selectionEnd: 8, endPadSeconds: 0 });
  assert.ok(trimmed !== null && Math.abs(trimmed - 5.15) < 1e-9);

  const padded = trimMediaTimingSelectionEnd({ ...base, selectionEnd: 8.5, endPadSeconds: 0.5 });
  assert.ok(padded !== null && Math.abs(padded - 5.65) < 1e-9);

  // Speech running through the line end means the subtitle cuts the audio off; keep it.
  assert.equal(
    trimMediaTimingSelectionEnd({ ...base, lineEnd: 5, selectionEnd: 5, endPadSeconds: 0 }),
    null,
  );
  // No speech inside the line at all: keep the subtitle timing rather than guess.
  assert.equal(
    trimMediaTimingSelectionEnd({ ...base, lineStart: 6, selectionEnd: 8, endPadSeconds: 0 }),
    null,
  );
  // A saving below the minimum trim is not worth moving the handle for.
  assert.equal(
    trimMediaTimingSelectionEnd({ ...base, lineEnd: 5.2, selectionEnd: 5.2, endPadSeconds: 0 }),
    null,
  );
  assert.equal(
    trimMediaTimingSelectionEnd({ ...base, peaks: [1], selectionEnd: 8, endPadSeconds: 0 }),
    null,
  );
});
