'use strict';

const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');
const {
  app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, globalShortcut,
  clipboard, nativeImage, screen,
} = require('electron');

const { GuideStore } = require('../core/store');
const { Settings } = require('../core/settings');
const { SearchIndex } = require('../core/search');
const { TemplateManager, FORMATS } = require('../core/templates');
const { buildRenderAst } = require('../core/renderast');
const { runExport, EXPORTERS } = require('../exporters');
const { exportGuideArchive, importGuideArchive, saveLinkedGuide, readArchive } = require('../core/archive');
const { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots');
const { readLock } = require('../core/locks');
const CaptureService = require('./capture');

/**
 * StepForge main process. Zero network code: no telemetry, no updates, no
 * remote anything. The renderer is sandboxed; everything below is the full
 * privileged surface.
 */

function resolveDataDir() {
  if (process.env.STEPFORGE_DATA_DIR) return process.env.STEPFORGE_DATA_DIR;
  if (process.platform === 'win32') {
    return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'stepforge');
  }
  const xdg = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
  return path.join(xdg, 'stepforge');
}

let store;
let settings;
let searchIndex;
let templates;
let capture;
let mainWindow;

function reindex(guideId) {
  try {
    searchIndex.indexGuide(store.getGuide(guideId), store.listSteps(guideId));
  } catch {
    // index failures must never block saves
  }
}

function orderedSteps(guideId) {
  const guide = store.getGuide(guideId);
  const steps = store.listSteps(guideId);
  return guide.stepsOrder.map((id) => steps.get(id)).filter(Boolean);
}

function applyTheme() {
  nativeTheme.themeSource = settings.get('appearance') || 'system';
}

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 1280,
    height: 820,
    minWidth: 880,
    minHeight: 560,
    show: false,
    autoHideMenuBar: true,
    backgroundColor: nativeTheme.shouldUseDarkColors ? '#111827' : '#ffffff',
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false,
      spellcheck: Boolean(settings.get('spellcheck')),
    },
  });
  mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
  mainWindow.once('ready-to-show', () => {
    mainWindow.show();
    // Dev-only verification hook: optionally navigate, then write a
    // screenshot locally and exit. Used by the smoke tooling.
    if (process.env.STEPFORGE_SCREENSHOT) {
      const target = process.env.STEPFORGE_SCREENSHOT;
      const navigate = process.env.STEPFORGE_SCREENSHOT_JS || '';
      setTimeout(async () => {
        try {
          if (navigate) {
            await mainWindow.webContents.executeJavaScript(navigate, true);
            await new Promise((r) => setTimeout(r, 900));
          }
          const image = await mainWindow.webContents.capturePage();
          fs.writeFileSync(target, image.toPNG());
        } catch (err) {
          console.error('screenshot failed:', err.message);
        } finally {
          app.quit();
        }
      }, 1500);
    }
    // Dev-only self-test: exercise the full click-capture pipeline — resume
    // session, wait for the frame recorder, inject OS-level clicks the way
    // the watcher would, and verify one stored step per click.
    if (process.env.STEPFORGE_CLICK_SELFTEST) {
      setTimeout(async () => {
        try {
          // The marker/drain scenarios inject clicks faster than the default
          // debounce to stress the frame pipeline; turn the debounce off for
          // them so every injected click is captured. A dedicated scenario
          // at the end re-enables it and verifies the debounce itself.
          settings.set('capture.clickDebounceMs', 0);
          const guide = store.createGuide({ title: 'click selftest' });
          capture.startSession(guide.guideId, { intervalSec: 0 });
          // Isolate the test from the user's real mouse: the session starts
          // the live OS click watcher, and a stray real click (dismissing
          // the toast, focusing the terminal) would add an extra step and
          // shift every marker comparison below.
          capture.stopClickWatcher();
          capture.togglePause(false);
          mainWindow.hide();
          // Arm the frame recorder directly: this host may lack the click
          // watcher binary (xinput), which normally gates the recorder, but
          // the recorder itself must still be testable end to end.
          await capture.startClickFrameBackend();
          // Let the stream backend (or the fallback loop) come up and buffer.
          await new Promise((res) => setTimeout(res, 3000));
          console.log('CLICK-SELFTEST source:', capture.state().clickFrameSource);
          // Targets are chosen in DIP; the OS hook reports *physical* pixels,
          // so convert before injecting (identity on unscaled displays).
          const { bounds } = screen.getPrimaryDisplay();
          const dipTargets = [
            { x: Math.round(bounds.x + bounds.width * 0.2), y: Math.round(bounds.y + bounds.height * 0.2) },
            { x: Math.round(bounds.x + bounds.width * 0.5), y: Math.round(bounds.y + bounds.height * 0.5) },
            { x: Math.round(bounds.x + bounds.width * 0.8), y: Math.round(bounds.y + bounds.height * 0.8) },
          ];
          const toPhysical = (p) => (typeof screen.dipToScreenPoint === 'function'
            ? screen.dipToScreenPoint(p)
            : p);
          for (const point of dipTargets) {
            capture.onOsClick(Date.now(), toPhysical(point), 'button-1');
            await new Promise((res) => setTimeout(res, 120)); // fast clicking
          }
          // Wait for the queue to drain (encodes can take seconds on WSLg).
          await capture.clickQueue;
          await new Promise((res) => setTimeout(res, 500));
          const stepIds = store.getGuide(guide.guideId).stepsOrder;
          const steps = store.listSteps(guide.guideId);
          const markers = stepIds.map((id) => (steps.get(id).annotations || []).length);
          console.log('CLICK-SELFTEST steps:', stepIds.length, 'of', dipTargets.length,
            'markers:', JSON.stringify(markers));
          if (stepIds.length !== dipTargets.length) {
            console.log('CLICK-SELFTEST step count mismatch — marker offsets below are unreliable');
          }
          // Marker accuracy: each oval's center (fractional) must match the
          // injected click position relative to the display bounds.
          stepIds.forEach((id, i) => {
            const a = (steps.get(id).annotations || [])[0];
            const expectedClick = dipTargets[i];
            if (!a || !expectedClick) return;
            const center = { x: a.x + a.w / 2, y: a.y + a.h / 2 };
            const expected = {
              x: (expectedClick.x - bounds.x) / bounds.width,
              y: (expectedClick.y - bounds.y) / bounds.height,
            };
            const offBy = Math.hypot(center.x - expected.x, center.y - expected.y);
            console.log(`CLICK-SELFTEST marker ${i}: off by ${(offBy * 100).toFixed(2)}% of screen`);
          });
          capture.finishSession();

          // Second scenario, reproducing the "I clicked many times but only
          // got two screenshots" report: a fast burst of clicks immediately
          // followed by finishing the session, so most clicks are still
          // queued (frames still encoding) when the stop lands.
          const burstGuide = store.createGuide({ title: 'burst selftest' });
          capture.startSession(burstGuide.guideId, { intervalSec: 0 });
          capture.stopClickWatcher();
          capture.togglePause(false);
          mainWindow.hide();
          await capture.startClickFrameBackend();
          await new Promise((res) => setTimeout(res, 1500));
          const burstCount = 8;
          for (let i = 0; i < burstCount; i++) {
            const p = {
              x: Math.round(bounds.x + bounds.width * (0.15 + 0.08 * i)),
              y: Math.round(bounds.y + bounds.height * 0.5),
            };
            capture.onOsClick(Date.now(), toPhysical(p), 'button-1');
            await new Promise((res) => setTimeout(res, 30)); // very fast clicking
          }
          // Finish right away — clicks are still mid-encode in the queue.
          capture.finishSession();
          await capture.clickQueue;
          await new Promise((res) => setTimeout(res, 1000));
          const burstSteps = store.getGuide(burstGuide.guideId).stepsOrder.length;
          console.log('CLICK-SELFTEST burst:', burstSteps, 'of', burstCount,
            burstSteps === burstCount ? 'OK — no clicks dropped on finish' : 'FAIL — clicks lost');

          // Helper: wait until armRecording has finished warming (window
          // hidden, buffer primed) so an injected click counts as a real
          // recording click rather than being ignored as a warmup click.
          const waitArmed = async () => {
            for (let i = 0; i < 80 && capture.warmingUp; i++) {
              await new Promise((res) => setTimeout(res, 50));
            }
          };

          // Third scenario: the real "Start recording" path. armRecording
          // warms the recorder while the window is visible and only arms the
          // session once it hides; the first click *after* arming must get a
          // pre-click frame (not the post-click shot that made "the first
          // screenshot late"), and a click *during* warmup must be ignored,
          // not mishandled. (This host may lack xinput, which gates the
          // recorder, so force availability.)
          const armGuide = store.createGuide({ title: 'arm selftest' });
          mainWindow.show();
          await new Promise((res) => setTimeout(res, 300));
          capture.startSession(armGuide.guideId, { intervalSec: 0 });
          capture.stopClickWatcher();
          capture.clickCaptureAvailable = () => true;
          capture.hiddenForSession = true; // window was visible at session start
          capture.togglePause(false); // armRecording: warm → hide → arm
          // A click during warmup must be ignored (window still visible).
          await new Promise((res) => setTimeout(res, 200));
          const warmupClicks = store.getGuide(armGuide.guideId).stepsOrder.length;
          capture.onOsClick(Date.now(), toPhysical({ x: bounds.x + 100, y: bounds.y + 100 }), 'button-1');
          await waitArmed();
          const armPoint = {
            x: Math.round(bounds.x + bounds.width * 0.4),
            y: Math.round(bounds.y + bounds.height * 0.4),
          };
          capture.onOsClick(Date.now(), toPhysical(armPoint), 'button-1');
          await capture.clickQueue;
          await new Promise((res) => setTimeout(res, 800));
          const armSteps = store.getGuide(armGuide.guideId).stepsOrder.length;
          console.log('CLICK-SELFTEST arm: warmup-click steps', warmupClicks,
            '-> after-arm steps', armSteps,
            armSteps === 1 ? 'OK — warmup click ignored, first armed click captured' : 'FAIL');
          capture.finishSession();

          // Fourth scenario: the debounce itself, exercised end to end through
          // onOsClick. A fast burst (40ms apart) must collapse to one step,
          // and deliberate clicks (300ms apart) must each register.
          settings.set('capture.clickDebounceMs', 200);
          const dbGuide = store.createGuide({ title: 'debounce selftest' });
          mainWindow.show();
          await new Promise((res) => setTimeout(res, 200));
          capture.startSession(dbGuide.guideId, { intervalSec: 0 });
          capture.stopClickWatcher();
          capture.clickCaptureAvailable = () => true;
          capture.hiddenForSession = true;
          capture.togglePause(false);
          await capture.startClickFrameBackend();
          await waitArmed();
          await new Promise((res) => setTimeout(res, 300));
          const dbPoint = {
            x: Math.round(bounds.x + bounds.width * 0.55),
            y: Math.round(bounds.y + bounds.height * 0.55),
          };
          // 4 clicks 40ms apart — accidental fast clicking → expect 1 step.
          for (let i = 0; i < 4; i++) {
            capture.onOsClick(Date.now(), toPhysical(dbPoint), 'button-1');
            await new Promise((res) => setTimeout(res, 40));
          }
          // 3 deliberate clicks 300ms apart → expect 3 more steps.
          for (let i = 0; i < 3; i++) {
            await new Promise((res) => setTimeout(res, 300));
            capture.onOsClick(Date.now(), toPhysical(dbPoint), 'button-1');
          }
          await capture.clickQueue;
          await new Promise((res) => setTimeout(res, 800));
          const dbSteps = store.getGuide(dbGuide.guideId).stepsOrder.length;
          console.log('CLICK-SELFTEST debounce:', dbSteps, 'of 4 expected',
            dbSteps === 4 ? 'OK — burst collapsed to 1, three deliberate clicks kept' : 'FAIL');
          capture.finishSession();
        } catch (err) {
          console.log('CLICK-SELFTEST ERROR', err.message);
        } finally {
          app.quit();
        }
      }, 1500);
    }
    // Dev-only self-test: exercise the exact hotkey-session capture path
    // (hide window -> grab -> showInactive) several times, then exit.
    if (process.env.STEPFORGE_CAPTURE_SELFTEST) {
      setTimeout(async () => {
        try {
          const guide = store.createGuide({ title: 'hotkey selftest' });
          capture.startSession(guide.guideId, { intervalSec: 0 });
          // Sessions start paused until "Start recording" is pressed; do
          // that here instead of waiting out the toast-grace delay, and
          // hide the window immediately rather than after the 400ms pause.
          capture.togglePause(false);
          mainWindow.hide();
          await new Promise((res) => setTimeout(res, 400));
          const results = [];
          for (let i = 0; i < 3; i++) {
            const r = await capture.hotkeyCapture();
            results.push(r.ok ? 'OK' : `FAIL:${r.reason}`);
            await new Promise((res) => setTimeout(res, 500));
          }
          console.log('HOTKEY-SELFTEST', JSON.stringify(results),
            'steps:', store.getGuide(guide.guideId).stepsOrder.length);

          // Interval auto-capture: 1s timer should add ~3 steps in 3.6s.
          const guide2 = store.createGuide({ title: 'interval selftest' });
          capture.startSession(guide2.guideId, { intervalSec: 1 });
          capture.togglePause(false);
          await new Promise((res) => setTimeout(res, 3600));
          capture.finishSession();
          console.log('INTERVAL-SELFTEST steps:', store.getGuide(guide2.guideId).stepsOrder.length);
        } catch (err) {
          console.log('SELFTEST ERROR', err.message);
        } finally {
          app.quit();
        }
      }, 1500);
    }
  });
  mainWindow.on('closed', () => { mainWindow = null; });
}

function registerHotkeys() {
  globalShortcut.unregisterAll();
  const accel = settings.get('capture.hotkeyCapture');
  const pauseAccel = settings.get('capture.hotkeyPauseResume');
  try {
    if (accel) {
      globalShortcut.register(accel, () => {
        capture.hotkeyCapture().catch(() => {});
      });
    }
    if (pauseAccel) {
      globalShortcut.register(pauseAccel, () => {
        capture.togglePause();
        sendToRenderer('capture:state', capture.state());
      });
    }
  } catch {
    // invalid accelerator strings must not crash the app
  }
}

function sendToRenderer(channel, payload) {
  if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send(channel, payload);
}

// ---- IPC ------------------------------------------------------------------

function setupIpc() {
  const h = (channel, fn) => ipcMain.handle(channel, async (event, args = {}) => fn(args));

  // library
  h('library:list', () => ({
    guides: store.listGuides().map((g) => ({
      ...g,
      stepCount: g.stepsOrder.length,
      locked: g.linkedSource ? Boolean(readLock(g.linkedSource.path)) : false,
    })),
    folders: store.loadFolders(),
  }));
  h('library:create', ({ title }) => {
    const guide = store.createGuide({
      title: title || 'Untitled guide',
      flags: { focusedViewDefault: Boolean(settings.get('editor.focusedViewDefaultForNewSteps')) },
    });
    reindex(guide.guideId);
    return guide;
  });
  h('library:duplicate', ({ guideId }) => {
    const copy = store.duplicateGuide(guideId);
    reindex(copy.guideId);
    return copy;
  });
  h('library:delete', ({ guideId }) => {
    store.deleteGuide(guideId);
    searchIndex.removeGuide(guideId);
    return true;
  });
  h('library:setFavorite', ({ guideId, favorite }) => store.setFavorite(guideId, favorite));
  h('library:trash:list', () => store.listTrash());
  h('library:trash:restore', ({ name }) => {
    const id = store.restoreFromTrash(name);
    reindex(id);
    return id;
  });
  h('library:trash:purge', ({ names } = {}) => {
    if (names && names.length) store.purgeTrashItems(names);
    else store.purgeTrash();
    return true;
  });
  h('folders:create', ({ name, parentId }) => store.createFolder(name, parentId || null));
  h('folders:rename', ({ folderId, name }) => store.renameFolder(folderId, name));
  h('folders:delete', ({ folderId }) => store.deleteFolder(folderId));
  h('folders:moveGuide', ({ guideId, folderId }) => store.moveGuideToFolder(guideId, folderId || null));

  // guide + steps
  h('guide:get', ({ guideId }) => ({
    guide: store.getGuide(guideId),
    steps: orderedSteps(guideId),
  }));
  h('guide:save', ({ guide }) => {
    const saved = store.saveGuide(guide);
    reindex(guide.guideId);
    return saved;
  });
  h('step:add', ({ guideId, fields, imageBase64, size, position }) => {
    const buf = imageBase64 ? Buffer.from(imageBase64, 'base64') : null;
    const step = store.addStep(guideId, fields || {}, buf, size || null, { position });
    reindex(guideId);
    return step;
  });
  h('step:save', ({ guideId, step }) => {
    const saved = store.saveStep(guideId, step);
    reindex(guideId);
    return saved;
  });
  h('step:delete', ({ guideId, stepId }) => {
    store.deleteStep(guideId, stepId);
    reindex(guideId);
    return true;
  });
  h('steps:reorder', ({ guideId, order }) => store.reorderSteps(guideId, order));
  h('step:imagePath', ({ guideId, stepId, which }) => {
    const p = store.stepImagePath(guideId, stepId, which || 'working');
    return p && fs.existsSync(p) ? `file://${p}?v=${fs.statSync(p).mtimeMs}` : null;
  });
  h('step:setWorkingImage', ({ guideId, stepId, pngBase64, size, step }) =>
    store.setWorkingImage(guideId, stepId, Buffer.from(pngBase64, 'base64'), size, step || null));
  h('step:resetWorkingImage', ({ guideId, stepId }) => {
    const p = store.stepImagePath(guideId, stepId, 'original');
    const img = nativeImage.createFromPath(p);
    const { width, height } = img.getSize();
    return store.resetWorkingImage(guideId, stepId, { width, height });
  });
  h('step:fromClipboard', ({ guideId, position }) => {
    const img = clipboard.readImage();
    if (img.isEmpty()) return { ok: false, reason: 'clipboard has no image' };
    const { width, height } = img.getSize();
    const step = store.addStep(guideId, {
      title: 'Pasted image',
      focusedView: { enabled: false, zoom: 1, panX: 0.5, panY: 0.5 },
    }, img.toPNG(), { width, height }, { position });
    reindex(guideId);
    return { ok: true, step };
  });
  h('step:importImage', async ({ guideId }) => {
    const res = await dialog.showOpenDialog(mainWindow, {
      title: 'Import images as steps',
      filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] }],
      properties: ['openFile', 'multiSelections'],
    });
    if (res.canceled) return { ok: false };
    const steps = [];
    for (const file of res.filePaths) {
      const img = nativeImage.createFromPath(file);
      if (img.isEmpty()) continue;
      const { width, height } = img.getSize();
      steps.push(store.addStep(guideId, { title: path.basename(file, path.extname(file)) },
        img.toPNG(), { width, height }));
    }
    reindex(guideId);
    return { ok: true, steps };
  });

  // search
  h('search:query', ({ q, guideId }) => searchIndex.search(q, { guideId: guideId || null }));
  h('search:titles', ({ q }) => searchIndex.searchTitles(q));

  // settings + placeholders
  h('settings:all', () => settings.data);
  h('settings:set', ({ keyPath, value }) => {
    settings.set(keyPath, value);
    if (keyPath === 'appearance') applyTheme();
    if (keyPath.startsWith('capture.hotkey')) registerHotkeys();
    return settings.data;
  });
  h('placeholders:globals:get', () => settings.getGlobalPlaceholders());
  h('placeholders:globals:set', ({ values }) => settings.setGlobalPlaceholders(values));

  // capture
  h('capture:shoot', async ({ guideId, mode, delayMs }) => {
    const result = await capture.shoot({ guideId, mode, delayMs });
    if (result.ok) reindex(guideId);
    return result;
  });
  h('capture:region', async ({ guideId }) => {
    const result = await capture.regionCapture(guideId);
    if (result.ok) reindex(guideId);
    return result;
  });
  h('capture:session', async ({ action, guideId, intervalSec }) => {
    if (action === 'start') capture.startSession(guideId, { intervalSec: intervalSec ?? null });
    else if (action === 'pause') capture.togglePause(true);
    else if (action === 'resume') capture.togglePause(false);
    else if (action === 'finish') capture.finishSession();
    else if (action === 'interval') capture.setInterval(intervalSec);
    const state = capture.state();
    sendToRenderer('capture:state', state);
    return state;
  });
  h('capture:state', () => capture.state());

  // archives & linked guides
  h('archive:export', async ({ guideId }) => {
    const guide = store.getGuide(guideId);
    const res = await dialog.showSaveDialog(mainWindow, {
      title: 'Share guide as file',
      defaultPath: `${guide.title.replace(/[/\\:]+/g, '-')}.sfgz`,
      filters: [{ name: 'StepForge guide archive', extensions: ['sfgz'] }],
    });
    if (res.canceled) return { ok: false };
    exportGuideArchive(store, guideId, res.filePath);
    return { ok: true, path: res.filePath };
  });
  h('archive:open', async ({ mode }) => {
    const res = await dialog.showOpenDialog(mainWindow, {
      title: 'Open guide archive',
      filters: [{ name: 'StepForge guide archive', extensions: ['sfgz'] }],
      properties: ['openFile'],
    });
    if (res.canceled) return { ok: false };
    try {
      const guide = importGuideArchive(store, res.filePaths[0], { mode: mode || 'copy' });
      reindex(guide.guideId);
      return { ok: true, guide };
    } catch (err) {
      return { ok: false, reason: err.message };
    }
  });
  h('archive:peek', ({ file }) => {
    const { manifest } = readArchive(file);
    return manifest;
  });
  h('archive:saveLinked', ({ guideId, force }) => saveLinkedGuide(store, guideId, { force: Boolean(force) }));

  // snapshots
  h('snapshots:list', ({ guideId }) => listSnapshots(store, guideId));
  h('snapshots:create', ({ guideId, label }) =>
    createSnapshot(store, guideId, { label: label || 'manual', keepLast: settings.get('backups.keepLast') }));
  h('snapshots:restore', ({ guideId, name }) => {
    const guide = restoreSnapshot(store, guideId, name);
    reindex(guideId);
    return guide;
  });

  // templates
  h('templates:list', ({ format }) => templates.list(format));
  h('templates:load', ({ format, name }) => templates.load(format, name));
  h('templates:save', ({ format, name, options }) => templates.save(format, name, options));
  h('templates:delete', ({ format, name }) => templates.remove(format, name));
  h('templates:rename', ({ format, name, newName }) => templates.rename(format, name, newName));
  h('templates:duplicate', ({ format, name }) => templates.duplicate(format, name));
  h('templates:export', async ({ format, name }) => {
    const res = await dialog.showSaveDialog(mainWindow, {
      defaultPath: `${name}.sfglt`,
      filters: [{ name: 'StepForge template', extensions: ['sfglt'] }],
    });
    if (res.canceled) return { ok: false };
    templates.exportTemplate(format, name, res.filePath);
    return { ok: true };
  });
  h('templates:import', async () => {
    const res = await dialog.showOpenDialog(mainWindow, {
      filters: [{ name: 'StepForge template', extensions: ['sfglt'] }],
      properties: ['openFile'],
    });
    if (res.canceled) return { ok: false };
    return { ok: true, ...templates.importTemplate(res.filePaths[0]) };
  });

  // export + preview
  h('export:formats', () => FORMATS.filter((f) => EXPORTERS[f]));
  h('export:defaults', ({ format }) => {
    // Exporter modules expose DEFAULT_TEMPLATE; the dialog renders editable
    // options from it (booleans -> checkbox, numbers -> number, strings -> text).
    const mod = {
      json: '../exporters/json',
      markdown: '../exporters/markdown',
      'html-simple': '../exporters/html',
      'html-rich': '../exporters/html',
      confluence: '../exporters/confluence',
      pdf: '../exporters/pdf',
      gif: '../exporters/gif',
      'image-bundle': '../exporters/image-bundle',
      docx: '../exporters/docx',
      pptx: '../exporters/pptx',
    }[format];
    if (!mod) return {};
    return { ...require(mod).DEFAULT_TEMPLATE };
  });
  h('export:run', async ({ guideId, format, options, outDir }) => {
    let dir = outDir || settings.get(`exports.lastOutputDirs.${format}`);
    if (!dir) {
      const res = await dialog.showOpenDialog(mainWindow, {
        title: 'Choose output folder', properties: ['openDirectory', 'createDirectory'],
      });
      if (res.canceled) return { ok: false };
      dir = res.filePaths[0];
    }
    settings.set(`exports.lastOutputDirs.${format}`, dir);
    const ast = buildRenderAst(store, guideId, { globals: settings.getGlobalPlaceholders() });
    const result = runExport(format, ast, dir, options || {});
    if (settings.get('exports.openFolderAfterExport')) shell.showItemInFolder(result.file);
    return { ok: true, ...result };
  });
  h('export:chooseDir', async ({ format }) => {
    const res = await dialog.showOpenDialog(mainWindow, {
      title: 'Choose output folder', properties: ['openDirectory', 'createDirectory'],
    });
    if (res.canceled) return null;
    settings.set(`exports.lastOutputDirs.${format}`, res.filePaths[0]);
    return res.filePaths[0];
  });
  h('export:preview', ({ guideId, format, options }) => {
    const previewDir = path.join(store.tempDir, `preview-${guideId}-${format}`);
    fs.rmSync(previewDir, { recursive: true, force: true });
    const ast = buildRenderAst(store, guideId, {
      globals: settings.getGlobalPlaceholders(),
      maxSteps: settings.get('exports.previewStepCount') || 3,
    });
    const result = runExport(format, ast, previewDir, options || {});
    return { ok: true, file: result.file, fileUrl: `file://${result.file}` };
  });
  h('preview:cleanup', () => {
    for (const entry of fs.readdirSync(store.tempDir)) {
      if (entry.startsWith('preview-')) {
        fs.rmSync(path.join(store.tempDir, entry), { recursive: true, force: true });
      }
    }
    return true;
  });

  // shell helpers
  h('shell:openPath', ({ target }) => shell.openPath(target));
  h('shell:showItemInFolder', ({ target }) => shell.showItemInFolder(target));
  h('app:info', () => ({
    version: app.getVersion(),
    dataDir: store.root,
    platform: process.platform,
  }));
}

// ---- lifecycle --------------------------------------------------------------

const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
  // Exiting silently here looks like a broken install ("npm start does
  // nothing") — say why, and let the running instance surface itself.
  console.error('[stepforge] already running — surfacing the existing window (check the tray).');
  app.quit();
} else {
  app.on('second-instance', () => {
    if (!mainWindow) return;
    // The window may be tucked away by a recording session; opening the
    // app again is an explicit request to see it, so pause and show, the
    // same as the tray's "Open StepForge".
    if (capture) capture.togglePause(true);
    if (mainWindow.isMinimized()) mainWindow.restore();
    mainWindow.show();
    mainWindow.focus();
  });

  app.whenReady().then(() => {
    const dataDir = resolveDataDir();
    store = new GuideStore(dataDir);
    settings = new Settings(store.settingsDir);
    searchIndex = new SearchIndex(store.indexDir);
    templates = new TemplateManager(store.templatesDir);
    capture = new CaptureService({
      store,
      settings,
      getWindow: () => mainWindow,
      notify: sendToRenderer,
    });

    applyTheme();
    setupIpc();
    createWindow();
    registerHotkeys();

    app.on('activate', () => {
      if (BrowserWindow.getAllWindows().length === 0) createWindow();
    });
  });

  app.on('will-quit', () => {
    globalShortcut.unregisterAll();
    if (capture) {
      // Targeted cleanup (not finishSession — that re-shows the window).
      capture.stopClickWatcher();
      capture.destroySessionTray();
    }
    // clean preview temp files on close
    try {
      for (const entry of fs.readdirSync(store.tempDir)) {
        fs.rmSync(path.join(store.tempDir, entry), { recursive: true, force: true });
      }
    } catch { /* best effort */ }
  });

  app.on('window-all-closed', () => {
    if (process.platform !== 'darwin') app.quit();
  });
}
