import fs from 'node:fs/promises';
import path from 'node:path';

import { app, BrowserWindow, dialog, Menu, protocol } from 'electron';

import { installStdoutEpipeGuards } from './safeConsole';

installStdoutEpipeGuards();

import { openDialogFilterLabel } from '../shared/appBranding';
import { USERS_BRANCH_FEATURES_ENABLED } from '../shared/features/usersBranchFeatures';
import { ipcChannels, type ScenePreviewImportEvent, type SessionState } from '../shared/ipc/contracts';
import {
  PROJECT_ZIP_OPEN_DIALOG_FILTER,
  PROJECT_ZIP_SAVE_DIALOG_FILTER,
  isProjectZipFileName,
  normalizeSaveProjectZipPath,
  projectZipFileNameFromBase,
  stripProjectZipExtension,
} from '../shared/project/projectZipExtension';
import type { Project } from '../shared/types';
import { asNpcId } from '../shared/types/ids';
import {
  asPreviewRotationDeg,
  previewRotationStepsCw,
} from '../shared/types/scenePreviewRotation';

import { EffectsStore, effectsDefaultTool } from './effects/effectsStore';
import { SceneDarknessStore } from './effects/sceneDarknessStore';
import { SceneTrapsStore } from './sceneTraps/sceneTrapsStore';
import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/router';
import { LicenseService } from './license/licenseService';
import { MaterialsOverlayStore } from './materials/materialsOverlayStore';
import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
import { ZipProjectStore } from './project/zipStore';
import { SceneViewStore } from './sceneView/sceneViewStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
import { PlayersStore } from './players/playersStore';
import { SceneNpcTokensSessionStore } from './players/sceneNpcTokensSessionStore';
import { ScenePlayerTokensSessionStore } from './players/scenePlayerTokensSessionStore';
import { SceneTokensSessionStore } from './tokens/sceneTokensSessionStore';
import { TokenGridSnapSessionStore } from './tokens/tokenGridSnapSessionStore';
import { TokensStore } from './tokens/tokensStore';
import { installAutoUpdater } from './update/installAutoUpdater';
import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo';
import { VideoPlaybackStore } from './video/videoPlaybackStore';
import {
  createBootWindow,
  destroyBootWindow,
  setBootWindowStatus,
  waitForBootWindowReady,
} from './windows/bootWindow';
import {
  applyDockIconIfNeeded,
  closeMultiWindow,
  closeSceneDescriptionWindow,
  createEditorWindowDeferred,
  createWindows,
  focusEditorWindow,
  getPresentationContentSize,
  getSceneDescriptionContent,
  getTokenPathEditorTarget,
  isMultiWindowOpen,
  markAppQuitting,
  openMaterialsWindow,
  openMultiWindow,
  openNpcsEditorWindow,
  openSceneEditorWindow,
  openTokenPathEditorWindow,
  openNpcsWindow,
  openSceneDescriptionWindow,
  closeMaterialsWindow,
  closeNpcsEditorWindow,
  closeSceneEditorWindow,
  closeTokenPathEditorWindow,
  closeNpcsWindow,
  sendToAppWindows,
  syncAllWindowChromeTitles,
  togglePresentationFullscreen,
  waitForEditorWindowReady,
  warmNpcsEditorWindow,
} from './windows/createWindows';
import { TokenPathSessionStore } from './tokens/tokenPathSessionStore';
import { tokenPathTotalLength } from '../shared/types/tokenPath';
import type { TokenPathTargetKind } from '../shared/types/tokenPathSession';

function emitZipProgress(evt: {
  kind: 'import' | 'export';
  stage: string;
  percent: number;
  detail?: string;
}): void {
  for (const win of BrowserWindow.getAllWindows()) {
    win.webContents.send(
      evt.kind === 'import' ? ipcChannels.project.importZipProgress : ipcChannels.project.exportZipProgress,
      evt,
    );
  }
}

function emitScenePreviewImportProgress(evt: ScenePreviewImportEvent): void {
  for (const win of BrowserWindow.getAllWindows()) {
    win.webContents.send(ipcChannels.project.scenePreviewImportProgress, evt);
  }
}

function emitMaterialUpsertProgress(evt: { percent: number; stage: string; detail?: string }): void {
  for (const win of BrowserWindow.getAllWindows()) {
    win.webContents.send(ipcChannels.project.materialUpsertProgress, evt);
  }
}

function emitNpcUpsertProgress(evt: { percent: number; stage: string; detail?: string }): void {
  for (const win of BrowserWindow.getAllWindows()) {
    win.webContents.send(ipcChannels.project.npcUpsertProgress, evt);
  }
}

/**
 * Отключение GPU ломает скорость вторичных окон (презентация/пульт — WebGL). По умолчанию не трогаем.
 * При чёрном экране в упакованной сборке: `DND_DISABLE_GPU=1`.
 */
if (process.platform === 'win32' && app.isPackaged && process.env.DND_DISABLE_GPU === '1') {
  app.disableHardwareAcceleration();
}

if (process.platform === 'win32') {
  app.setAppUserModelId('com.ttrpgplayer.app');
}
// Не вызывать app.setName() с другим именем: на Windows/macOS меняется каталог userData,
// и проекты в …/userData/projects «пропадают» из списка (остаются в старой папке).

protocol.registerSchemesAsPrivileged([
  {
    scheme: 'dnd',
    privileges: {
      standard: true,
      secure: true,
      supportFetchAPI: true,
      corsEnabled: true,
    },
  },
]);

const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
  app.quit();
} else {
  app.on('second-instance', () => {
    focusEditorWindow();
  });
}

const projectStore = new ZipProjectStore();

/** Системная полоса меню Electron отключена во всех режимах (в т.ч. без пункта «Вид»). */
function installAppMenuForSession(): void {
  Menu.setApplicationMenu(null);
}

const effectsStore = new EffectsStore();
const sceneDarknessStore = new SceneDarknessStore();
const sceneTrapsStore = new SceneTrapsStore();
const sceneViewStore = new SceneViewStore();
const videoStore = new VideoPlaybackStore();
const materialsOverlayStore = new MaterialsOverlayStore();
const npcsOverlayStore = new NpcsOverlayStore();
const sceneTokensSessionStore = new SceneTokensSessionStore();
const tokenPathSessionStore = new TokenPathSessionStore();
const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore();
const tokenGridSnapSessionStore = new TokenGridSnapSessionStore();
let tokensStore: TokensStore | null = null;
let playersStore: PlayersStore | null = null;

function emitTokenPathSessionState(): void {
  const state = tokenPathSessionStore.getState();
  for (const win of BrowserWindow.getAllWindows()) {
    win.webContents.send(ipcChannels.tokenPathSession.stateChanged, { state });
  }
}

function seedTokenPathPlaybackForProject(project: Project | null): void {
  tokenPathSessionStore.reset();
  if (!project?.currentSceneId) {
    emitTokenPathSessionState();
    return;
  }
  const scene = project.scenes[project.currentSceneId];
  if (!scene) {
    emitTokenPathSessionState();
    return;
  }
  const now = Date.now();
  const seedOne = (kind: TokenPathTargetKind, placementId: string, path: NonNullable<(typeof scene.tokens)[number]['path']>) => {
    const pathLength = tokenPathTotalLength(path);
    if (pathLength <= 1e-9) return;
    tokenPathSessionStore.dispatch({
      kind: 'seedPlayback',
      entry: {
        kind,
        placementId,
        phase: path.startMode === 'delayed' ? 'delay' : 'moving',
        baseDist: 0,
        direction: 1,
        rejoinDist: null,
        durationSec: path.durationSec,
        pathLength,
        segmentStartedAtMs: now,
      },
    });
  };
  for (const t of scene.tokens ?? []) {
    if (t.path && t.path.points.length >= 2) seedOne('token', String(t.id), t.path);
  }
  for (const t of scene.npcTokens ?? []) {
    if (t.path && t.path.points.length >= 2) seedOne('npcToken', String(t.id), t.path);
  }
  emitTokenPathSessionState();
}

function emitEffectsState(): void {
  const state = effectsStore.getState();
  for (const win of BrowserWindow.getAllWindows()) {
    win.webContents.send(ipcChannels.effects.stateChanged, { state });
  }
}

function emitMaterialsOverlayState(): void {
  const state = materialsOverlayStore.getState();
  for (const win of BrowserWindow.getAllWindows()) {
    win.webContents.send(ipcChannels.materialsOverlay.stateChanged, { state });
  }
}

function syncMaterialsOverlayWithProject(project: Project | null): void {
  if (!project) {
    materialsOverlayStore.clear();
    return;
  }
  const ids = new Set((project.materials ?? []).map((m) => m.id));
  materialsOverlayStore.ensureMaterialStillExists(ids);
}

function emitNpcsOverlayState(): void {
  const state = npcsOverlayStore.getState();
  for (const win of BrowserWindow.getAllWindows()) {
    win.webContents.send(ipcChannels.npcsOverlay.stateChanged, { state });
  }
}

function syncNpcsOverlayWithProject(project: Project | null): void {
  if (!project) {
    npcsOverlayStore.clear();
    return;
  }
  const ids = new Set((project.npcs ?? []).map((n) => n.id));
  npcsOverlayStore.ensureNpcStillExists(ids);
}

function emitSceneDarknessState(): void {
  const state = sceneDarknessStore.getState();
  for (const win of BrowserWindow.getAllWindows()) {
    win.webContents.send(ipcChannels.sceneDarkness.stateChanged, { state });
  }
}

function emitSceneViewState(): void {
  const state = sceneViewStore.getState();
  for (const win of BrowserWindow.getAllWindows()) {
    win.webContents.send(ipcChannels.sceneView.stateChanged, { state });
  }
}

function emitTokensState(): void {
  const tokens = tokensStore?.list() ?? [];
  for (const win of BrowserWindow.getAllWindows()) {
    win.webContents.send(ipcChannels.tokens.stateChanged, { tokens });
  }
}

function emitSceneTokensSessionState(): void {
  const state = sceneTokensSessionStore.getState();
  for (const win of BrowserWindow.getAllWindows()) {
    win.webContents.send(ipcChannels.sceneTokensSession.stateChanged, { state });
  }
}

function emitTokenGridSnapState(): void {
  const { enabled } = tokenGridSnapSessionStore.getState();
  for (const win of BrowserWindow.getAllWindows()) {
    win.webContents.send(ipcChannels.tokenGridSnap.stateChanged, { enabled });
  }
}

function emitPlayersState(): void {
  const players = playersStore?.listPlayers() ?? [];
  const teams = playersStore?.listTeams() ?? [];
  for (const win of BrowserWindow.getAllWindows()) {
    win.webContents.send(ipcChannels.players.stateChanged, { players, teams });
  }
}

function emitPlayerUpsertProgress(p: { percent: number; stage: string; detail?: string }): void {
  for (const win of BrowserWindow.getAllWindows()) {
    win.webContents.send(ipcChannels.players.upsertProgress, p);
  }
}

function emitSceneNpcTokensSessionState(): void {
  const state = sceneNpcTokensSessionStore.getState();
  for (const win of BrowserWindow.getAllWindows()) {
    win.webContents.send(ipcChannels.sceneNpcTokensSession.stateChanged, { state });
  }
}

let npcMapSpawnDragNpcId: string | null = null;
let npcMapSpawnHover: { nx: number; ny: number } | null = null;

function emitNpcMapSpawnDragState(): void {
  const state = {
    dragging: Boolean(npcMapSpawnDragNpcId),
    npcId: npcMapSpawnDragNpcId,
    hoverNx: npcMapSpawnHover?.nx ?? null,
    hoverNy: npcMapSpawnHover?.ny ?? null,
  };
  for (const win of BrowserWindow.getAllWindows()) {
    win.webContents.send(ipcChannels.npcMapSpawn.stateChanged, { state });
  }
}

function clearNpcMapSpawnDrag(): void {
  if (!npcMapSpawnDragNpcId && !npcMapSpawnHover) return;
  npcMapSpawnDragNpcId = null;
  npcMapSpawnHover = null;
  emitNpcMapSpawnDragState();
}

function emitScenePlayerTokensSessionState(): void {
  const state = scenePlayerTokensSessionStore.getState();
  for (const win of BrowserWindow.getAllWindows()) {
    win.webContents.send(ipcChannels.scenePlayerTokensSession.stateChanged, { state });
  }
}

function syncSceneDarknessForProject(project: Project): void {
  const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null;
  const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined;
  const enabled =
    Boolean(scene?.darkenScene) &&
    (scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video');
  sceneDarknessStore.switchScene(cacheKey, enabled);
}

function emitSceneTrapsState(): void {
  const state = sceneTrapsStore.getState();
  for (const win of BrowserWindow.getAllWindows()) {
    win.webContents.send(ipcChannels.sceneTraps.stateChanged, { state });
  }
}

function syncSceneTrapsForProject(project: Project): void {
  const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null;
  const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined;
  const trapIds = (scene?.traps ?? []).map((t) => t.id);
  sceneTrapsStore.switchScene(cacheKey, trapIds);
}

function emitVideoState(): void {
  const state = videoStore.getState();
  for (const win of BrowserWindow.getAllWindows()) {
    win.webContents.send(ipcChannels.video.stateChanged, { state });
  }
}

// Периодически чистим истёкшие эффекты (в основном молнии).
setInterval(() => {
  if (effectsStore.pruneExpired()) {
    emitEffectsState();
  }
}, 500);

// Пока видео "играет" — периодически рассылаем state (нужен для новых окон и коррекции).
setInterval(() => {
  if (videoStore.getState().playing) {
    emitVideoState();
  }
}, 500);

function emitSessionState(): void {
  const project = projectStore.getOpenProject();
  const state: SessionState = {
    project,
    currentSceneId: project?.currentSceneId ?? null,
  };
  // Не шлём полный project в editor/splash — редактор обновляется через invoke-ответы.
  sendToAppWindows(ipcChannels.session.stateChanged, { state });
}

/**
 * Упакованное приложение: экран загрузки → проверки → редактор.
 * В dev по умолчанию без экрана; тест: `DND_SHOW_BOOT=1`. Отключить везде: `DND_SKIP_BOOT=1`.
 */
async function runStartupAfterHandlers(licenseService: LicenseService): Promise<void> {
  const useBootSequence =
    process.env.DND_SKIP_BOOT !== '1' && (app.isPackaged || process.env.DND_SHOW_BOOT === '1');

  if (!useBootSequence) {
    createWindows();
    emitSessionState();
    emitEffectsState();
    emitMaterialsOverlayState();
    emitNpcsOverlayState();
    emitVideoState();
    return;
  }

  const splash = createBootWindow();
  try {
    await waitForBootWindowReady(splash);
  } catch (err) {
    console.error('[boot] splash load failed', err);
    destroyBootWindow(splash);
    createWindows();
    emitSessionState();
    emitEffectsState();
    emitMaterialsOverlayState();
    emitNpcsOverlayState();
    emitVideoState();
    return;
  }

  splash.show();
  setBootWindowStatus(splash, 'Инициализация…');

  try {
    setBootWindowStatus(splash, 'Подготовка данных…');
    await projectStore.ensureRoots();
  } catch (e) {
    console.error('[boot] ensureRoots', e);
  }

  setBootWindowStatus(splash, 'Проверка лицензии…');
  // Сеть в `getStatus()` не блокируем старт: синхронный снимок, отзыв — в фоне.
  licenseService.getStatusSync();
  queueMicrotask(() => {
    licenseService.getStatus();
  });

  setBootWindowStatus(splash, 'Загрузка редактора…');
  const editor = createEditorWindowDeferred();
  const bootEditorMs = 2000;
  await Promise.race([
    waitForEditorWindowReady(editor),
    new Promise<void>((resolve) => setTimeout(resolve, bootEditorMs)),
  ]);
  setBootWindowStatus(splash, 'Готово');
  destroyBootWindow(splash);
  if (!editor.isDestroyed()) {
    editor.show();
    editor.focus();
  }

  emitSessionState();
  emitEffectsState();
  emitMaterialsOverlayState();
  emitNpcsOverlayState();
  emitVideoState();
}

async function main() {
  await app.whenReady();
  /**
   * `ZipProjectStore` пишет `project.json` в кэш синхронно с мутациями, а упаковку `.dnd.zip`
   * откладывает (`queueSave`, ~250 мс). Без финального `saveNow()` при выходе на диске
   * остаётся старый zip — при следующем открытии кэш пересоздаётся из него и теряются
   * недавние поля (в т.ч. `campaignAudios`).
   */
  let appQuittingAfterPendingSave = false;
  app.on('before-quit', (e) => {
    if (appQuittingAfterPendingSave) return;
    e.preventDefault();
    appQuittingAfterPendingSave = true;
    void projectStore
      .saveNow()
      .catch((err: unknown) => {
        console.error('[before-quit] saveNow failed', err);
      })
      .finally(() => {
        app.quit();
      });
  });

  const licenseService = new LicenseService(app.getPath('userData'));
  tokensStore = new TokensStore(app.getPath('userData'));
  await tokensStore.ensureLoaded();
  if (USERS_BRANCH_FEATURES_ENABLED) {
    playersStore = new PlayersStore(app.getPath('userData'));
    await playersStore.ensureLoaded();
  }
  setLicenseAssert(() => {
    licenseService.assertForIpc();
  });
  installAppMenuForSession();
  registerDndAssetProtocol(projectStore, tokensStore, playersStore);
  registerHandler(ipcChannels.app.quit, () => {
    markAppQuitting();
    app.quit();
    return { ok: true };
  });
  registerHandler(ipcChannels.app.getVersion, () => ({
    version: getAppSemanticVersion(),
    buildNumber: getOptionalBuildNumber(),
    packaged: app.isPackaged,
  }));
  registerHandler(ipcChannels.license.getStatus, () => licenseService.getStatus());
  registerHandler(ipcChannels.license.setToken, async ({ token }) => licenseService.setToken(token));
  registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken());
  registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version));
  registerHandler(ipcChannels.windows.openMultiWindow, (req) => {
    sceneDarknessStore.resetSession();
    sceneTrapsStore.resetSession();
    sceneTokensSessionStore.reset();
    tokenPathSessionStore.reset();
    sceneNpcTokensSessionStore.reset();
    clearNpcMapSpawnDrag();
    scenePlayerTokensSessionStore.reset();
    tokenGridSnapSessionStore.reset();
    if (USERS_BRANCH_FEATURES_ENABLED) {
      const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : [];
      if (playerIds.length > 0) {
        scenePlayerTokensSessionStore.dispatch({ kind: 'setSelection', playerIds });
      }
    }
    effectsStore.dispatch({ kind: 'tool.set', tool: effectsDefaultTool() });
    openMultiWindow();
    const project = projectStore.getOpenProject();
    if (project) {
      syncSceneDarknessForProject(project);
      syncSceneTrapsForProject(project);
      seedTokenPathPlaybackForProject(project);
    } else {
      emitTokenPathSessionState();
    }
    emitSceneDarknessState();
    emitSceneTrapsState();
    emitSceneTokensSessionState();
    emitSceneNpcTokensSessionState();
    emitScenePlayerTokensSessionState();
    emitTokenGridSnapState();
    emitEffectsState();
    return { ok: true };
  });
  registerHandler(ipcChannels.windows.closeMultiWindow, () => {
    closeMultiWindow();
    tokenGridSnapSessionStore.reset();
    emitTokenGridSnapState();
    return { ok: true };
  });
  registerHandler(ipcChannels.windows.syncChromeTitles, ({ localeTag }) => {
    syncAllWindowChromeTitles(localeTag);
    return { ok: true };
  });
  registerHandler(ipcChannels.windows.togglePresentationFullscreen, () => {
    const isFullScreen = togglePresentationFullscreen();
    return { ok: true, isFullScreen };
  });
  registerHandler(ipcChannels.windows.getMultiWindowState, () => {
    return { open: isMultiWindowOpen() };
  });
  registerHandler(ipcChannels.windows.getPresentationContentSize, () => {
    const size = getPresentationContentSize();
    return size ?? { width: null, height: null };
  });
  registerHandler(ipcChannels.windows.openSceneDescription, ({ html }) => {
    openSceneDescriptionWindow(html);
    return { ok: true };
  });
  registerHandler(ipcChannels.windows.closeSceneDescription, () => {
    closeSceneDescriptionWindow();
    return { ok: true };
  });
  registerHandler(ipcChannels.windows.getSceneDescriptionContent, () => {
    return { html: getSceneDescriptionContent() };
  });
  registerHandler(ipcChannels.windows.openMaterials, () => {
    openMaterialsWindow();
    return { ok: true };
  });
  registerHandler(ipcChannels.windows.closeMaterials, () => {
    closeMaterialsWindow();
    return { ok: true };
  });
  registerHandler(ipcChannels.windows.openNpcsEditor, () => {
    openNpcsEditorWindow();
    return { ok: true };
  });
  registerHandler(ipcChannels.windows.closeNpcsEditor, () => {
    closeNpcsEditorWindow();
    return { ok: true };
  });
  registerHandler(ipcChannels.windows.openSceneEditor, () => {
    openSceneEditorWindow();
    return { ok: true };
  });
  registerHandler(ipcChannels.windows.closeSceneEditor, () => {
    closeSceneEditorWindow();
    return { ok: true };
  });
  registerHandler(ipcChannels.windows.openTokenPathEditor, ({ kind, placementId }) => {
    openTokenPathEditorWindow(kind, placementId);
    return { ok: true };
  });
  registerHandler(ipcChannels.windows.closeTokenPathEditor, () => {
    closeTokenPathEditorWindow();
    return { ok: true };
  });
  registerHandler(ipcChannels.windows.getTokenPathEditorTarget, () => getTokenPathEditorTarget());
  registerHandler(ipcChannels.windows.openNpcs, (req) => {
    openNpcsWindow();
    const npcId = req?.npcId ? asNpcId(String(req.npcId)) : null;
    if (npcId) {
      npcsOverlayStore.dispatch({ kind: 'show', npcId });
      emitNpcsOverlayState();
    }
    return { ok: true };
  });
  registerHandler(ipcChannels.windows.closeNpcs, () => {
    closeNpcsWindow();
    return { ok: true };
  });

  registerHandler(ipcChannels.materialsOverlay.getState, () => {
    return { state: materialsOverlayStore.getState() };
  });
  registerHandler(ipcChannels.materialsOverlay.dispatch, ({ event }) => {
    materialsOverlayStore.dispatch(event);
    emitMaterialsOverlayState();
    return { ok: true };
  });
  registerHandler(ipcChannels.npcsOverlay.getState, () => {
    return { state: npcsOverlayStore.getState() };
  });
  registerHandler(ipcChannels.npcsOverlay.dispatch, ({ event }) => {
    npcsOverlayStore.dispatch(event);
    emitNpcsOverlayState();
    return { ok: true };
  });

  registerHandler(ipcChannels.project.list, async () => {
    const projects = await projectStore.listProjects();
    return {
      projects: projects.map((p) => ({
        id: p.id,
        name: p.name,
        updatedAt: p.updatedAt,
        fileName: p.fileName,
      })),
    };
  });
  registerHandler(ipcChannels.project.create, async ({ name }) => {
    const project = await projectStore.createProject(name);
    emitSessionState();
    warmNpcsEditorWindow();
    return { project };
  });
  registerHandler(ipcChannels.project.open, async ({ projectId }) => {
    const project = await projectStore.openProjectById(projectId);
    sceneViewStore.reset();
    sceneTokensSessionStore.reset();
    sceneNpcTokensSessionStore.reset();
    clearNpcMapSpawnDrag();
    scenePlayerTokensSessionStore.reset();
    emitSceneViewState();
    emitSceneTokensSessionState();
    emitSceneNpcTokensSessionState();
    emitScenePlayerTokensSessionState();
    emitSessionState();
    warmNpcsEditorWindow();
    return { project };
  });
  registerHandler(ipcChannels.project.close, async () => {
    closeNpcsEditorWindow();
    await projectStore.closeOpenProject();
    effectsStore.clear();
    materialsOverlayStore.clear();
    npcsOverlayStore.clear();
    sceneDarknessStore.resetSession();
    sceneTrapsStore.resetSession();
    sceneViewStore.reset();
    sceneTokensSessionStore.reset();
    sceneNpcTokensSessionStore.reset();
    clearNpcMapSpawnDrag();
    scenePlayerTokensSessionStore.reset();
    tokenGridSnapSessionStore.reset();
    emitEffectsState();
    emitMaterialsOverlayState();
    emitNpcsOverlayState();
    emitSceneDarknessState();
    emitSceneTrapsState();
    emitSceneViewState();
    emitSceneTokensSessionState();
    emitSceneNpcTokensSessionState();
    emitScenePlayerTokensSessionState();
    emitTokenGridSnapState();
    emitSessionState();
    return { ok: true };
  });
  registerHandler(ipcChannels.project.get, () => {
    return { project: projectStore.getOpenProject() };
  });
  registerHandler(ipcChannels.project.saveNow, async () => {
    await projectStore.saveNow();
    return { ok: true };
  });
  registerHandler(ipcChannels.project.setCurrentScene, async ({ sceneId }) => {
    await projectStore.updateProject((p) => ({ ...p, currentSceneId: sceneId, currentGraphNodeId: null }));
    effectsStore.clear();
    materialsOverlayStore.clear();
    npcsOverlayStore.clear();
    sceneViewStore.reset();
    scenePlayerTokensSessionStore.clearPlacements();
    // Token moves persist for the whole play session (reset only on project open/close).
    const project = projectStore.getOpenProject();
    if (project) {
      syncSceneDarknessForProject(project);
      syncSceneTrapsForProject(project);
    }
    emitEffectsState();
    emitMaterialsOverlayState();
    emitNpcsOverlayState();
    emitSceneDarknessState();
    emitSceneTrapsState();
    emitSceneViewState();
    emitSceneTokensSessionState();
    emitScenePlayerTokensSessionState();
    seedTokenPathPlaybackForProject(project);
    emitSessionState();
    return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
  });
  registerHandler(ipcChannels.project.setCurrentGraphNode, async ({ graphNodeId }) => {
    const open = projectStore.getOpenProject();
    if (!open) throw new Error('No open project');
    const gn = graphNodeId ? open.sceneGraphNodes.find((n) => n.id === graphNodeId) : null;
    await projectStore.updateProject((p) => ({
      ...p,
      currentGraphNodeId: graphNodeId,
      currentSceneId: gn ? gn.sceneId : null,
    }));
    effectsStore.clear();
    materialsOverlayStore.clear();
    npcsOverlayStore.clear();
    sceneViewStore.reset();
    scenePlayerTokensSessionStore.clearPlacements();
    // Token moves persist for the whole play session (reset only on project open/close).
    const project = projectStore.getOpenProject();
    if (project) {
      syncSceneDarknessForProject(project);
      syncSceneTrapsForProject(project);
    }
    emitEffectsState();
    emitMaterialsOverlayState();
    emitNpcsOverlayState();
    emitSceneDarknessState();
    emitSceneTrapsState();
    emitSceneViewState();
    emitSceneTokensSessionState();
    emitScenePlayerTokensSessionState();
    seedTokenPathPlaybackForProject(project);
    emitSessionState();
    const p = projectStore.getOpenProject();
    return {
      currentGraphNodeId: p?.currentGraphNodeId ?? null,
      currentSceneId: p?.currentSceneId ?? null,
    };
  });
  registerHandler(ipcChannels.project.updateScene, async ({ sceneId, patch }) => {
    const before = projectStore.getOpenProject()?.scenes[sceneId];
    const next = await projectStore.updateScene(sceneId, patch);
    const project = projectStore.getOpenProject();
    if (project?.currentSceneId === sceneId && patch.darkenScene !== undefined) {
      syncSceneDarknessForProject(project);
      emitSceneDarknessState();
    }
    if (project && project.currentSceneId === sceneId && patch.traps !== undefined) {
      syncSceneTrapsForProject(project);
      emitSceneTrapsState();
    }
    if (
      project?.currentSceneId === sceneId &&
      patch.previewRotationDeg !== undefined &&
      before
    ) {
      const steps = previewRotationStepsCw(
        asPreviewRotationDeg(before.previewRotationDeg),
        asPreviewRotationDeg(patch.previewRotationDeg),
      );
      if (steps !== 0) {
        sceneTokensSessionStore.rotateMapCwSteps(steps);
        sceneNpcTokensSessionStore.rotateMapCwSteps(steps);
        scenePlayerTokensSessionStore.rotateMapCwSteps(steps);
        emitSceneTokensSessionState();
        emitSceneNpcTokensSessionState();
        emitScenePlayerTokensSessionState();
      }
    }
    if (project && project.currentSceneId === sceneId && patch.previewRotationDeg !== undefined) {
      // Trap ids stay the same; runtime statuses remain valid after coordinate remap in project.
      syncSceneTrapsForProject(project);
      emitSceneTrapsState();
    }
    emitSessionState();
    return { scene: next };
  });
  registerHandler(ipcChannels.project.updateConnections, async ({ sceneId, connections }) => {
    const next = await projectStore.updateConnections(sceneId, connections);
    emitSessionState();
    return { scene: next };
  });
  registerHandler(ipcChannels.project.importMedia, async ({ sceneId, filePaths: pathsFromDrop }) => {
    let filePaths = pathsFromDrop ?? [];
    if (!pathsFromDrop) {
      const { canceled, filePaths: picked } = await dialog.showOpenDialog({
        properties: ['openFile', 'multiSelections'],
        filters: [
          {
            name: openDialogFilterLabel('videoAndAudio', app.getLocale()),
            extensions: ['mp4', 'webm', 'mov', 'mp3', 'wav', 'ogg', 'm4a', 'aac'],
          },
        ],
      });
      if (canceled || picked.length === 0) {
        const project = projectStore.getOpenProject();
        if (!project) throw new Error('No open project');
        return { project, imported: [] };
      }
      filePaths = picked;
    }
    const result = await projectStore.importMediaFiles(sceneId, filePaths);
    emitSessionState();
    return result;
  });
  registerHandler(ipcChannels.project.importCampaignAudio, async ({ filePaths: pathsFromDrop }) => {
    let filePaths = pathsFromDrop ?? [];
    if (!pathsFromDrop) {
      const { canceled, filePaths: picked } = await dialog.showOpenDialog({
        properties: ['openFile', 'multiSelections'],
        filters: [
          {
            name: openDialogFilterLabel('audio', app.getLocale()),
            extensions: ['mp3', 'wav', 'ogg', 'm4a', 'aac'],
          },
        ],
      });
      if (canceled || picked.length === 0) {
        const project = projectStore.getOpenProject();
        if (!project) throw new Error('No open project');
        return { canceled: true as const, project, imported: [] };
      }
      filePaths = picked;
    }
    const result = await projectStore.importCampaignAudioFiles(filePaths);
    emitSessionState();
    return { canceled: false as const, ...result };
  });
  registerHandler(ipcChannels.project.updateCampaignAudios, async ({ audios }) => {
    const project = await projectStore.setCampaignAudios(audios);
    emitSessionState();
    return { project };
  });
  registerHandler(
    ipcChannels.project.upsertMaterial,
    async ({ materialId, name, filePath: pathFromDrop }) => {
      let filePath = pathFromDrop;
      if (!filePath && !materialId) {
        const { canceled, filePaths } = await dialog.showOpenDialog({
          properties: ['openFile'],
          filters: [
            {
              name: openDialogFilterLabel('images', app.getLocale()),
              extensions: ['png', 'jpg', 'jpeg', 'webp'],
            },
          ],
        });
        if (canceled || filePaths.length === 0) {
          throw new Error('Material image is required');
        }
        filePath = filePaths[0];
      }
      const project = await projectStore.upsertMaterial(
        {
          ...(materialId ? { materialId } : {}),
          name,
          ...(filePath ? { filePath } : {}),
        },
        (p) => emitMaterialUpsertProgress(p),
      );
      syncMaterialsOverlayWithProject(project);
      emitMaterialsOverlayState();
      emitSessionState();
      return { project };
    },
  );
  registerHandler(ipcChannels.project.deleteMaterial, async ({ materialId }) => {
    const project = await projectStore.deleteMaterial(materialId);
    syncMaterialsOverlayWithProject(project);
    emitMaterialsOverlayState();
    emitSessionState();
    return { project };
  });
  registerHandler(ipcChannels.project.setMaterialsOrder, async ({ materialIds }) => {
    const project = await projectStore.setMaterialsOrder(materialIds);
    emitSessionState();
    return { project };
  });
  registerHandler(ipcChannels.project.setMaterialRotation, async ({ materialId, rotationDeg }) => {
    const project = await projectStore.setMaterialRotation(materialId, rotationDeg);
    emitSessionState();
    return { project };
  });
  registerHandler(ipcChannels.project.setMaterialLegend, async ({ materialId, legend }) => {
    const project = await projectStore.setMaterialLegend(materialId, legend);
    emitSessionState();
    return { project };
  });
  registerHandler(ipcChannels.project.pickMaterialImage, async () => {
    const { canceled, filePaths } = await dialog.showOpenDialog({
      properties: ['openFile'],
      filters: [
        {
          name: openDialogFilterLabel('images', app.getLocale()),
          extensions: ['png', 'jpg', 'jpeg', 'webp'],
        },
      ],
    });
    if (canceled || filePaths.length === 0) return { canceled: true as const };
    const filePath = filePaths[0]!;
    const buf = await fs.readFile(filePath);
    const ext = path.extname(filePath).toLowerCase();
    const mime = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
    const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
    return { canceled: false as const, filePath, previewDataUrl };
  });
  registerHandler(
    ipcChannels.project.upsertNpc,
    async ({ npcId, name, description, filePath: pathFromDrop, groupId, ringColor, disposition, imageOffset }) => {
      let filePath = pathFromDrop;
      if (!filePath && !npcId) {
        const { canceled, filePaths } = await dialog.showOpenDialog({
          properties: ['openFile'],
          filters: [
            {
              name: openDialogFilterLabel('images', app.getLocale()),
              extensions: ['png', 'jpg', 'jpeg', 'webp'],
            },
          ],
        });
        if (canceled || filePaths.length === 0) {
          throw new Error('NPC avatar is required');
        }
        filePath = filePaths[0];
      }
      const project = await projectStore.upsertNpc(
        {
          ...(npcId ? { npcId } : {}),
          name,
          ...(typeof description === 'string' ? { description } : {}),
          ...(filePath ? { filePath } : {}),
          ...(groupId !== undefined ? { groupId } : {}),
          ...(ringColor !== undefined ? { ringColor } : {}),
          ...(disposition !== undefined ? { disposition } : {}),
          ...(imageOffset !== undefined ? { imageOffset } : {}),
        },
        (p) => emitNpcUpsertProgress(p),
      );
      syncNpcsOverlayWithProject(project);
      emitNpcsOverlayState();
      emitSessionState();
      return { project };
    },
  );
  registerHandler(
    ipcChannels.project.updateNpcFields,
    async ({ npcId, name, description, groupId, ringColor, disposition, imageOffset, imageScale }) => {
      const project = await projectStore.updateNpcFields(npcId, {
        ...(typeof name === 'string' ? { name } : {}),
        ...(typeof description === 'string' ? { description } : {}),
        ...(groupId !== undefined ? { groupId } : {}),
        ...(ringColor !== undefined ? { ringColor } : {}),
        ...(disposition !== undefined ? { disposition } : {}),
        ...(imageOffset !== undefined ? { imageOffset } : {}),
        ...(imageScale !== undefined ? { imageScale } : {}),
      });
      emitSessionState();
      return { project };
    },
  );
  registerHandler(ipcChannels.project.updateNpcPosition, async ({ npcId, x, y }) => {
    // Layout-only: Presentation/Control не читают npc.x/y; UI коммитит локально из ответа invoke.
    const project = await projectStore.updateNpcPosition(npcId, x, y);
    return { project };
  });
  registerHandler(ipcChannels.project.deleteNpc, async ({ npcId }) => {
    const project = await projectStore.deleteNpc(npcId);
    syncNpcsOverlayWithProject(project);
    emitNpcsOverlayState();
    emitSessionState();
    return { project };
  });
  registerHandler(ipcChannels.project.setNpcsOrder, async ({ npcIds }) => {
    const project = await projectStore.setNpcsOrder(npcIds);
    emitSessionState();
    return { project };
  });
  registerHandler(ipcChannels.project.pickNpcAvatar, async () => {
    const { canceled, filePaths } = await dialog.showOpenDialog({
      properties: ['openFile'],
      filters: [
        {
          name: openDialogFilterLabel('images', app.getLocale()),
          extensions: ['png', 'jpg', 'jpeg', 'webp'],
        },
      ],
    });
    if (canceled || filePaths.length === 0) return { canceled: true as const };
    const filePath = filePaths[0]!;
    const buf = await fs.readFile(filePath);
    const ext = path.extname(filePath).toLowerCase();
    const mime = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
    const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
    return { canceled: false as const, filePath, previewDataUrl };
  });
  registerHandler(
    ipcChannels.project.upsertNpcRelation,
    async ({ relationId, sourceNpcId, targetNpcId, label }) => {
      const project = await projectStore.upsertNpcRelation({
        ...(relationId ? { relationId } : {}),
        sourceNpcId,
        targetNpcId,
        label,
      });
      emitSessionState();
      return { project };
    },
  );
  registerHandler(ipcChannels.project.deleteNpcRelation, async ({ relationId }) => {
    const project = await projectStore.deleteNpcRelation(relationId);
    emitSessionState();
    return { project };
  });
  registerHandler(ipcChannels.project.upsertNpcGroup, async ({ groupId, name, color, parentId }) => {
    const project = await projectStore.upsertNpcGroup({
      ...(groupId ? { groupId } : {}),
      name,
      ...(typeof color === 'string' ? { color } : {}),
      ...(parentId !== undefined ? { parentId } : {}),
    });
    emitSessionState();
    return { project };
  });
  registerHandler(ipcChannels.project.deleteNpcGroup, async ({ groupId }) => {
    const project = await projectStore.deleteNpcGroup(groupId);
    syncNpcsOverlayWithProject(project);
    emitNpcsOverlayState();
    emitSessionState();
    return { project };
  });
  registerHandler(ipcChannels.project.setNpcGroupsOrder, async ({ groupIds }) => {
    const project = await projectStore.setNpcGroupsOrder(groupIds);
    emitSessionState();
    return { project };
  });
  registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId, filePath: pathFromDrop }) => {
    let filePath = pathFromDrop;
    if (!filePath) {
      const { canceled, filePaths } = await dialog.showOpenDialog({
        properties: ['openFile'],
        filters: [
          {
            name: openDialogFilterLabel('imagesAndVideo', app.getLocale()),
            extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'mp4', 'webm', 'mov'],
          },
        ],
      });
      if (canceled || !filePaths[0]) {
        const project = projectStore.getOpenProject();
        if (!project) throw new Error('No open project');
        return { project, assetId: null, background: false };
      }
      filePath = filePaths[0];
    }
    const result = await projectStore.importScenePreviewMedia(sceneId, filePath);
    emitSessionState();
    emitScenePreviewImportProgress({
      sceneId,
      assetId: result.assetId,
      phase: 'queued',
      project: result.project,
    });
    void (async () => {
      try {
        emitScenePreviewImportProgress({ sceneId, assetId: result.assetId, phase: 'optimizing' });
        const finalized = await projectStore.finalizeScenePreviewImport(sceneId, result.assetId);
        if (finalized.changed) {
          emitSessionState();
        }
        // Always clear optimizing UI — including abort on project close (changed: false).
        emitScenePreviewImportProgress({
          sceneId,
          assetId: result.assetId,
          phase: 'done',
          ...(finalized.changed && finalized.project ? { project: finalized.project } : {}),
        });
      } catch (e) {
        emitScenePreviewImportProgress({
          sceneId,
          assetId: result.assetId,
          phase: 'error',
          message: e instanceof Error ? e.message : String(e),
        });
      }
    })();
    return result;
  });
  registerHandler(ipcChannels.project.clearScenePreview, async ({ sceneId }) => {
    const project = await projectStore.clearScenePreview(sceneId);
    emitSessionState();
    return { project };
  });
  registerHandler(ipcChannels.project.updateSceneGraphNodePosition, async ({ nodeId, x, y }) => {
    // Layout-only: граф сцен живёт в editor (optimistic + invoke); session broadcast не нужен.
    const project = await projectStore.updateSceneGraphNodePosition(nodeId, x, y);
    return { project };
  });
  registerHandler(ipcChannels.project.addSceneGraphNode, async ({ sceneId, x, y }) => {
    const project = await projectStore.addSceneGraphNode(sceneId, x, y);
    emitSessionState();
    return { project };
  });
  registerHandler(ipcChannels.project.removeSceneGraphNode, async ({ nodeId }) => {
    const project = await projectStore.removeSceneGraphNode(nodeId);
    emitSessionState();
    return { project };
  });
  registerHandler(ipcChannels.project.addSceneGraphEdge, async ({ sourceGraphNodeId, targetGraphNodeId }) => {
    const project = await projectStore.addSceneGraphEdge(sourceGraphNodeId, targetGraphNodeId);
    emitSessionState();
    return { project };
  });
  registerHandler(ipcChannels.project.removeSceneGraphEdge, async ({ edgeId }) => {
    const project = await projectStore.removeSceneGraphEdge(edgeId);
    emitSessionState();
    return { project };
  });
  registerHandler(ipcChannels.project.setSceneGraphNodeStart, async ({ graphNodeId }) => {
    const project = await projectStore.setSceneGraphNodeStart(graphNodeId);
    emitSessionState();
    return { project };
  });
  registerHandler(ipcChannels.project.setSceneGraphNodeSideStoryStart, async ({ graphNodeId }) => {
    const project = await projectStore.setSceneGraphNodeSideStoryStart(graphNodeId);
    emitSessionState();
    return { project };
  });
  registerHandler(ipcChannels.project.updateSideStoryLineTitle, async ({ graphNodeId, title }) => {
    const project = await projectStore.updateSideStoryLineTitle(graphNodeId, title);
    emitSessionState();
    return { project };
  });
  registerHandler(ipcChannels.project.deleteScene, async ({ sceneId }) => {
    const project = await projectStore.deleteScene(sceneId);
    emitSessionState();
    return { project };
  });
  registerHandler(ipcChannels.project.setSceneListOrder, async ({ sceneListOrder }) => {
    const project = await projectStore.setSceneListOrder(sceneListOrder);
    emitSessionState();
    return { project };
  });
  registerHandler(ipcChannels.project.rename, async ({ name, fileBaseName }) => {
    const project = await projectStore.renameOpenProject(name, fileBaseName);
    emitSessionState();
    return { project };
  });
  registerHandler(ipcChannels.project.importZip, async () => {
    const { canceled, filePaths } = await dialog.showOpenDialog({
      properties: ['openFile'],
      filters: [PROJECT_ZIP_OPEN_DIALOG_FILTER],
    });
    if (canceled || !filePaths[0]) {
      return { canceled: true as const };
    }
    const srcPath = filePaths[0];
    emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' });
    const project = await projectStore.importProjectFromExternalZip(srcPath, (p) => {
      emitZipProgress({
        kind: 'import',
        stage: p.stage,
        percent: p.percent,
        ...(p.detail ? { detail: p.detail } : null),
      });
    });
    emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
    emitSessionState();
    return { canceled: false as const, project };
  });
  registerHandler(ipcChannels.project.getProjectStorylines, async ({ projectId, labels }) => {
    return projectStore.getProjectStorylines(projectId, labels);
  });
  registerHandler(ipcChannels.project.peekImportZip, async ({ labels, targetHasMainStart }) => {
    const { canceled, filePaths } = await dialog.showOpenDialog({
      properties: ['openFile'],
      filters: [PROJECT_ZIP_OPEN_DIALOG_FILTER],
    });
    if (canceled || !filePaths[0]) {
      return { canceled: true as const };
    }
    const filePath = filePaths[0];
    const peek = await projectStore.peekImportFromZipPath(filePath, labels, targetHasMainStart);
    return { canceled: false as const, ...peek };
  });
  registerHandler(ipcChannels.project.pickImportZipFile, async () => {
    const { canceled, filePaths } = await dialog.showOpenDialog({
      properties: ['openFile'],
      filters: [PROJECT_ZIP_OPEN_DIALOG_FILTER],
    });
    if (canceled || !filePaths[0]) {
      return { canceled: true as const };
    }
    return { canceled: false as const, filePath: filePaths[0] };
  });
  registerHandler(ipcChannels.project.peekImportZipPath, async ({ filePath, labels, targetHasMainStart }) => {
    return projectStore.peekImportFromZipPath(filePath, labels, targetHasMainStart);
  });
  registerHandler(
    ipcChannels.project.peekImportFromProject,
    async ({ sourceProjectId, labels, targetHasMainStart }) => {
      return projectStore.peekImportFromProjectId(sourceProjectId, labels, targetHasMainStart);
    },
  );
  registerHandler(
    ipcChannels.project.mergeImportZip,
    async ({ filePath, storylineSelections, sceneResolutions, npcResolutions }) => {
      emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт линий…' });
      const { project, report } = await projectStore.mergeStorylinesFromExternalZip(
        filePath,
        storylineSelections,
        sceneResolutions,
        (p) => {
          emitZipProgress({
            kind: 'import',
            stage: p.stage,
            percent: p.percent,
            ...(p.detail ? { detail: p.detail } : null),
          });
        },
        npcResolutions,
        async (cacheDir) => {
          const remap = await tokensStore!.importFromExportDir(cacheDir);
          emitTokensState();
          return remap;
        },
      );
      emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
      emitSessionState();
      return { project, report };
    },
  );
  registerHandler(
    ipcChannels.project.mergeImportFromProject,
    async ({ sourceProjectId, storylineSelections, sceneResolutions, npcResolutions }) => {
      emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт линий…' });
      const { project, report } = await projectStore.mergeStorylinesFromProjectId(
        sourceProjectId,
        storylineSelections,
        sceneResolutions,
        (p) => {
          emitZipProgress({
            kind: 'import',
            stage: p.stage,
            percent: p.percent,
            ...(p.detail ? { detail: p.detail } : null),
          });
        },
        npcResolutions,
        async (cacheDir) => {
          // Импорт из другого локального проекта: app-tokens в zip нет — только из внешнего архива.
          // Если в sourceCache нет app-tokens, remap пустой.
          const remap = await tokensStore!.importFromExportDir(cacheDir);
          emitTokensState();
          return remap;
        },
      );
      emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
      emitSessionState();
      return { project, report };
    },
  );
  registerHandler(ipcChannels.project.importZipFromPath, async ({ filePath }) => {
    emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' });
    const project = await projectStore.importProjectFromExternalZip(filePath, (p) => {
      emitZipProgress({
        kind: 'import',
        stage: p.stage,
        percent: p.percent,
        ...(p.detail ? { detail: p.detail } : null),
      });
    });
    emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
    emitSessionState();
    return { project };
  });
  registerHandler(ipcChannels.project.pickFoundrySource, async ({ mode }) => {
    if (mode === 'folder') {
      const { canceled, filePaths } = await dialog.showOpenDialog({
        properties: ['openDirectory'],
        title: 'Папка мира или модуля Foundry VTT',
      });
      if (canceled || !filePaths[0]) return { canceled: true as const };
      return { canceled: false as const, sourcePath: filePaths[0] };
    }
    const { canceled, filePaths } = await dialog.showOpenDialog({
      properties: ['openFile'],
      title: 'Архив мира или модуля Foundry VTT',
      filters: [
        { name: 'Foundry package', extensions: ['zip', 'fvtt'] },
        { name: 'All files', extensions: ['*'] },
      ],
    });
    if (canceled || !filePaths[0]) return { canceled: true as const };
    return { canceled: false as const, sourcePath: filePaths[0] };
  });
  registerHandler(ipcChannels.project.importFoundry, async ({ sourcePath }) => {
    emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт Foundry…' });
    try {
      const project = await projectStore.importProjectFromFoundry(sourcePath, (p) => {
        emitZipProgress({
          kind: 'import',
          stage:
            p.stage === 'unzip' ? 'unzip' : p.stage === 'zip' ? 'zip' : p.stage === 'done' ? 'done' : 'copy',
          percent: p.percent,
          ...(p.detail ? { detail: p.detail } : null),
        });
      });
      emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
      emitSessionState();
      return { project };
    } catch (e) {
      emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Ошибка' });
      throw e;
    }
  });
  registerHandler(
    ipcChannels.project.exportZip,
    async ({ projectId, storylineSelections, npcIds, labels }) => {
      const list = await projectStore.listProjects();
      const entry = list.find((p) => p.id === projectId);
      if (!entry) {
        throw new Error('Проект не найден');
      }
      const defaultName = isProjectZipFileName(entry.fileName)
        ? entry.fileName.toLowerCase().endsWith('.ttrpg.zip')
          ? entry.fileName
          : projectZipFileNameFromBase(stripProjectZipExtension(entry.fileName))
        : projectZipFileNameFromBase(stripProjectZipExtension(entry.fileName));
      const { canceled, filePath } = await dialog.showSaveDialog({
        defaultPath: defaultName,
        filters: [PROJECT_ZIP_SAVE_DIALOG_FILTER],
      });
      if (canceled || !filePath) {
        return { canceled: true as const };
      }
      const dest = normalizeSaveProjectZipPath(filePath);
      try {
        emitZipProgress({ kind: 'export', stage: 'zip', percent: 0, detail: 'Экспорт…' });
        await projectStore.exportStorylinesZipToPath(
          projectId,
          storylineSelections,
          npcIds ?? [],
          dest,
          labels,
          (p) => {
            emitZipProgress({
              kind: 'export',
              stage: p.stage,
              percent: p.percent,
              ...(p.detail ? { detail: p.detail } : null),
            });
          },
          async (tokenIds, exportRoot) => {
            await tokensStore!.packForExport(tokenIds, exportRoot);
          },
        );
        emitZipProgress({ kind: 'export', stage: 'done', percent: 100, detail: 'Готово' });
        return { canceled: false as const };
      } catch (err) {
        const detail = err instanceof Error ? err.message : 'Ошибка экспорта';
        emitZipProgress({ kind: 'export', stage: 'error', percent: 0, detail });
        throw err;
      }
    },
  );
  registerHandler(ipcChannels.project.deleteProject, async ({ projectId }) => {
    await projectStore.deleteProjectById(projectId);
    emitSessionState();
    return { ok: true };
  });
  registerHandler(ipcChannels.project.assetFileUrl, ({ assetId }) => ({
    url: projectStore.getAssetFileUrl(assetId),
  }));

  registerHandler(ipcChannels.effects.getState, () => {
    return { state: effectsStore.getState() };
  });
  registerHandler(ipcChannels.effects.dispatch, ({ event }) => {
    effectsStore.dispatch(event);
    emitEffectsState();
    return { ok: true };
  });

  registerHandler(ipcChannels.sceneDarkness.getState, () => {
    return { state: sceneDarknessStore.getState() };
  });
  registerHandler(ipcChannels.sceneDarkness.dispatch, ({ event }) => {
    sceneDarknessStore.dispatch(event);
    emitSceneDarknessState();
    return { ok: true };
  });

  registerHandler(ipcChannels.sceneTraps.getState, () => {
    return { state: sceneTrapsStore.getState() };
  });
  registerHandler(ipcChannels.sceneTraps.dispatch, ({ event }) => {
    sceneTrapsStore.dispatch(event);
    emitSceneTrapsState();
    return { ok: true };
  });

  registerHandler(ipcChannels.sceneView.getState, () => {
    return { state: sceneViewStore.getState() };
  });
  registerHandler(ipcChannels.sceneView.dispatch, ({ event }) => {
    sceneViewStore.dispatch(event);
    emitSceneViewState();
    return { ok: true };
  });

  registerHandler(ipcChannels.tokens.list, async () => {
    await tokensStore!.ensureLoaded();
    return { tokens: tokensStore!.list() };
  });
  registerHandler(ipcChannels.tokens.upsert, async ({ id, name, filePath }) => {
    const token = await tokensStore!.upsert({
      name,
      ...(id !== undefined ? { id } : {}),
      ...(filePath !== undefined ? { filePath } : {}),
    });
    emitTokensState();
    return { token };
  });
  registerHandler(ipcChannels.tokens.delete, async ({ id }) => {
    await tokensStore!.delete(id);
    // Убрать placements с текущей сцены.
    const project = projectStore.getOpenProject();
    const sceneId = project?.currentSceneId;
    if (sceneId && project) {
      const scene = project.scenes[sceneId];
      if (scene?.tokens?.some((t) => t.tokenId === id)) {
        await projectStore.updateScene(sceneId, {
          tokens: scene.tokens.filter((t) => t.tokenId !== id),
        });
        emitSessionState();
      }
    }
    emitTokensState();
    return { ok: true };
  });
  registerHandler(ipcChannels.tokens.pickImage, async () => {
    const { canceled, filePaths } = await dialog.showOpenDialog({
      properties: ['openFile'],
      filters: [
        {
          name: openDialogFilterLabel('images', app.getLocale()),
          extensions: ['png', 'jpg', 'jpeg', 'webp'],
        },
      ],
    });
    if (canceled || filePaths.length === 0) return { canceled: true as const };
    const filePath = filePaths[0]!;
    const buf = await fs.readFile(filePath);
    const ext = path.extname(filePath).toLowerCase();
    const mime = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
    const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
    return { canceled: false as const, filePath, previewDataUrl };
  });
  registerHandler(ipcChannels.tokens.imageUrl, ({ id }) => {
    return { url: tokensStore!.getImageUrl(id) };
  });
  registerHandler(ipcChannels.sceneTokensSession.getState, () => {
    return { state: sceneTokensSessionStore.getState() };
  });
  registerHandler(ipcChannels.sceneTokensSession.dispatch, ({ event }) => {
    sceneTokensSessionStore.dispatch(event);
    emitSceneTokensSessionState();
    return { ok: true };
  });
  registerHandler(ipcChannels.tokenPathSession.getState, () => {
    return { state: tokenPathSessionStore.getState() };
  });
  registerHandler(ipcChannels.tokenPathSession.dispatch, ({ event }) => {
    tokenPathSessionStore.dispatch(event);
    emitTokenPathSessionState();
    return { ok: true };
  });

  registerHandler(ipcChannels.tokenGridSnap.getState, () => tokenGridSnapSessionStore.getState());
  registerHandler(ipcChannels.tokenGridSnap.setEnabled, ({ enabled }) => {
    const next = tokenGridSnapSessionStore.setEnabled(enabled);
    emitTokenGridSnapState();
    return next;
  });

  registerHandler(ipcChannels.players.list, async () => {
    if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
      return { players: [], teams: [] };
    }
    await playersStore.ensureLoaded();
    return { players: playersStore.listPlayers(), teams: playersStore.listTeams() };
  });
  registerHandler(
    ipcChannels.players.upsert,
    async ({ id, name, filePath, teamId, ringColor, imageOffset, imageScale }) => {
      if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
        throw new Error('Players library is disabled in this build');
      }
      const player = await playersStore.upsert(
        {
          name,
          ...(id !== undefined ? { id } : {}),
          ...(filePath !== undefined ? { filePath } : {}),
          ...(teamId !== undefined ? { teamId } : {}),
          ...(ringColor !== undefined ? { ringColor } : {}),
          ...(imageOffset !== undefined ? { imageOffset } : {}),
          ...(imageScale !== undefined ? { imageScale } : {}),
        },
        (p) => emitPlayerUpsertProgress(p),
      );
      emitPlayersState();
      return { player };
    },
  );
  registerHandler(ipcChannels.players.delete, async ({ id }) => {
    if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
      return { ok: true };
    }
    await playersStore.delete(id);
    emitPlayersState();
    return { ok: true };
  });
  registerHandler(ipcChannels.players.setOrder, async ({ playerIds }) => {
    if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
      return { players: [] };
    }
    const players = await playersStore.setPlayersOrder(playerIds);
    emitPlayersState();
    return { players };
  });
  registerHandler(ipcChannels.players.upsertTeam, async ({ id, name, color }) => {
    if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
      throw new Error('Players library is disabled in this build');
    }
    const team = await playersStore.upsertTeam({
      name,
      ...(id !== undefined ? { id } : {}),
      ...(color !== undefined ? { color } : {}),
    });
    emitPlayersState();
    return { team };
  });
  registerHandler(ipcChannels.players.deleteTeam, async ({ id }) => {
    if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
      return { ok: true };
    }
    await playersStore.deleteTeam(id);
    emitPlayersState();
    return { ok: true };
  });
  registerHandler(ipcChannels.players.setTeamsOrder, async ({ teamIds }) => {
    if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
      return { teams: [] };
    }
    const teams = await playersStore.setTeamsOrder(teamIds);
    emitPlayersState();
    return { teams };
  });
  registerHandler(ipcChannels.players.assignTeam, async ({ playerId, teamId }) => {
    if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
      throw new Error('Players library is disabled in this build');
    }
    const player = await playersStore.assignPlayerTeam(playerId, teamId);
    emitPlayersState();
    return { player };
  });
  registerHandler(ipcChannels.players.pickImage, async () => {
    if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
      return { canceled: true as const };
    }
    const { canceled, filePaths } = await dialog.showOpenDialog({
      properties: ['openFile'],
      filters: [
        {
          name: openDialogFilterLabel('images', app.getLocale()),
          extensions: ['png', 'jpg', 'jpeg', 'webp'],
        },
      ],
    });
    if (canceled || filePaths.length === 0) return { canceled: true as const };
    const filePath = filePaths[0]!;
    const buf = await fs.readFile(filePath);
    const ext = path.extname(filePath).toLowerCase();
    const mime = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
    const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
    return { canceled: false as const, filePath, previewDataUrl };
  });
  registerHandler(ipcChannels.players.imageUrl, ({ id }) => {
    if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
      return { url: null };
    }
    return { url: playersStore.getImageUrl(id) };
  });
  registerHandler(ipcChannels.sceneNpcTokensSession.getState, () => {
    return { state: sceneNpcTokensSessionStore.getState() };
  });
  registerHandler(ipcChannels.sceneNpcTokensSession.dispatch, ({ event }) => {
    sceneNpcTokensSessionStore.dispatch(event);
    emitSceneNpcTokensSessionState();
    return { ok: true };
  });
  registerHandler(ipcChannels.npcMapSpawn.getState, () => {
    return {
      state: {
        dragging: Boolean(npcMapSpawnDragNpcId),
        npcId: npcMapSpawnDragNpcId,
        hoverNx: npcMapSpawnHover?.nx ?? null,
        hoverNy: npcMapSpawnHover?.ny ?? null,
      },
    };
  });
  registerHandler(ipcChannels.npcMapSpawn.beginDrag, ({ npcId }) => {
    npcMapSpawnDragNpcId = String(npcId ?? '') || null;
    npcMapSpawnHover = null;
    emitNpcMapSpawnDragState();
    return { ok: true };
  });
  registerHandler(ipcChannels.npcMapSpawn.setHover, ({ nx, ny }) => {
    if (!npcMapSpawnDragNpcId) return { ok: true };
    if (nx == null || ny == null || !Number.isFinite(nx) || !Number.isFinite(ny)) {
      if (npcMapSpawnHover === null) return { ok: true };
      npcMapSpawnHover = null;
      emitNpcMapSpawnDragState();
      return { ok: true };
    }
    const next = { nx: Math.max(0, Math.min(1, nx)), ny: Math.max(0, Math.min(1, ny)) };
    if (npcMapSpawnHover && npcMapSpawnHover.nx === next.nx && npcMapSpawnHover.ny === next.ny) {
      return { ok: true };
    }
    npcMapSpawnHover = next;
    emitNpcMapSpawnDragState();
    return { ok: true };
  });
  registerHandler(ipcChannels.npcMapSpawn.cancel, () => {
    clearNpcMapSpawnDrag();
    return { ok: true };
  });
  registerHandler(ipcChannels.npcMapSpawn.commit, () => {
    const npcId = npcMapSpawnDragNpcId;
    const hover = npcMapSpawnHover;
    clearNpcMapSpawnDrag();
    if (!npcId || !hover) return { spawned: false };
    const project = projectStore.getOpenProject();
    const sceneId = project?.currentSceneId;
    const npc = project?.npcs.find((item) => item.id === npcId);
    if (!sceneId || !npc) return { spawned: false };
    sceneNpcTokensSessionStore.dispatch({
      kind: 'spawn',
      npcId: npc.id,
      sceneId,
      nx: hover.nx,
      ny: hover.ny,
      disposition: npc.disposition,
    });
    emitSceneNpcTokensSessionState();
    return { spawned: true };
  });
  registerHandler(ipcChannels.scenePlayerTokensSession.getState, () => {
    return { state: scenePlayerTokensSessionStore.getState() };
  });
  registerHandler(ipcChannels.scenePlayerTokensSession.dispatch, ({ event }) => {
    scenePlayerTokensSessionStore.dispatch(event);
    emitScenePlayerTokensSessionState();
    return { ok: true };
  });

  registerHandler(ipcChannels.video.getState, () => {
    return { state: videoStore.getState() };
  });
  registerHandler(ipcChannels.video.dispatch, ({ event }) => {
    videoStore.dispatch(event);
    emitVideoState();
    return { ok: true };
  });

  installAutoUpdater(licenseService, registerHandler);
  installIpcRouter();
  applyDockIconIfNeeded();
  await runStartupAfterHandlers(licenseService);

  app.on('activate', () => {
    focusEditorWindow();
  });
}

if (gotTheLock) {
  void main();
}

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