'use strict';

const test = require('node:test');
const assert = require('node:assert/strict');

const CaptureService = require('../../app/capture');

function makeService({ settings: settingsOverrides, screenApi } = {}) {
  const store = {
    addStep() {
      throw new Error('not used in this test');
    },
  };
  const settingsData = {
    'capture.mode': 'fullscreen',
    'capture.delayMs': 0,
    ...settingsOverrides,
  };
  const settings = {
    get(key) {
      return key in settingsData ? settingsData[key] : null;
    },
  };
  return new CaptureService({
    store,
    settings,
    getWindow: () => null,
    notify: () => {},
    screenApi: screenApi || {
      getCursorScreenPoint: () => ({ x: 0, y: 0 }),
      getAllDisplays: () => [],
    },
  });
}

// The raw/regular twin window plus margin: how long a test must wait for a
// held Linux raw press to fire when no coordinate twin arrives.
const TWIN_FLUSH_MS = 60;
const settle = (ms = TWIN_FLUSH_MS) => new Promise((r) => setTimeout(r, ms));

function makeFrame(name, ageMs = 0, overrides = {}) {
  return {
    mode: overrides.mode || 'fullscreen',
    png: Buffer.from(name),
    size: overrides.size || { width: 100, height: 100 },
    display: overrides.display || { bounds: { x: 0, y: 0, width: 100, height: 100 } },
    cursor: overrides.cursor || { x: 50, y: 50 },
    capturedAt: Date.now() - ageMs,
  };
}

// ---- fresh-shot fallback path ----------------------------------------------

test('click-triggered session capture uses the low-latency hide pause', async () => {
  const service = makeService();
  service.session = { guideId: 'guide-1', paused: false, count: 0, intervalSec: 0 };

  let seenOptions = null;
  service.shoot = async (options) => {
    seenOptions = options;
    return { ok: true, step: { stepId: 'step-1' } };
  };

  const result = await service.sessionCapture('click');

  assert.equal(result.ok, true);
  assert.equal(service.session.count, 1);
  assert.deepEqual(seenOptions, {
    guideId: 'guide-1',
    mode: 'fullscreen',
    delayMs: 0,
    hideWindowDelayMs: 25,
    refocus: false,
    clickPos: null,
  });
});

// ---- Linux watcher parsing ---------------------------------------------------

test('rapid click watcher bursts are parsed one click at a time', () => {
  const service = makeService();
  let clicks = 0;
  service.onOsClick = () => {
    clicks += 1;
  };

  service.processClickWatcherData([
    'EVENT type 17 (RawButtonPress)',
    'EVENT type 18 (RawButtonRelease)',
    'EVENT type 17 (RawButtonPress)',
    'EVENT type 18 (RawButtonRelease)',
  ].join('\n'), 'linux');

  assert.equal(clicks, 2);
});

test('raw button presses fire; scroll-wheel ticks (buttons 4-7) are ignored', async () => {
  const service = makeService();
  let clicks = 0;
  service.onOsClick = () => {
    clicks += 1;
  };

  service.processClickWatcherData([
    'EVENT type 15 (RawButtonPress)',
    '    device: 11 (11)',
    '    detail: 1',
    '    valuators:',
    'EVENT type 15 (RawButtonPress)', // scroll-wheel tick
    '    device: 11 (11)',
    '    detail: 4',
    'EVENT type 15 (RawButtonPress)', // horizontal scroll
    '    device: 11 (11)',
    '    detail: 7',
    'EVENT type 15 (RawButtonPress)',
    '    device: 11 (11)',
    '    detail: 3',
  ].join('\n'), 'linux');

  await settle(); // raw presses hold briefly for a coordinate twin
  assert.equal(clicks, 2, 'buttons 4-7 are scroll ticks, not clicks');
});

test('regular ButtonPress blocks carry their root coordinates into onOsClick', () => {
  // The event-time root position is what keeps the marker on the real click
  // even when the pointer keeps moving after the press — a live cursor read
  // at parse time would drift.
  const service = makeService();
  const seen = [];
  service.onOsClick = (at, osPoint, button) => {
    seen.push({ osPoint, button });
  };

  service.processClickWatcherData([
    'EVENT type 4 (ButtonPress)',
    '    device: 11 (10)',
    '    detail: 1',
    '    flags:',
    '    root: 644.52/343.55',
    '    event: 644.52/343.55',
  ].join('\n'), 'linux');

  assert.deepEqual(seen, [{ osPoint: { x: 645, y: 344 }, button: 'button-1' }]);
});

test('a raw press and its regular twin merge into a single click with coordinates', async () => {
  // One physical press can be delivered as both a RawButtonPress and a
  // ButtonPress block. That duplication is resolved structurally — never by
  // a time debounce that could swallow real fast clicks.
  const service = makeService();
  const seen = [];
  service.onOsClick = (at, osPoint, button) => {
    seen.push({ osPoint, button });
  };

  service.processClickWatcherData([
    'EVENT type 15 (RawButtonPress)',
    '    device: 11 (11)',
    '    detail: 1',
    '    valuators:',
    'EVENT type 4 (ButtonPress)',
    '    device: 11 (10)',
    '    detail: 1',
    '    root: 100.00/200.00',
  ].join('\n'), 'linux');

  await settle();
  assert.deepEqual(seen, [{ osPoint: { x: 100, y: 200 }, button: 'button-1' }],
    'exactly one click, carrying the regular twin\'s coordinates');
});

test('two genuine fast presses of the same button both fire', async () => {
  const service = makeService();
  const seen = [];
  service.onOsClick = (at, osPoint, button) => {
    seen.push({ osPoint, button });
  };

  service.processClickWatcherData([
    'EVENT type 4 (ButtonPress)',
    '    detail: 1',
    '    root: 10.00/10.00',
    'EVENT type 4 (ButtonPress)',
    '    detail: 1',
    '    root: 12.00/11.00',
  ].join('\n'), 'linux');

  await settle();
  assert.equal(seen.length, 2, 'fast clicking must never be dropped by the parser');
});

test('motion events with detail lines do not fire clicks', () => {
  const service = makeService();
  let clicks = 0;
  service.onOsClick = () => {
    clicks += 1;
  };

  service.processClickWatcherData([
    'EVENT type 17 (RawMotion)',
    '    device: 11 (11)',
    '    detail: 0',
    '    valuators:',
  ].join('\n'), 'linux');

  assert.equal(clicks, 0);
});

test('event lines split across stdout chunks are reassembled before parsing', async () => {
  const service = makeService();
  let clicks = 0;
  service.onOsClick = () => {
    clicks += 1;
  };

  service.ingestClickWatcherChunk('EVENT type 15 (RawButt', 'linux');
  assert.equal(clicks, 0, 'a partial line must not be parsed yet');
  service.ingestClickWatcherChunk('onPress)\n    detail: 1\n', 'linux');

  await settle();
  assert.equal(clicks, 1);
});

// ---- click queue --------------------------------------------------------------

test('clicks queue behind an in-progress capture instead of being dropped', async () => {
  const service = makeService();
  const order = [];
  let releaseFirst;
  const firstGate = new Promise((r) => { releaseFirst = r; });
  service.sessionCapture = async (trigger, clickPos) => {
    order.push(`start-${clickPos.x}`);
    if (clickPos.x === 1) await firstGate;
    order.push(`done-${clickPos.x}`);
    return { ok: true };
  };

  service.enqueueClickCapture({ x: 1, y: 1 });
  const second = service.enqueueClickCapture({ x: 2, y: 2 });
  releaseFirst();
  await second;

  assert.deepEqual(order, ['start-1', 'done-1', 'start-2', 'done-2'],
    'the second click must run after the first, not be dropped');
});

test('fast clicks are paired with their frames at event time, not behind the store queue', async () => {
  // With a slow PNG encode or store, the click queue can run seconds late.
  // The frame request must go out at click time anyway, or the second
  // click's frame would be selected (and possibly evicted) far too late.
  const service = makeService();
  service.session = { guideId: 'guide-eager', paused: false, count: 0, intervalSec: 0 };
  service.userIsInApp = () => false;
  const requested = [];
  let releaseFirst;
  const firstGate = new Promise((r) => { releaseFirst = r; });
  service.frameForClick = (clickPos, clickAt) => {
    requested.push(clickAt);
    const frame = makeFrame(`frame-${clickAt}`);
    return clickAt === 1000 ? firstGate.then(() => frame) : Promise.resolve(frame);
  };
  let stored = 0;
  service.storeFrameAsStep = () => {
    stored += 1;
    return { ok: true, step: { stepId: `step-${stored}` } };
  };

  service.enqueueClickCapture({ x: 1, y: 1 }, 1000, 'left');
  const queue = service.enqueueClickCapture({ x: 2, y: 2 }, 1040, 'left');

  assert.deepEqual(requested, [1000, 1040],
    'both frames must be requested immediately, while the first store is still pending');
  releaseFirst();
  await queue;
  assert.equal(stored, 2);
  assert.equal(service.session.count, 2);
});

test('clicks still queued when the session finishes are stored, not dropped', async () => {
  // Reported as "I clicked N times but only got two screenshots": with slow
  // encodes the queue lags, and finishing the session used to discard every
  // click still waiting in it.
  const service = makeService();
  service.session = { guideId: 'guide-finish', paused: false, count: 0, intervalSec: 0 };
  service.userIsInApp = () => false;
  let releaseFrame;
  const frameGate = new Promise((r) => { releaseFrame = r; });
  service.frameForClick = () => frameGate.then(() => makeFrame('late-stored-frame'));
  const added = [];
  service.store.addStep = (guideId, fields, png) => {
    added.push({ guideId, png: png.toString() });
    return { stepId: 'step-late' };
  };
  const events = [];
  service.notify = (channel, payload) => events.push({ channel, payload });

  // Click happened comfortably before the user reached for the stop button.
  const queue = service.enqueueClickCapture({ x: 5, y: 5 }, Date.now() - 2000, 'left');
  service.finishSession();
  releaseFrame();
  await queue;

  assert.deepEqual(added, [{ guideId: 'guide-finish', png: 'late-stored-frame' }],
    'the click was recorded while the session was live — it must become a step');
  const addedEvent = events.find((e) => e.channel === 'capture:added');
  assert.equal(addedEvent.payload.guideId, 'guide-finish');
});

test('the tray click that stops the session does not become a junk step', async () => {
  // The tray gesture that stops capture is also seen by the OS hook; storing
  // it would append a step of the tray/menu to every recording. It is
  // matched by position so only that exact click is dropped.
  const service = makeService({
    screenApi: {
      getCursorScreenPoint: () => ({ x: 1900, y: 12 }), // over the tray
      getAllDisplays: () => [],
    },
  });
  service.session = { guideId: 'guide-stop', paused: false, count: 0, intervalSec: 0 };
  service.userIsInApp = () => false;
  service.frameForClick = async () => makeFrame('stop-click-frame');
  const added = [];
  service.store.addStep = (guideId, fields, png) => {
    added.push(png.toString());
    return { stepId: 'step-stop' };
  };

  // The hook reports the tray click at the tray position.
  const queue = service.enqueueClickCapture({ x: 1900, y: 12 }, Date.now(), 'left');
  service.noteUiStopGesture(); // tray handler records where it was clicked
  service.finishSession();
  await queue;

  assert.deepEqual(added, [], 'the stop click must be discarded');
});

test('a fast workflow click near the stop time but elsewhere is NOT dropped', async () => {
  // Position matching is what makes this safe: the user clicks their
  // workflow, then reaches up to the tray. The last workflow click lands
  // far from the tray and must survive even though it is close in time.
  const service = makeService({
    screenApi: {
      getCursorScreenPoint: () => ({ x: 1900, y: 12 }), // tray location
      getAllDisplays: () => [],
    },
  });
  service.session = { guideId: 'guide-near', paused: false, count: 0, intervalSec: 0 };
  service.userIsInApp = () => false;
  service.frameForClick = async () => makeFrame('workflow-frame');
  const added = [];
  service.store.addStep = (guideId, fields, png) => {
    added.push(png.toString());
    return { stepId: 'step-near' };
  };

  // Workflow click in the middle of the screen, then the tray stop.
  const queue = service.enqueueClickCapture({ x: 600, y: 500 }, Date.now(), 'left');
  service.noteUiStopGesture();
  service.finishSession();
  await queue;

  assert.deepEqual(added, ['workflow-frame'], 'a click away from the tray must be kept');
});

test('queued click captures preserve the original event time and button', async () => {
  const service = makeService();
  const seen = [];
  service.sessionCapture = async (trigger, clickPos, clickMeta) => {
    seen.push({ trigger, clickPos, clickMeta });
    return { ok: true };
  };

  await service.enqueueClickCapture({ x: 7, y: 8 }, 1770000000456, 'left');

  assert.deepEqual(seen, [{
    trigger: 'click',
    clickPos: { x: 7, y: 8 },
    clickMeta: { at: 1770000000456, button: 'left' },
  }]);
});

// ---- Windows watcher parsing ---------------------------------------------------

test('windows click watcher output is counted line by line', () => {
  const service = makeService();
  let clicks = 0;
  service.onOsClick = () => {
    clicks += 1;
  };

  service.processClickWatcherData('CLICK\r\nCLICK\r\n', 'win32');

  assert.equal(clicks, 2);
});

test('windows click lines carry the click-time cursor position', () => {
  const service = makeService();
  const seen = [];
  service.onOsClick = (at, osPoint) => {
    seen.push(osPoint);
  };

  service.processClickWatcherData('READY\r\nCLICK 1280 -64\r\nCLICK\r\n', 'win32');

  assert.deepEqual(seen, [{ x: 1280, y: -64 }, null],
    'coordinates ride along with the event; bare CLICK still works');
});

test('windows hook click lines carry button and event timestamp', () => {
  const service = makeService();
  const seen = [];
  service.onOsClick = (at, osPoint, button) => {
    seen.push({ at, osPoint, button });
  };

  service.processClickWatcherData('READY\r\nCLICK 321 -9 left 1770000000123\r\n', 'win32');

  assert.deepEqual(seen, [{
    at: 1770000000123,
    osPoint: { x: 321, y: -9 },
    button: 'left',
  }]);
});

// ---- click debounce (~200ms) ---------------------------------------------------
//
// These tests drive real onOsClick calls with controlled timestamps and
// record which clicks survive to enqueueClickCapture, so they exercise the
// actual debounce arithmetic rather than asserting on comments or constants.
// The behavior they lock in: clicks of one button closer together than the
// debounce window collapse to one; clicks spaced further apart all register.

/** Run a timestamp sequence through onOsClick; return the accepted times. */
function runClickSequence(service, times, { button = 'left', point = { x: 10, y: 20 } } = {}) {
  service.session = service.session || { guideId: 'g', paused: false, count: 0, intervalSec: 0 };
  const accepted = [];
  service.enqueueClickCapture = (clickPos, at) => { accepted.push(at); };
  for (const t of times) service.onOsClick(t, point, button);
  return accepted;
}

test('default debounce is 200ms when the setting is absent', () => {
  const service = makeService(); // settings stub has no clickDebounceMs
  assert.equal(service.clickDebounceMs(), 200);
});

test('two deliberate clicks 400ms apart both register (the reported case)', () => {
  const service = makeService({ settings: { 'capture.clickDebounceMs': 200 } });
  const accepted = runClickSequence(service, [0, 400]);
  assert.deepEqual(accepted, [0, 400], 'clicks well outside the window must not be dropped');
});

test('clicks 400–500ms apart all register across a longer sequence', () => {
  const service = makeService({ settings: { 'capture.clickDebounceMs': 200 } });
  const times = [0, 450, 950, 1400, 1900]; // 450/500/450/500 ms gaps
  assert.deepEqual(runClickSequence(service, times), times,
    'every click spaced beyond the window is captured');
});

test('a click just past the window (250ms) registers; just inside (150ms) does not', () => {
  const service = makeService({ settings: { 'capture.clickDebounceMs': 200 } });
  assert.deepEqual(runClickSequence(makeService({ settings: { 'capture.clickDebounceMs': 200 } }), [0, 250]), [0, 250]);
  assert.deepEqual(runClickSequence(service, [0, 150]), [0], '150ms < 200ms window is debounced away');
});

test('exactly 200ms apart registers (window is exclusive at the boundary)', () => {
  const service = makeService({ settings: { 'capture.clickDebounceMs': 200 } });
  assert.deepEqual(runClickSequence(service, [0, 200]), [0, 200]);
});

test('a fast burst collapses to a single step', () => {
  const service = makeService({ settings: { 'capture.clickDebounceMs': 200 } });
  // 5 clicks 30ms apart — accidental fast clicking.
  assert.deepEqual(runClickSequence(service, [0, 30, 60, 90, 120]), [0],
    'rapid repeats within the window are one click');
});

test('the window is measured from the last ACCEPTED click, not the last dropped one', () => {
  // A run of fast clicks must not push the next real click out forever: once
  // a click is accepted, only later clicks reset the reference, and dropped
  // clicks never do. 0 accepted; 100/150 dropped; 250 is 250ms after 0 so
  // accepted; 300 dropped; 500 is 250ms after 250 so accepted.
  const service = makeService({ settings: { 'capture.clickDebounceMs': 200 } });
  assert.deepEqual(runClickSequence(service, [0, 100, 150, 250, 300, 500]), [0, 250, 500]);
});

test('different mouse buttons debounce independently', () => {
  const service = makeService({ settings: { 'capture.clickDebounceMs': 200 } });
  service.session = { guideId: 'g', paused: false, count: 0, intervalSec: 0 };
  const accepted = [];
  service.enqueueClickCapture = (clickPos, at, button) => { accepted.push(`${button}@${at}`); };
  // Left then right 50ms apart: different buttons, both register. A second
  // left 50ms after the first left is debounced.
  service.onOsClick(0, { x: 1, y: 1 }, 'left');
  service.onOsClick(50, { x: 1, y: 1 }, 'right');
  service.onOsClick(50, { x: 1, y: 1 }, 'left');
  assert.deepEqual(accepted, ['left@0', 'right@50']);
});

test('the debounce window is configurable', () => {
  const service = makeService({ settings: { 'capture.clickDebounceMs': 500 } });
  // With a 500ms window, the 400ms-apart clicks now collapse.
  assert.deepEqual(runClickSequence(service, [0, 400]), [0]);
  // 600ms apart clears the larger window.
  const service2 = makeService({ settings: { 'capture.clickDebounceMs': 500 } });
  assert.deepEqual(runClickSequence(service2, [0, 600]), [0, 600]);
});

test('a debounce of 0 captures every click', () => {
  const service = makeService({ settings: { 'capture.clickDebounceMs': 0 } });
  assert.deepEqual(runClickSequence(service, [0, 1, 2, 3]), [0, 1, 2, 3]);
});

test('duplicate hook deliveries of one press collapse under the debounce', () => {
  // The same physical press delivered twice a few ms apart is well inside
  // any reasonable window, so it still yields exactly one step.
  const service = makeService({ settings: { 'capture.clickDebounceMs': 200 } });
  assert.deepEqual(runClickSequence(service, [1770000000000, 1770000000003]), [1770000000000]);
});

test('debounce state resets between sessions so the first click always registers', () => {
  const service = makeService({ settings: { 'capture.clickDebounceMs': 200 } });
  runClickSequence(service, [0]);
  service.stopClickWatcher(); // clears per-button accepted times
  const accepted = [];
  service.session = { guideId: 'g2', paused: false, count: 0, intervalSec: 0 };
  service.enqueueClickCapture = (clickPos, at) => { accepted.push(at); };
  // A click 50ms later but in a fresh session must not be debounced away.
  service.onOsClick(50, { x: 10, y: 20 }, 'left');
  assert.deepEqual(accepted, [50]);
});

test('debounced clicks never reach the capture queue (end to end through onOsClick)', async () => {
  // Integration: drive the real onOsClick → enqueueClickCapture → clickQueue
  // → sessionCapture → store path with a stubbed frame source, and confirm
  // the stored step count matches the number of accepted clicks.
  const service = makeService({ settings: { 'capture.clickDebounceMs': 200, 'capture.clickMarker': false } });
  service.session = { guideId: 'g-e2e', paused: false, count: 0, intervalSec: 0 };
  service.userIsInApp = () => false;
  service.frameForClick = async () => makeFrame('frame');
  const stored = [];
  service.store.addStep = (guideId, fields, png) => {
    stored.push(png.toString());
    return { stepId: `s${stored.length}` };
  };
  // Two deliberate clicks 450ms apart, with an accidental fast repeat 40ms
  // after the first. Expect two stored steps, not three or one.
  service.onOsClick(0, { x: 10, y: 20 }, 'left');
  service.onOsClick(40, { x: 10, y: 20 }, 'left'); // debounced
  service.onOsClick(450, { x: 30, y: 40 }, 'left');
  await service.clickQueue;

  assert.equal(stored.length, 2, 'one step per accepted click — burst repeat dropped, real clicks kept');
  assert.equal(service.session.count, 2);
});

// ---- warmup gating (recording goes live only after the window hides) -----------

test('clicks during warmup are ignored, not captured as junk', () => {
  const service = makeService();
  service.session = { guideId: 'g-warm', paused: false, count: 0, intervalSec: 0 };
  service.warmingUp = true;
  const seen = [];
  service.enqueueClickCapture = (clickPos, at) => { seen.push(at); };

  service.onOsClick(1000, { x: 10, y: 20 }, 'left');
  assert.deepEqual(seen, [], 'a click while warming up must not be captured');

  // Once armed, clicks are captured again.
  service.warmingUp = false;
  service.onOsClick(2000, { x: 10, y: 20 }, 'left');
  assert.deepEqual(seen, [2000]);
});

test('pausing and finishing clear the warmup flag', () => {
  const service = makeService();
  service.session = { guideId: 'g-warm2', paused: false, count: 0, intervalSec: 0 };
  service.warmingUp = true;
  service.togglePause(true);
  assert.equal(service.warmingUp, false, 'pause cancels an in-flight warmup');

  service.session = { guideId: 'g-warm3', paused: false, count: 0, intervalSec: 0 };
  service.warmingUp = true;
  service.finishSession();
  assert.equal(service.warmingUp, false, 'finish cancels an in-flight warmup');
});

test('armRecording warms while visible, then hides and arms the session', async () => {
  // Reproduces the restart path: recording must not be "live" until the
  // window is hidden. A click injected during warmup is ignored; a click
  // after arming is captured.
  const service = makeService();
  const win = {
    destroyed: false, visible: true,
    isDestroyed() { return this.destroyed; },
    isVisible() { return this.visible; },
    isMinimized() { return false; },
    hide() { this.visible = false; },
    show() { this.visible = true; },
    focus() {}, getTitle() { return 'StepForge'; },
    getBounds() { return { x: 0, y: 0, width: 800, height: 600 }; },
  };
  service.getWindow = () => win;
  service.clickCaptureAvailable = () => true;
  // Stub the recorder so warmup resolves fast without real Electron.
  service.startClickFrameBackend = async () => {};
  service.session = { guideId: 'g-arm', paused: false, count: 0, intervalSec: 0 };
  service.hiddenForSession = true;
  const captured = [];
  service.enqueueClickCapture = (clickPos, at) => { captured.push(at); };

  service.armRecording();
  assert.equal(service.warmingUp, true, 'warming up begins immediately');
  service.onOsClick(1, { x: 10, y: 10 }, 'left');
  assert.deepEqual(captured, [], 'a click during warmup is ignored');

  // Wait out the warmup (min-visible 400ms + settle 150ms here).
  for (let i = 0; i < 40 && service.warmingUp; i++) {
    await new Promise((r) => setTimeout(r, 50));
  }
  assert.equal(service.warmingUp, false, 'warmup clears');
  assert.equal(win.visible, false, 'the window is hidden once armed');

  service.onOsClick(2, { x: 10, y: 10 }, 'left');
  assert.deepEqual(captured, [2], 'a click after arming is captured');
  service.finishSession();
});

test('a slow recorder start still arms within the warmup cap', async () => {
  // If the backend start hangs (Windows can take seconds), the window must
  // still hide and recording must still arm — the restart bug was the
  // window staying up for many seconds, dropping every click over it.
  const service = makeService();
  const win = {
    destroyed: false, visible: true,
    isDestroyed() { return this.destroyed; },
    isVisible() { return this.visible; },
    isMinimized() { return false; },
    hide() { this.visible = false; },
    show() { this.visible = true; },
    focus() {}, getTitle() { return 'StepForge'; },
    getBounds() { return { x: 0, y: 0, width: 800, height: 600 }; },
  };
  service.getWindow = () => win;
  service.clickCaptureAvailable = () => true;
  // Backend start that never resolves within the test.
  service.startClickFrameBackend = () => new Promise(() => {});
  service.session = { guideId: 'g-slow', paused: false, count: 0, intervalSec: 0 };
  service.hiddenForSession = true;

  service.armRecording();
  // The cap is 1500ms; wait a bit beyond it.
  for (let i = 0; i < 60 && service.warmingUp; i++) {
    await new Promise((r) => setTimeout(r, 50));
  }
  assert.equal(service.warmingUp, false, 'a hung backend start must not keep recording un-armed');
  assert.equal(win.visible, false, 'the window hides even when the recorder is slow to start');
  service.finishSession();
});

test('a slow backend start from a finished session does not install itself or block a restart', async () => {
  // Rapid restart: session A starts a backend that resolves slowly; the user
  // finishes A and starts B before it resolves. A's late backend must not
  // install into B, and the starting-guard must not block B from starting
  // its own.
  const service = makeService();
  service.session = { guideId: 'A', paused: false, count: 0, intervalSec: 0 };

  let releaseA;
  const aReady = new Promise((r) => { releaseA = r; });
  const built = [];
  // Model the generation guard at the seam: a slow start that checks
  // captureGen before installing the backend it built.
  service.startClickFrameBackend = async function patched() {
    const gen = this.captureGen;
    this.streamBackendStarting = true;
    try {
      await aReady; // slow start
      const backend = { isActive: () => true, stop: () => built.push('stopped') };
      if (gen !== this.captureGen || !this.session || this.session.paused) {
        backend.stop();
        return;
      }
      this.streamBackend = backend;
      built.push('installed');
    } finally {
      if (gen === this.captureGen) this.streamBackendStarting = false;
    }
  };

  const startA = service.startClickFrameBackend();
  service.finishSession(); // bumps captureGen, A is now stale
  releaseA();
  await startA;

  assert.deepEqual(built, ['stopped'], 'the stale backend is stopped, never installed');
  assert.equal(service.streamBackend, null);
  assert.equal(service.streamBackendStarting, false, 'the guard is freed for the next session');
});

// ---- coordinate conversion ------------------------------------------------------

test('hook coordinates are converted physical → DIP via screenToDipPoint when available', () => {
  const service = makeService({
    screenApi: {
      screenToDipPoint: (p) => ({ x: p.x / 2, y: p.y / 2 }),
      getCursorScreenPoint: () => { throw new Error('must not fall back to a cursor read'); },
    },
  });
  service.session = { guideId: 'guide-dip', paused: false, count: 0, intervalSec: 0 };
  const seen = [];
  service.enqueueClickCapture = (clickPos) => {
    seen.push(clickPos);
  };

  service.onOsClick(1770000000000, { x: 1280, y: 640 }, 'left');

  assert.deepEqual(seen, [{ x: 640, y: 320 }]);
});

test('without screenToDipPoint, coordinates convert via display geometry (Linux/X11)', () => {
  const service = makeService({
    screenApi: {
      getAllDisplays: () => [
        { id: 1, scaleFactor: 2, bounds: { x: 0, y: 0, width: 1440, height: 900 } },
      ],
      getCursorScreenPoint: () => { throw new Error('must not fall back to a cursor read'); },
    },
  });
  service.session = { guideId: 'guide-x11', paused: false, count: 0, intervalSec: 0 };
  const seen = [];
  service.enqueueClickCapture = (clickPos) => {
    seen.push(clickPos);
  };

  service.onOsClick(1770000000000, { x: 1500, y: 900 }, 'button-1');

  assert.deepEqual(seen, [{ x: 750, y: 450 }],
    'a physical click on a 2x display must land at the halved DIP point');
});

test('clicks without event coordinates fall back to a live cursor read', () => {
  const service = makeService({
    screenApi: {
      getCursorScreenPoint: () => ({ x: 11, y: 22 }),
      getAllDisplays: () => [],
    },
  });
  service.session = { guideId: 'guide-cursor', paused: false, count: 0, intervalSec: 0 };
  const seen = [];
  service.enqueueClickCapture = (clickPos) => {
    seen.push(clickPos);
  };

  service.onOsClick(1770000000000, null, 'mouse');

  assert.deepEqual(seen, [{ x: 11, y: 22 }]);
});

// ---- watcher loss -----------------------------------------------------------------

test('losing the click watcher mid-session falls back to interval capture', () => {
  const service = makeService();
  service.settings.get = (key) => (key === 'capture.autoIntervalSec' ? 3 : null);
  service.session = { guideId: 'guide-loss', paused: false, count: 0, intervalSec: 0 };
  const states = [];
  service.notify = (channel, payload) => {
    states.push({ channel, payload });
  };

  try {
    service.handleClickWatcherLoss('exited with code 1');

    assert.equal(service.session.intervalSec, 3,
      'captures must not silently stop when the watcher dies');
    assert.ok(states.some((s) => s.channel === 'capture:state'));
  } finally {
    service.finishSession();
  }
});

// ---- strict frame selection -----------------------------------------------------

test('a click is served instantly from the freshly buffered frame', async () => {
  const service = makeService();
  service.session = { guideId: 'guide-2', paused: false, count: 0, intervalSec: 0 };
  service.latestFrame = makeFrame('buffered-png');
  service.shoot = async () => {
    throw new Error('must not take a fresh shot when a buffered frame is ready');
  };
  const added = [];
  service.store.addStep = (guideId, fields, png) => {
    added.push(png.toString());
    return { stepId: 'step-1' };
  };

  const result = await service.sessionCapture('click', { x: 10, y: 10 });

  assert.equal(result.ok, true);
  assert.deepEqual(added, ['buffered-png']);
  assert.equal(service.session.count, 1);
});

test('click capture uses the newest frame completed before the click time', async () => {
  const service = makeService();
  const clickAt = Date.now();
  service.session = { guideId: 'guide-history', paused: false, count: 0, intervalSec: 0 };
  const before = makeFrame('before-click');
  before.startedAt = clickAt - 40;
  before.capturedAt = clickAt - 30;
  const after = makeFrame('after-click');
  after.startedAt = clickAt + 5;
  after.capturedAt = clickAt + 15;
  service.recentFrames = [before, after];
  service.latestFrame = after;
  service.shoot = async () => {
    throw new Error('a matching pre-click frame should be used');
  };
  const added = [];
  service.store.addStep = (guideId, fields, png) => {
    added.push(png.toString());
    return { stepId: 'step-history' };
  };

  const result = await service.sessionCapture('click', { x: 10, y: 10 }, { at: clickAt });

  assert.equal(result.ok, true);
  assert.deepEqual(added, ['before-click']);
});

test('a buffered frame from a different display is ignored for click capture', async () => {
  const service = makeService();
  const clickAt = Date.now();
  service.session = { guideId: 'guide-display', paused: false, count: 0, intervalSec: 0 };
  service.frameLoopRunning = true;
  service.frameLoopInFlight = true;
  service.frameLoopGrabStartedAt = clickAt - 10; // the in-flight grab predates the click
  service.latestFrame = makeFrame('wrong-display', 0, {
    display: { bounds: { x: 0, y: 0, width: 100, height: 100 } },
  });

  service.nextFrame = async () => {
    const f = makeFrame('right-display', 0, {
      display: { bounds: { x: 100, y: 0, width: 100, height: 100 } },
      cursor: { x: 150, y: 10 },
    });
    f.startedAt = clickAt - 10;
    return f;
  };
  service.shoot = async () => {
    throw new Error('click capture should not fall back when a matching frame arrives');
  };

  const added = [];
  service.store.addStep = (guideId, fields, png) => {
    added.push(png.toString());
    return { stepId: 'step-display' };
  };

  const result = await service.sessionCapture('click', { x: 150, y: 10 }, { at: clickAt });

  assert.equal(result.ok, true);
  assert.deepEqual(added, ['right-display']);
  assert.equal(service.session.count, 1);
});

test('a stale buffered frame is not reused — the click falls back to a fresh shot', async () => {
  const service = makeService();
  service.session = { guideId: 'guide-stale', paused: false, count: 0, intervalSec: 0 };
  service.latestFrame = makeFrame('stale-png', 10_000);

  let shootCalled = false;
  service.shoot = async () => {
    shootCalled = true;
    return { ok: true, step: { stepId: 'fresh-step' } };
  };

  const result = await service.sessionCapture('click', { x: 1, y: 1 });

  assert.equal(result.ok, true);
  assert.equal(shootCalled, true, 'a stale buffered frame must not be reused');
});

test('strict mode: a frame whose grab started after the click is rejected', async () => {
  // This replaces the old "idle click waits for the imminent loop frame"
  // behavior: a grab that begins after the click can already show the
  // click's effects, so strict mode takes the explicit fresh-shot fallback
  // instead of passing it off as the click-time screen.
  const service = makeService();
  service.session = { guideId: 'guide-strict', paused: false, count: 0, intervalSec: 0 };
  service.frameLoopRunning = true;
  service.frameLoopInFlight = false; // nothing in flight at click time

  const clickAt = Date.now();
  service.nextFrame = async () => {
    throw new Error('strict idle clicks must not wait for a post-click frame');
  };
  let shootCalled = false;
  service.shoot = async () => {
    shootCalled = true;
    return { ok: true, step: { stepId: 'fresh-step' } };
  };

  const result = await service.sessionCapture('click', { x: 1, y: 1 }, { at: clickAt });

  assert.equal(result.ok, true);
  assert.equal(shootCalled, true);
});

test('balanced mode keeps the legacy slack: an imminent post-click frame is accepted', async () => {
  const service = makeService({ settings: { 'capture.strictClickFrames': false } });
  service.session = { guideId: 'guide-balanced', paused: false, count: 0, intervalSec: 0 };
  service.frameLoopRunning = true;
  service.frameLoopInFlight = false;

  const clickAt = Date.now();
  service.nextFrame = async () => {
    const f = makeFrame('next-loop-frame');
    f.startedAt = clickAt + 100; // grab began one idle gap after the click
    f.capturedAt = clickAt + 350;
    return f;
  };
  service.shoot = async () => {
    throw new Error('balanced idle clicks wait for the loop frame');
  };
  const added = [];
  service.store.addStep = (guideId, fields, png) => {
    added.push(png.toString());
    return { stepId: 'balanced-step' };
  };

  const result = await service.sessionCapture('click', { x: 1, y: 1 }, { at: clickAt });

  assert.equal(result.ok, true);
  assert.deepEqual(added, ['next-loop-frame']);
});

test('balanced mode: a loop frame started too long after the click still falls back', async () => {
  const service = makeService({ settings: { 'capture.strictClickFrames': false } });
  service.session = { guideId: 'guide-late', paused: false, count: 0, intervalSec: 0 };
  service.frameLoopRunning = true;
  service.frameLoopInFlight = false;

  const clickAt = Date.now();
  service.nextFrame = async () => {
    const f = makeFrame('too-late-frame');
    f.startedAt = clickAt + 5000; // way past the slack window
    f.capturedAt = clickAt + 6000;
    return f;
  };
  let shootCalled = false;
  service.shoot = async () => {
    shootCalled = true;
    return { ok: true, step: { stepId: 'fresh-step' } };
  };

  const result = await service.sessionCapture('click', { x: 1, y: 1 }, { at: clickAt });

  assert.equal(result.ok, true);
  assert.equal(shootCalled, true, 'late frames must not be passed off as the click-time screen');
});

test('clicks during an in-flight pre-click grab wait for the frame instead of being dropped', async () => {
  const service = makeService();
  service.session = { guideId: 'guide-fast', paused: false, count: 0, intervalSec: 0 };
  service.frameLoopRunning = true; // a grab is in flight, no frame buffered yet
  service.frameLoopInFlight = true;
  const clickAt = Date.now();
  service.frameLoopGrabStartedAt = clickAt - 10;
  service.shoot = async () => {
    throw new Error('waiting clicks must use the loop frame, not a competing shot');
  };
  const added = [];
  const frames = [];
  service.store.addStep = (guideId, fields, png) => {
    added.push(png.toString());
    return { stepId: `step-${added.length}` };
  };
  const origStore = service.storeFrameAsStep.bind(service);
  service.storeFrameAsStep = (guideId, mode, frame, clickPos) => {
    frames.push(frame);
    return origStore(guideId, mode, frame, clickPos);
  };

  // Two rapid clicks land before the grab completes.
  const first = service.sessionCapture('click', { x: 1, y: 1 }, { at: clickAt });
  const second = service.sessionCapture('click', { x: 2, y: 2 }, { at: clickAt });
  const loopFrame = makeFrame('loop-frame');
  loopFrame.startedAt = clickAt - 10;
  service.acceptFrame(loopFrame);
  const [r1, r2] = await Promise.all([first, second]);

  assert.equal(r1.ok, true);
  assert.equal(r2.ok, true);
  assert.equal(added.length, 2, 'one step per click — fast clicks are never dropped');
  assert.equal(service.session.count, 2);
  for (const frame of frames) {
    assert.ok(frame.startedAt <= clickAt,
      'strict mode: no step may use a frame whose grab started after its click');
  }
});

// ---- stream backend integration ---------------------------------------------------

test('click frames come from the stream backend when it is active', async () => {
  const service = makeService();
  const clickAt = Date.now();
  service.session = { guideId: 'guide-stream', paused: false, count: 0, intervalSec: 0 };
  const requests = [];
  service.streamBackend = {
    isActive: () => true,
    frameForClick: async (req) => {
      requests.push(req);
      return {
        mode: 'fullscreen',
        png: Buffer.from('stream-frame'),
        size: { width: 200, height: 100 },
        display: { bounds: { x: 0, y: 0, width: 100, height: 100 } },
        startedAt: clickAt - 50,
        capturedAt: clickAt - 40,
        source: 'stream',
      };
    },
    stop: () => {},
  };
  service.shoot = async () => {
    throw new Error('the stream frame must be used, not a fresh shot');
  };
  const added = [];
  service.store.addStep = (guideId, fields, png) => {
    added.push(png.toString());
    return { stepId: 'stream-step' };
  };

  const result = await service.sessionCapture('click', { x: 10, y: 10 }, { at: clickAt });

  assert.equal(result.ok, true);
  assert.deepEqual(added, ['stream-frame']);
  assert.deepEqual(requests, [{ clickPos: { x: 10, y: 10 }, clickAt, strict: true, leadMs: 0 }],
    'the worker receives the hook-time click timestamp, strictness, and lead');
});

test('a stream backend with no qualifying frame falls through to the fresh-shot path', async () => {
  const service = makeService();
  service.session = { guideId: 'guide-stream-miss', paused: false, count: 0, intervalSec: 0 };
  service.streamBackend = {
    isActive: () => true,
    frameForClick: async () => null,
    stop: () => {},
  };
  let shootCalled = false;
  service.shoot = async () => {
    shootCalled = true;
    return { ok: true, step: { stepId: 'fresh-step' } };
  };

  const result = await service.sessionCapture('click', { x: 1, y: 1 });

  assert.equal(result.ok, true);
  assert.equal(shootCalled, true);
});

test('pausing stops the frame loop, drops buffered frames, and stops the stream backend', () => {
  const service = makeService();
  service.session = { guideId: 'guide-pause', paused: false, count: 0, intervalSec: 0 };
  service.frameLoopRunning = true;
  service.latestFrame = makeFrame('pre-pause');
  let backendStopped = false;
  service.streamBackend = { isActive: () => true, stop: () => { backendStopped = true; } };

  service.togglePause(true);

  assert.equal(service.frameLoopRunning, false);
  assert.equal(service.latestFrame, null, 'a resume must never serve a pre-pause frame');
  assert.equal(backendStopped, true);
  assert.equal(service.streamBackend, null);
});

test('an unhealthy stream backend degrades to the in-process frame loop', () => {
  const service = makeService();
  service.session = { guideId: 'guide-degrade', paused: false, count: 0, intervalSec: 0 };
  service.streamBackend = { isActive: () => true, stop: () => {} };
  let loopStarted = false;
  service.startFrameLoop = () => { loopStarted = true; };
  const states = [];
  service.notify = (channel) => states.push(channel);

  service.degradeToFrameLoop();

  assert.equal(service.streamBackend, null);
  assert.equal(loopStarted, true, 'capture must not silently stop when the worker dies');
  assert.ok(states.includes('capture:state'));
});

test('session state reports which frame recorder is serving clicks', () => {
  const service = makeService();
  service.session = { guideId: 'guide-state', paused: false, count: 0, intervalSec: 0 };

  assert.equal(service.state().clickFrameSource, 'idle');
  assert.equal(service.state().strictClickFrames, true);
  service.frameLoopRunning = true;
  assert.equal(service.state().clickFrameSource, 'loop');
  service.streamBackend = { isActive: () => true, stop: () => {} };
  assert.equal(service.state().clickFrameSource, 'stream');
  service.streamBackend = null;
  service.frameLoopRunning = false;
});

// ---- marker + session lifecycle ------------------------------------------------

test('click capture marks the click-time cursor position', async () => {
  const service = makeService();
  service.settings.get = (key) => {
    if (key === 'capture.mode') return 'fullscreen';
    if (key === 'capture.delayMs') return 0;
    if (key === 'capture.clickMarker') return true;
    if (key === 'capture.clickMarkerColor') return '#E5484D';
    if (key === 'editor.focusedViewDefaultForNewSteps') return false;
    return null;
  };
  service.session = { guideId: 'guide-4', paused: false, count: 0, intervalSec: 0 };
  // No capture cache, so sessionCapture falls back to a fresh shoot().
  let seenCapturePoint = null;
  service.captureCurrentFrame = async (_mode, capturePoint) => {
    seenCapturePoint = capturePoint;
    return {
      mode: 'fullscreen',
      png: Buffer.from('live-png'),
      size: { width: 100, height: 100 },
      display: { bounds: { x: 0, y: 0, width: 100, height: 100 } },
      // Grab-time cursor, well outside the display — must not be used.
      cursor: { x: -1, y: -1 },
      capturedAt: Date.now(),
    };
  };

  let added = null;
  service.store.addStep = (guideId, fields, png, size) => {
    added = { guideId, fields, png, size };
    return { stepId: 'step-4', ...fields };
  };
  service.notify = () => {};

  const result = await service.sessionCapture('click', { x: 50, y: 50 });

  assert.equal(result.ok, true);
  assert.deepEqual(seenCapturePoint, { x: 50, y: 50 });
  assert.equal(added.fields.annotations.length, 1);
  assert.equal(added.fields.annotations[0].type, 'oval');
});

test('a new session starts paused and does not hide the window until "Start recording" is pressed', async () => {
  const service = makeService();
  const win = {
    destroyed: false, visible: true, minimized: false, hidden: 0, shown: 0,
    isDestroyed() { return this.destroyed; },
    isVisible() { return this.visible; },
    isMinimized() { return this.minimized; },
    hide() { this.visible = false; this.hidden += 1; },
    show() { this.visible = true; this.shown += 1; },
    showInactive() { this.visible = true; this.shown += 1; },
    focus() {},
    getTitle() { return 'StepForge'; },
    getBounds() { return { x: 0, y: 0, width: 800, height: 600 }; },
  };
  service.getWindow = () => win;
  service.clickCaptureAvailable = () => true;

  try {
    service.startSession('guide-5');

    assert.equal(service.session.paused, true, 'sessions start paused');
    assert.equal(service.state().paused, true);
    assert.equal(win.hidden, 0, 'window must stay visible until recording starts');

    // User clicks "Start recording" (the resume action).
    service.togglePause(false);
    assert.equal(service.session.paused, false);
    assert.equal(win.hidden, 0, 'hide is deferred briefly so the user sees it happen');

    await new Promise((r) => setTimeout(r, 450));
    assert.equal(win.hidden, 1, 'window hides once recording actually starts');
  } finally {
    service.finishSession();
  }
});
