'use strict';

const path = require('node:path');
const { spawn, execFileSync } = require('node:child_process');
const { desktopCapturer, screen, BrowserWindow, nativeImage, Tray, Menu, Notification } = require('electron');
const { expandPlaceholders } = require('../core/placeholders');
const raster = require('../core/raster');
const { encodePng } = require('../core/png');

/**
 * Capture service: full-screen, active-window, and region capture via
 * Electron's desktopCapturer, plus a click-marker annotation at the cursor
 * position and a capture session (start/pause/resume/finish).
 *
 * A session captures continuously, with three triggers layered by what the
 * platform supports:
 *  - click-capture via an OS adapter (xinput on X11, PowerShell on Windows),
 *  - a global hotkey (unreliable on some Wayland compositors),
 *  - interval auto-capture as the always-works fallback.
 *
 * Note: under Wayland/WSLg, screen capture may require portal support; all
 * failures surface as { ok: false, reason } instead of crashing.
 */

// Dedupe duplicate watcher events for one physical click while still
// allowing intentionally fast clicking.
const CLICK_DEBOUNCE_MS = 40;
// Idle gap between frame-loop grabs. Keep this at zero so the buffered
// frame stays as close to real time as possible while recording.
const FRAME_LOOP_IDLE_MS = 0;
// A buffered frame older than this is too stale to pass off as "the screen
// at the instant of the click".
const CLICK_FRAME_MAX_AGE_MS = 600;
// How long a click waits for the in-flight grab before falling back to a
// one-off fresh shot.
const CLICK_FRAME_WAIT_MS = 2000;
const CLICK_CAPTURE_HIDE_DELAY_MS = 25;

function pointInBounds(point, bounds) {
  if (!point || !bounds) return false;
  return point.x >= bounds.x
    && point.x <= bounds.x + bounds.width
    && point.y >= bounds.y
    && point.y <= bounds.y + bounds.height;
}

function hasBinary(name) {
  try {
    execFileSync('which', [name], { stdio: 'pipe' });
    return true;
  } catch {
    return false;
  }
}

class CaptureService {
  constructor({ store, settings, getWindow, notify }) {
    this.store = store;
    this.settings = settings;
    this.getWindow = getWindow;
    this.notify = notify;
    this.session = null; // { guideId, paused, count, intervalSec }
    this.intervalTimer = null;
    this.clickWatcher = null;
    this.frameLoopTimer = null;
    this.frameLoopRunning = false;
    this.frameWaiters = [];
    this.latestFrame = null;
    this.lastClickCapture = 0;
    this.clickWatcherButtonDown = false;
    this.frameLoopInFlight = false;
    this.shooting = false;
  }

  state() {
    return this.session
      ? {
        active: true,
        paused: this.session.paused,
        guideId: this.session.guideId,
        count: this.session.count,
        intervalSec: this.session.intervalSec || 0,
        clickCapture: Boolean(this.clickWatcher),
        clickCaptureAvailable: this.clickCaptureAvailable(),
      }
      : { active: false, clickCaptureAvailable: this.clickCaptureAvailable() };
  }

  clickCaptureAvailable() {
    if (this._clickAvail === undefined) {
      this._clickAvail = process.platform === 'win32' || (process.platform === 'linux' && hasBinary('xinput'));
    }
    return this._clickAvail;
  }

  startSession(guideId, { intervalSec = null } = {}) {
    this.finishSession();
    // Default trigger: clicks when the platform supports it, otherwise an
    // interval so a session always produces steps even if the global hotkey
    // never fires (common under Wayland/WSLg).
    let interval = intervalSec;
    if (interval == null) {
      interval = this.clickCaptureAvailable() ? 0 : (this.settings.get('capture.autoIntervalSec') || 5);
    }
    // Sessions start paused: nothing hides and no capturing happens until
    // the user explicitly presses "Start recording" in the capture bar, so
    // New Capture never makes the window vanish out from under them.
    this.session = { guideId, paused: true, count: 0, intervalSec: interval };
    if (this.settings.get('capture.captureOutsideClicks') !== false) this.startClickWatcher();
    this.applyInterval();
    this.notify('capture:state', this.state());

    // (Skipped for the dev screenshot hook, which needs a visible page.)
    if (!process.env.STEPFORGE_SCREENSHOT) {
      this.createSessionTray();
      const win = this.getWindow();
      // Remember whether the window was visible when the session was set
      // up — that's what `togglePause` uses to decide whether to tuck the
      // app away once the user actually starts recording.
      this.hiddenForSession = Boolean(win && !win.isDestroyed() && win.isVisible());
      try {
        new Notification({
          title: 'StepForge is ready to capture',
          body: 'Click "Start recording" in the red capture bar when you’re ready. The window tucks away and the red tray icon takes over.',
        }).show();
      } catch { /* notifications unavailable on this desktop */ }
    }
  }

  /** Red-dot tray icon with session controls, shown while recording. */
  createSessionTray() {
    this.destroySessionTray();
    try {
      const img = raster.createImage(16, 16, [0, 0, 0, 0]);
      raster.fillOval(img, 2, 2, 12, 12, [229, 72, 77, 255]);
      this.tray = new Tray(nativeImage.createFromBuffer(encodePng(img)));
      this.tray.setToolTip('StepForge — capture session running');
      const rebuild = () => {
        if (!this.tray || this.tray.isDestroyed()) return;
        this.tray.setContextMenu(Menu.buildFromTemplate([
          { label: `Captured ${this.session ? this.session.count : 0} steps`, enabled: false },
          { type: 'separator' },
          { label: 'Capture now', click: () => this.sessionCapture('manual').then(rebuild).catch(() => {}) },
          {
            label: this.session && this.session.paused ? 'Resume capturing' : 'Pause capturing',
            click: () => { this.togglePause(); rebuild(); },
          },
          {
            label: 'Open StepForge (pauses capture)',
            click: () => {
              this.togglePause(true);
              this.showWindow();
              rebuild();
            },
          },
          { type: 'separator' },
          { label: 'Finish session', click: () => this.finishSession() },
        ]));
      };
      rebuild();
      this.rebuildTrayMenu = rebuild;
      this.tray.on('click', () => {
        this.togglePause(true);
        this.showWindow();
        rebuild();
      });
    } catch {
      this.tray = null; // no tray on this desktop; cursor-over skip still protects clicks
    }
  }

  destroySessionTray() {
    if (this.tray && !this.tray.isDestroyed()) this.tray.destroy();
    this.tray = null;
    this.rebuildTrayMenu = null;
  }

  showWindow() {
    const win = this.getWindow();
    if (win && !win.isDestroyed()) {
      win.show();
      win.focus();
    }
  }

  setInterval(intervalSec) {
    if (!this.session) return this.state();
    this.session.intervalSec = Math.max(0, Number(intervalSec) || 0);
    this.applyInterval();
    this.notify('capture:state', this.state());
    return this.state();
  }

  applyInterval() {
    if (this.intervalTimer) {
      clearInterval(this.intervalTimer);
      this.intervalTimer = null;
    }
    const sec = this.session && this.session.intervalSec;
    if (sec > 0) {
      this.intervalTimer = setInterval(() => {
        this.sessionCapture('interval').catch(() => {});
      }, sec * 1000);
    }
  }

  togglePause(force) {
    if (!this.session) return;
    const wasPaused = this.session.paused;
    this.session.paused = typeof force === 'boolean' ? force : !this.session.paused;
    // Starting/resuming tucks the window away again for clean shots (after
    // a brief delay so the user sees it happen) and starts the frame loop
    // that serves click captures. Pausing stops the loop and discards the
    // buffered frame, so a resume can never serve a pre-pause screen.
    if (wasPaused && !this.session.paused) {
      const win = this.getWindow();
      const arm = () => {
        if (!this.session || this.session.paused) return;
        if (this.hiddenForSession && win && !win.isDestroyed() && win.isVisible()) win.hide();
        if (this.settings.get('capture.captureOutsideClicks') !== false && this.clickCaptureAvailable()) {
          this.startFrameLoop();
        }
      };
      if (this.hiddenForSession && win && !win.isDestroyed()) setTimeout(arm, 400);
      else arm();
    } else if (!wasPaused && this.session.paused) {
      this.stopFrameLoop();
    }
    if (this.rebuildTrayMenu) this.rebuildTrayMenu();
    this.notify('capture:state', this.state());
  }

  finishSession() {
    if (this.intervalTimer) {
      clearInterval(this.intervalTimer);
      this.intervalTimer = null;
    }
    this.stopClickWatcher();
    this.stopFrameLoop();
    this.destroySessionTray();
    this.session = null;
    if (this.hiddenForSession) {
      this.hiddenForSession = false;
      this.showWindow();
    }
    this.notify('capture:state', this.state());
  }

  /**
   * True when the user is interacting with StepForge itself. Deliberately
   * based on cursor position over the visible window, not isFocused():
   * some compositors (WSLg) report focus as stuck-true, which would block
   * every automatic capture forever.
   */
  userIsInApp() {
    const win = this.getWindow();
    if (!win || win.isDestroyed() || !win.isVisible() || win.isMinimized()) return false;
    const cur = screen.getCursorScreenPoint();
    const b = win.getBounds();
    return cur.x >= b.x && cur.x <= b.x + b.width && cur.y >= b.y && cur.y <= b.y + b.height;
  }

  /** One capture inside the active session (hotkey/click/interval/manual). */
  async sessionCapture(trigger = 'hotkey', clickPos = null) {
    if (!this.session || this.session.paused) return { ok: false, reason: 'no active capture session' };
    // Automatic triggers stand down while the user is in StepForge, so the
    // app stays clickable mid-session and never screenshots itself.
    if (trigger !== 'manual' && this.userIsInApp()) {
      return { ok: false, reason: 'skipped — StepForge is focused' };
    }

    // Clicks are served from the frame loop: the buffered frame was grabbed
    // at (or moments before) the click instant, so the background matches
    // what the user clicked on. A click that lands while a grab is in
    // flight waits for that frame instead of being dropped, so fast
    // clicking still yields one step per click.
    if (trigger === 'click') {
      const frame = await this.frameForClick(clickPos);
      if (!this.session || this.session.paused) return { ok: false, reason: 'no active capture session' };
      if (frame) {
        const result = this.storeFrameAsStep(this.session.guideId, frame.mode, frame, clickPos);
        if (result.ok) this.noteStepAdded(result.step, trigger);
        return result;
      }
      // No usable frame (loop not running or grab failing): fall through
      // to a one-off fresh shot.
    }

    if (this.shooting) return { ok: false, reason: 'capture already in progress' };
    this.shooting = true;
    try {
      const mode = this.settings.get('capture.mode') || 'fullscreen';
      const grabMode = mode === 'region' ? 'fullscreen' : mode;
      const finalResult = await this.shoot({
        guideId: this.session.guideId,
        mode: grabMode,
        delayMs: 0,
        hideWindowDelayMs: trigger === 'click' ? CLICK_CAPTURE_HIDE_DELAY_MS : null,
        refocus: false, // don't steal focus from the app the user is documenting
        clickPos,
      });
      if (finalResult.ok) this.noteStepAdded(finalResult.step, trigger);
      return finalResult;
    } finally {
      this.shooting = false;
    }
  }

  noteStepAdded(step, trigger) {
    this.session.count += 1;
    this.notify('capture:added', { guideId: this.session.guideId, step, trigger });
    this.notify('capture:state', this.state());
    if (this.rebuildTrayMenu) this.rebuildTrayMenu(); // refresh step counter
  }

  hotkeyCapture() {
    return this.sessionCapture('hotkey');
  }

  // ---- click-triggered capture --------------------------------------------

  /**
   * Continuous screen-grab loop that runs while recording. It keeps the most
   * recent frame in `latestFrame` so a click can be served from a frame
   * grabbed at (or moments before) the instant of the click — a fresh grab
   * started after the click would land hundreds of ms late and show the
   * click's effects instead of what the user clicked on.
   */
  startFrameLoop() {
    if (this.frameLoopRunning) return;
    this.frameLoopRunning = true;
    const tick = async () => {
      if (!this.frameLoopRunning) return;
      if (!this.session || this.session.paused) {
        this.frameLoopRunning = false;
        this.frameLoopInFlight = false;
        return;
      }
      try {
        if (!this.shooting) {
          this.frameLoopInFlight = true;
          const mode = this.settings.get('capture.mode') || 'fullscreen';
          const grabMode = mode === 'region' ? 'fullscreen' : mode;
          const frame = await this.captureCurrentFrame(grabMode);
          if (this.frameLoopRunning) this.acceptFrame(frame);
        }
      } catch {
        // Grab failures are fine — clicks fall back to a one-off fresh shot.
      } finally {
        this.frameLoopInFlight = false;
        if (this.frameLoopRunning && this.session && !this.session.paused) {
          this.frameLoopTimer = setTimeout(tick, FRAME_LOOP_IDLE_MS);
        }
      }
    };
    this.frameLoopTimer = setTimeout(tick, 0);
  }

  /** Store a grabbed frame and hand it to any clicks waiting on it. */
  acceptFrame(frame) {
    this.latestFrame = frame;
    const waiters = this.frameWaiters;
    this.frameWaiters = [];
    for (const resolve of waiters) resolve(frame);
  }

  /** Resolves with the next frame the loop grabs (null on timeout/stop). */
  nextFrame(timeoutMs) {
    return new Promise((resolve) => {
      const entry = (frame) => {
        clearTimeout(timer);
        resolve(frame);
      };
      const timer = setTimeout(() => {
        this.frameWaiters = this.frameWaiters.filter((w) => w !== entry);
        resolve(null);
      }, timeoutMs);
      this.frameWaiters.push(entry);
    });
  }

  stopFrameLoop() {
    if (this.frameLoopTimer) {
      clearTimeout(this.frameLoopTimer);
      this.frameLoopTimer = null;
    }
    this.frameLoopRunning = false;
    this.latestFrame = null;
    const waiters = this.frameWaiters;
    this.frameWaiters = [];
    for (const resolve of waiters) resolve(null);
  }

  /**
   * Freshest frame usable for a click capture: the buffered frame when it's
   * recent enough, otherwise the next frame the loop delivers. Null when the
   * loop isn't running or can't deliver in time.
   */
  async frameForClick(clickPos = null) {
    const mode = this.settings.get('capture.mode') || 'fullscreen';
    const grabMode = mode === 'region' ? 'fullscreen' : mode;
    // Fast clicks can move to another monitor before the buffered frame is
    // consumed; only reuse frames from the clicked display.
    const usable = (f) => {
      const sameDisplay = !clickPos || pointInBounds(clickPos, f && f.display && f.display.bounds);
      return Boolean(f)
        && f.mode === grabMode
        && Date.now() - f.capturedAt <= CLICK_FRAME_MAX_AGE_MS
        && sameDisplay;
    };
    if (usable(this.latestFrame)) return this.latestFrame;
    if (!this.frameLoopRunning || !this.frameLoopInFlight) return null;
    const deadline = Date.now() + CLICK_FRAME_WAIT_MS;
    while (this.frameLoopRunning && Date.now() < deadline) {
      const next = await this.nextFrame(Math.max(1, deadline - Date.now()));
      if (usable(next)) return next;
    }
    return null;
  }

  startClickWatcher() {
    this.stopClickWatcher();
    try {
      this.clickWatcherButtonDown = false;
      if (process.platform === 'linux' && hasBinary('xinput')) {
        // Stream raw button events from the X server; one capture per press.
        this.clickWatcher = spawn('xinput', ['test-xi2', '--root'], { stdio: ['ignore', 'pipe', 'ignore'] });
        this.clickWatcher.stdout.on('data', (chunk) => {
          this.processClickWatcherData(chunk.toString(), 'linux');
        });
      } else if (process.platform === 'win32') {
        // Poll the left mouse button via GetAsyncKeyState; print one line per click.
        const ps = `
Add-Type -Namespace W -Name U -MemberDefinition '[DllImport("user32.dll")] public static extern short GetAsyncKeyState(int k);'
$down = $false
while ($true) {
  $s = [W.U]::GetAsyncKeyState(0x01) -band 0x8000
  if ($s -and -not $down) { Write-Output CLICK }
  $down = [bool]$s
  Start-Sleep -Milliseconds 10
}`;
        this.clickWatcher = spawn('powershell.exe', ['-NoProfile', '-Command', ps], { stdio: ['ignore', 'pipe', 'ignore'] });
        this.clickWatcher.stdout.on('data', (chunk) => {
          this.processClickWatcherData(chunk.toString(), 'win32');
        });
      }
      if (this.clickWatcher) {
        this.clickWatcher.on('error', () => { this.clickWatcher = null; });
        this.clickWatcher.on('exit', () => { this.clickWatcher = null; });
      }
    } catch {
      this.clickWatcher = null;
    }
  }

  stopClickWatcher() {
    if (this.clickWatcher) {
      try { this.clickWatcher.kill(); } catch { /* already gone */ }
      this.clickWatcher = null;
    }
    this.clickWatcherButtonDown = false;
  }

  processClickWatcherData(text, platform = process.platform) {
    const lines = String(text).split(/\r?\n/);
    if (platform === 'linux') {
      for (const line of lines) {
        if (!line) continue;
        // xinput batches multiple events into one chunk, so parse line by
        // line and track press/release state instead of collapsing the chunk.
        if (/RawButtonPress|ButtonPress/.test(line)) {
          if (!this.clickWatcherButtonDown) {
            this.clickWatcherButtonDown = true;
            this.onOsClick();
          }
        } else if (/RawButtonRelease|ButtonRelease/.test(line)) {
          this.clickWatcherButtonDown = false;
        }
      }
      return;
    }
    if (platform === 'win32') {
      for (const line of lines) {
        if (line.includes('CLICK')) this.onOsClick();
      }
    }
  }

  onOsClick(at = Date.now()) {
    if (!this.session || this.session.paused) return;
    // Ignore clicks on StepForge itself (pausing, finishing, editing).
    if (BrowserWindow.getFocusedWindow()) return;
    if (at - this.lastClickCapture < CLICK_DEBOUNCE_MS) return;
    this.lastClickCapture = at;
    // Grab the cursor position synchronously, right when the click is
    // detected, so the marker lands exactly where the user clicked even if
    // the shot itself takes a moment to grab.
    const clickPos = screen.getCursorScreenPoint();
    this.sessionCapture('click', clickPos).catch(() => {});
  }

  async captureCurrentFrame(mode, capturePoint = null) {
    const grabbed = await this.grab(mode, capturePoint);
    return {
      mode,
      png: grabbed.image.toPNG(),
      size: grabbed.image.getSize(),
      display: grabbed.display,
      cursor: capturePoint || grabbed.cursor,
      capturedAt: Date.now(),
    };
  }

  storeFrameAsStep(guideId, mode, frame, clickPos = null) {
    if (!frame) return { ok: false, reason: 'no capture frame available' };
    const annotations = [];
    const cursor = clickPos || frame.cursor;
    if (mode !== 'window' && this.settings.get('capture.clickMarker')) {
      const fx = (cursor.x - frame.display.bounds.x) / frame.display.bounds.width;
      const fy = (cursor.y - frame.display.bounds.y) / frame.display.bounds.height;
      if (fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1) {
        const d = 0.035;
        annotations.push({
          type: 'oval',
          x: fx - d / 2, y: fy - (d * frame.size.width / frame.size.height) / 2,
          w: d, h: d * frame.size.width / frame.size.height,
          style: {
            stroke: this.settings.get('capture.clickMarkerColor') || '#E5484D',
            strokeWidth: 4, fill: 'transparent',
          },
        });
      }
    }

    const step = this.store.addStep(guideId, {
      title: this.autoTitle(mode),
      annotations,
      focusedView: {
        enabled: Boolean(this.settings.get('editor.focusedViewDefaultForNewSteps')),
        zoom: 1, panX: 0.5, panY: 0.5,
      },
    }, frame.png, frame.size);
    return { ok: true, step };
  }

  autoTitle(mode) {
    const tplStr = this.settings.get('editor.autoTitleTemplate') || '[[Mode]] capture [[Time]]';
    const now = new Date();
    const pad = (n) => String(n).padStart(2, '0');
    return expandPlaceholders(tplStr, {
      Mode: { fullscreen: 'Screen', window: 'Window', region: 'Region' }[mode] || 'Screen',
      Time: `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`,
      Date: `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`,
    });
  }

  /** Grab the screen/window image as { image, display } or throw. */
  async grab(mode, cursorPoint = null) {
    const cursor = cursorPoint || screen.getCursorScreenPoint();
    const display = screen.getDisplayNearestPoint(cursor);
    const { width, height } = display.size;
    const scale = display.scaleFactor || 1;
    // Ask for both kinds: some compositors (WSLg/Wayland portals) expose no
    // individual window sources, so window mode falls back to the screen.
    const sources = await desktopCapturer.getSources({
      types: mode === 'window' ? ['window', 'screen'] : ['screen'],
      thumbnailSize: { width: Math.round(width * scale), height: Math.round(height * scale) },
    });
    if (!sources.length) throw new Error('no capture sources available (portal/permissions?)');

    let source = null;
    if (mode === 'window') {
      const win = this.getWindow();
      const ownTitle = win ? win.getTitle() : '';
      const windows = sources.filter((s) => s.id.startsWith('window:'));
      source = windows.find((s) => s.name && s.name !== ownTitle && !/stepforge/i.test(s.name))
        || windows[0]
        || sources.find((s) => s.id.startsWith('screen:'));
    } else {
      const screens = sources.filter((s) => s.id.startsWith('screen:'));
      source = screens.find((s) => String(s.display_id) === String(display.id)) || screens[0] || sources[0];
    }
    if (!source) throw new Error('no capture source matched');
    const image = source.thumbnail;
    if (!image || image.isEmpty()) throw new Error('capture returned an empty image');
    return { image, display, cursor };
  }

  /**
   * Hide the app window while `fn` runs so screenshots show the user's work,
   * not StepForge itself. Restores visibility afterwards.
   */
  async withWindowHidden(fn, { refocus = true, pauseMs = 350 } = {}) {
    const win = this.getWindow();
    const wasVisible = win && !win.isDestroyed() && win.isVisible() && !win.isMinimized();
    if (wasVisible) {
      win.hide();
      if (pauseMs > 0) {
        await new Promise((r) => setTimeout(r, pauseMs)); // let the compositor repaint
      }
    }
    try {
      return await fn();
    } finally {
      if (wasVisible && win && !win.isDestroyed()) {
        if (refocus) {
          win.show();
          win.focus();
        } else {
          win.showInactive();
        }
      }
    }
  }

  /**
   * Take a screenshot and append it to the guide as a new image step.
   * Adds a click-marker annotation at the cursor position when enabled.
   */
  async shoot({
    guideId,
    mode = 'fullscreen',
    delayMs = null,
    hideWindow = true,
    refocus = true,
    hideWindowDelayMs = null,
    clickPos = null,
  }) {
    const delay = delayMs == null ? this.settings.get('capture.delayMs') || 0 : delayMs;
    if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
    let frame;
    try {
      frame = hideWindow
        ? await this.withWindowHidden(() => this.captureCurrentFrame(mode, clickPos), {
          refocus,
          pauseMs: hideWindowDelayMs == null ? 350 : hideWindowDelayMs,
        })
        : await this.captureCurrentFrame(mode, clickPos);
    } catch (err) {
      return { ok: false, reason: err.message };
    }
    return this.storeFrameAsStep(guideId, mode, frame, clickPos);
  }

  /**
   * Region capture: shoot the full screen, then let the user drag a
   * rectangle in a fullscreen overlay; the crop becomes the step image.
   */
  async regionCapture(guideId) {
    let grabbed;
    try {
      grabbed = await this.withWindowHidden(() => this.grab('fullscreen'));
    } catch (err) {
      return { ok: false, reason: err.message };
    }
    const { image, display } = grabbed;
    const rect = await this.pickRegion(display, image);
    if (!rect) return { ok: false, reason: 'selection cancelled' };

    const cropped = image.crop(rect);
    const size = cropped.getSize();
    if (!size.width || !size.height) return { ok: false, reason: 'empty selection' };
    const step = this.store.addStep(guideId, { title: this.autoTitle('region') },
      cropped.toPNG(), size);
    return { ok: true, step };
  }

  /** Fullscreen overlay window that resolves with a crop rect (image px). */
  pickRegion(display, image) {
    return new Promise((resolve) => {
      const overlay = new BrowserWindow({
        x: display.bounds.x,
        y: display.bounds.y,
        width: display.bounds.width,
        height: display.bounds.height,
        frame: false,
        transparent: true,
        alwaysOnTop: true,
        fullscreen: true,
        skipTaskbar: true,
        webPreferences: {
          preload: path.join(__dirname, 'region-preload.js'),
          contextIsolation: true,
        },
      });
      let settled = false;
      const finish = (rect) => {
        if (settled) return;
        settled = true;
        if (!overlay.isDestroyed()) overlay.close();
        resolve(rect);
      };
      const { ipcMain } = require('electron');
      const onPick = (event, rect) => {
        if (event.sender !== overlay.webContents) return;
        ipcMain.removeListener('region:picked', onPick);
        if (!rect) return finish(null);
        const imgSize = image.getSize();
        const sx = imgSize.width / display.bounds.width;
        const sy = imgSize.height / display.bounds.height;
        finish({
          x: Math.round(rect.x * sx),
          y: Math.round(rect.y * sy),
          width: Math.round(rect.w * sx),
          height: Math.round(rect.h * sy),
        });
      };
      ipcMain.on('region:picked', onPick);
      overlay.on('closed', () => finish(null));
      overlay.loadFile(path.join(__dirname, 'renderer', 'region.html'));
    });
  }
}

module.exports = CaptureService;
