import crypto from 'node:crypto';
import fssync from 'node:fs';
import fs from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';

import { ZipFile } from 'yazl';

import { isSceneGraphEdgeRejected } from '../../shared/graph/sceneGraphEdgeRules';
import { canSetSideStoryStart, getSideStoryComponentNodeIds } from '../../shared/graph/sceneGraphLineage';
import {
  prependSceneListOrder,
  reconcileSceneListOrder,
  removeFromSceneListOrder,
} from '../../shared/graph/sceneListOrder';
import {
  buildPartialExportProject,
  collectTokenIdsFromProject,
  computeGraphImportOffsetX,
  listExportableStorylines,
  listImportableStorylines,
  mergeStorylinesIntoProject,
  newExportBundleProjectId,
  remapProjectSceneTokenIds,
  type NpcImportResolution,
  type SceneImportResolution,
  type StorylineImportMergeReport,
  type StorylineLabels,
  type StorylineListItem,
  type StorylineSelection,
} from '../../shared/graph/storylineExportImport';
import type { ScenePatch } from '../../shared/ipc/contracts';
import {
  isProjectZipFileName,
  projectZipFileNameFromBase,
  stripProjectZipExtension,
} from '../../shared/project/projectZipExtension';
import type {
  MaterialLegend,
  MediaAsset,
  MediaAssetType,
  Project,
  ProjectId,
  ProjectNpc,
  ProjectNpcGroup,
  ProjectNpcRelation,
  Scene,
  SceneGraphEdge,
  SceneGraphNode,
  SceneId,
  SceneTrap,
} from '../../shared/types';
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
import { normalizeMaterialLegend } from '../../shared/types/materialLegend';
import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens';
import {
  clampPlayerImageOffset,
  clampPlayerImageScale,
  DEFAULT_PLAYER_RING_COLOR,
  normalizeSceneNpcToken,
  type SceneNpcToken,
} from '../../shared/types/appPlayers';
import {
  DEFAULT_NPC_DISPOSITION,
  normalizeNpcDisposition,
  npcDispositionRingColor,
  type NpcDisposition,
} from '../../shared/types/npcDisposition';
import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid';
import {
  asPreviewRotationDeg,
  previewRotationStepsCw,
  rotateMapMarkersByCwSteps,
  rotateSceneTokensByCwSteps,
} from '../../shared/types/scenePreviewRotation';
import { normalizeSceneTrap } from '../../shared/types/sceneTraps';
import type {
  AssetId,
  GraphNodeId,
  MaterialId,
  NpcGroupId,
  NpcId,
  NpcRelationId,
} from '../../shared/types/ids';
import {
  asAssetId,
  asGraphNodeId,
  asMaterialId,
  asNpcGroupId,
  asNpcId,
  asNpcRelationId,
  asProjectId,
} from '../../shared/types/ids';
import {
  DEFAULT_NPC_GROUP_COLOR,
  normalizeHexColor,
  normalizeNpcGroups,
  resolveNpcGroupId,
  wouldCreateGroupCycle,
} from '../../shared/npcs/npcGroups';
import {
  buildProjectFromFoundryDocuments,
  loadFoundryDocumentsForImport,
  type FoundryImportProgress,
} from '../foundry/foundryImport';
import { getAppSemanticVersion } from '../versionInfo';

import { reconcileAssetFiles } from './assetPrune';
import { recoverOrphanProjectZipTmpInRoot, replaceFileAtomic } from './atomicReplace';
import { rmWithRetries } from './fsRetry';
import { optimizeImageBufferVisuallyLossless } from './optimizeImageImport.lib.mjs';
import { getLegacyProjectsRootDirs, getProjectsCacheRootDir, getProjectsRootDir } from './paths';
import { generateScenePreviewThumbnailBytes } from './scenePreviewThumbnail';
import { readProjectJsonFromZip, unzipToDir } from './yauzlProjectZip';

type ProjectIndexEntry = {
  id: ProjectId;
  name: string;
  updatedAt: string;
  fileName: string;
};

type OpenProject = {
  id: ProjectId;
  zipPath: string;
  cacheDir: string;
  projectPath: string;
  project: Project;
};

export class ZipProjectStore {
  private openProject: OpenProject | null = null;
  private saveQueued = false;
  private saving = false;
  /** Bumps on create/open so in-flight disk writes cannot commit after project switch. */
  private projectSession = 0;
  /** Serializes project.json writes — parallel renames caused ENOENT on Windows. */
  private projectWriteChain: Promise<void> = Promise.resolve();
  /**
   * Serializes project mutations (mutator + reconcileAssetFiles).
   * Parallel updateProject during batch scene-preview import caused lost updates and
   * reconcileAssetFiles deleting preview files still referenced after a stale write (ENOENT).
   */
  private projectUpdateChain: Promise<unknown> = Promise.resolve();
  /** Serializes background preview optimize/thumb — avoids parallel sharp + orphan races. */
  private previewFinalizeChain: Promise<unknown> = Promise.resolve();
  /** Serializes zip pack operations — parallel yazl/yauzl caused «unexpected number of bytes». */
  private packChain: Promise<void> = Promise.resolve();
  /** Serializes open/close/unzip — concurrent IPC caused ghost open projects and deadlocks. */
  private projectSwitchChain: Promise<void> = Promise.resolve();
  private saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;

  private enqueueProjectUpdate<T>(fn: () => Promise<T>): Promise<T> {
    const task = this.projectUpdateChain.then(() => fn());
    this.projectUpdateChain = task.then(
      () => undefined,
      () => undefined,
    );
    return task;
  }

  private enqueuePreviewFinalize<T>(fn: () => Promise<T>): Promise<T> {
    const task = this.previewFinalizeChain.then(() => fn());
    this.previewFinalizeChain = task.then(
      () => undefined,
      () => undefined,
    );
    return task;
  }

  /** Wait until preview optimize/thumb and project mutators finish (before pack/close). */
  private async drainProjectMutations(): Promise<void> {
    await this.previewFinalizeChain;
    await this.projectUpdateChain;
  }

  private enqueuePack(cacheDir: string, zipPath: string): Promise<void> {
    const next = this.packChain.then(async () => {
      await this.packZipFromCache(cacheDir, zipPath);
    });
    this.packChain = next.catch(() => undefined);
    return next;
  }

  private enqueueProjectSwitch<T>(fn: () => Promise<T>): Promise<T> {
    const task = this.projectSwitchChain.then(() => fn());
    this.projectSwitchChain = task.then(
      () => undefined,
      () => undefined,
    );
    return task;
  }

  private enqueueOpenProject(projectId: ProjectId, onUnzipPercent?: (pct: number) => void): Promise<Project> {
    return this.enqueueProjectSwitch(() => this.openProjectByIdInner(projectId, onUnzipPercent));
  }

  /** Waits for debounced save, in-flight pack, and pending project.json writes before reading zip. */
  private async drainSavePipeline(): Promise<void> {
    if (this.saveDebounceTimer) {
      clearTimeout(this.saveDebounceTimer);
      this.saveDebounceTimer = null;
    }
    // Finish preview finalizers before flush/pack — otherwise yazl ENOENT on deleted assets.
    await this.drainProjectMutations();
    if (this.saveQueued) {
      this.saveQueued = false;
      await this.flushSave();
    }
    while (this.saving) {
      await new Promise((r) => setTimeout(r, 10));
    }
    await this.packChain;
    await this.projectWriteChain;
    await this.drainProjectMutations();
  }

  async ensureRoots(): Promise<void> {
    await fs.mkdir(getProjectsRootDir(), { recursive: true });
    await fs.mkdir(getProjectsCacheRootDir(), { recursive: true });
    await this.migrateLegacyProjectZipsIfNeeded();
    await recoverOrphanProjectZipTmpInRoot(getProjectsRootDir());
  }

  /** Копирует архивы проектов из каталогов с «чужим» app name, если в текущем каталоге такого файла ещё нет. */
  private async migrateLegacyProjectZipsIfNeeded(): Promise<void> {
    const dest = getProjectsRootDir();
    let destNames: string[];
    try {
      destNames = await fs.readdir(dest);
    } catch {
      return;
    }
    const destZips = new Set(destNames.filter((n) => isProjectZipFileName(n)));
    for (const legacyRoot of getLegacyProjectsRootDirs()) {
      let legacyNames: string[];
      try {
        legacyNames = await fs.readdir(legacyRoot);
      } catch {
        continue;
      }
      for (const name of legacyNames) {
        if (!isProjectZipFileName(name)) continue;
        if (destZips.has(name)) continue;
        const from = path.join(legacyRoot, name);
        const to = path.join(dest, name);
        try {
          const st = await fs.stat(from);
          if (!st.isFile()) continue;
          // Переносим (а не копируем), чтобы:
          // - не было дублей между разными appName
          // - удалённые пользователем проекты не «возрождались» при следующем ensureRoots()
          try {
            await fs.rename(from, to);
          } catch {
            await fs.copyFile(from, to);
            try {
              await rmWithRetries(fs.rm, from, { force: true });
            } catch {
              // best effort: если zip уже скопирован в dest, миграцию считаем успешной;
              // legacy-копия может остаться (например из-за lock/AV), но удаление проекта
              // затем чистит legacy по fileName.
            }
          }
          destZips.add(name);
        } catch {
          /* ignore */
        }
      }
    }
  }

  async listProjects(): Promise<ProjectIndexEntry[]> {
    await this.ensureRoots();
    const root = getProjectsRootDir();
    const entries = await fs.readdir(root, { withFileTypes: true });
    const files = entries
      .filter((e) => e.isFile() && isProjectZipFileName(e.name))
      .map((e) => path.join(root, e.name));

    const out: ProjectIndexEntry[] = [];
    for (const filePath of files) {
      try {
        const project = await readProjectJsonFromZip(filePath);
        out.push({
          id: project.id,
          name: project.meta.name,
          updatedAt: project.meta.updatedAt,
          fileName: path.basename(filePath),
        });
      } catch {
        // Один битый архив не должен скрывать остальные проекты в списке.
      }
    }
    out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
    return out;
  }

  async createProject(name: string): Promise<Project> {
    await this.ensureRoots();
    this.projectSession += 1;
    const id = asProjectId(this.randomId());
    const now = new Date().toISOString();
    const fileBaseName = `${sanitizeFileName(name)}_${id}`;
    const appVer = getAppSemanticVersion();
    const project: Project = {
      id,
      meta: {
        name,
        fileBaseName,
        createdAt: now,
        updatedAt: now,
        createdWithAppVersion: appVer,
        appVersion: appVer,
        schemaVersion: PROJECT_SCHEMA_VERSION,
      },
      scenes: {},
      sceneListOrder: [],
      assets: {},
      campaignAudios: [],
      materials: [],
      npcs: [],
      npcGroups: [],
      npcRelations: [],
      currentSceneId: null,
      currentGraphNodeId: null,
      sceneGraphNodes: [],
      sceneGraphEdges: [],
    };

    const zipPath = path.join(getProjectsRootDir(), projectZipFileNameFromBase(fileBaseName));
    const cacheDir = path.join(getProjectsCacheRootDir(), id);
    const projectPath = path.join(cacheDir, 'project.json');
    this.openProject = { id, zipPath, cacheDir, projectPath, project };
    await this.writeCacheProject(cacheDir, project);
    await this.enqueuePack(cacheDir, zipPath);
    return this.openProject.project;
  }

  async openProjectById(projectId: ProjectId): Promise<Project> {
    return this.enqueueOpenProject(projectId);
  }

  private async openProjectByIdInner(
    projectId: ProjectId,
    onUnzipPercent?: (pct: number) => void,
  ): Promise<Project> {
    await this.ensureRoots();
    if (this.openProject?.id === projectId) {
      return this.openProject.project;
    }
    const sessionAtStart = this.projectSession;
    // Mutations are persisted to cache immediately, but zip packing is debounced (queueSave).
    // When switching projects we delete the cache and restore it from the zip, so flush pending saves first.
    if (this.openProject) {
      await this.saveNow();
    }
    await this.drainSavePipeline();
    if (sessionAtStart !== this.projectSession) {
      throw new Error('Открытие проекта отменено');
    }
    this.projectSession += 1;
    const openSession = this.projectSession;
    const list = await this.listProjects();
    const entry = list.find((p) => p.id === projectId);
    if (!entry) {
      throw new Error('Project not found');
    }
    const zipPath = path.join(getProjectsRootDir(), entry.fileName);
    const cacheDir = path.join(getProjectsCacheRootDir(), projectId);

    await fs.rm(cacheDir, { recursive: true, force: true });
    await fs.mkdir(cacheDir, { recursive: true });
    try {
      await unzipToDir(zipPath, cacheDir, (done, total) => {
        if (!onUnzipPercent) return;
        const pct = total > 0 ? Math.round((done / total) * 100) : 0;
        onUnzipPercent(Math.max(0, Math.min(100, pct)));
      });
    } catch (err) {
      const detail = err instanceof Error ? err.message : String(err);
      throw new Error(`Не удалось открыть проект: архив повреждён или занят (${detail})`);
    }

    if (openSession !== this.projectSession) {
      await fs.rm(cacheDir, { recursive: true, force: true }).catch(() => undefined);
      throw new Error('Открытие проекта отменено');
    }

    const projectPath = path.join(cacheDir, 'project.json');
    const projectRaw = await fs.readFile(projectPath, 'utf8');
    const parsed = JSON.parse(projectRaw) as unknown as Project;
    const project = normalizeProject(parsed);
    const fileBaseName = entry.fileName.replace(/\.dnd\.zip$/iu, '');
    project.meta.fileBaseName = project.meta.fileBaseName.trim().length
      ? project.meta.fileBaseName
      : fileBaseName;
    this.openProject = { id: projectId, zipPath, cacheDir, projectPath, project };
    return project;
  }

  private async openProjectByIdWithProgress(
    projectId: ProjectId,
    onUnzipPercent: (pct: number) => void,
  ): Promise<Project> {
    return this.enqueueOpenProject(projectId, onUnzipPercent);
  }

  getOpenProject(): Project | null {
    return this.openProject?.project ?? null;
  }

  /** Публичный URL для ассетов — кастомная схема `dnd:` (см. `registerDndAssetProtocol`). */
  getAssetFileUrl(assetId: AssetId): string | null {
    if (!this.getAssetReadInfo(assetId)) return null;
    return `dnd://asset?id=${encodeURIComponent(assetId)}`;
  }

  getAssetReadInfo(assetId: AssetId): { absPath: string; mime: string } | null {
    const open = this.openProject;
    if (!open) return null;
    const asset = open.project.assets[assetId];
    if (!asset) return null;
    return { absPath: path.join(open.cacheDir, asset.relPath), mime: asset.mime };
  }

  getImageAssetReadInfo(assetId: AssetId): { absPath: string; mime: string } | null {
    const open = this.openProject;
    if (!open) return null;
    const asset = open.project.assets[assetId];
    if (asset?.type !== 'image') return null;
    return { absPath: path.join(open.cacheDir, asset.relPath), mime: asset.mime };
  }

  async importScenePreviewMedia(
    sceneId: SceneId,
    filePath: string,
  ): Promise<{ project: Project; assetId: AssetId; background: boolean }> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    const sc = open.project.scenes[sceneId];
    if (!sc) throw new Error('Scene not found');

    const kind0 = classifyMediaPath(filePath);
    if (!kind0 || (kind0.type !== 'image' && kind0.type !== 'video')) {
      throw new Error('Файл превью должен быть изображением или видео');
    }

    const buf = await fs.readFile(filePath);
    const id = asAssetId(this.randomId());
    const orig = path.basename(filePath);
    const safeOrig = sanitizeFileName(orig);
    const relPath = `assets/${id}_${safeOrig}`;
    const abs = path.join(open.cacheDir, relPath);
    const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
    await fs.mkdir(path.dirname(abs), { recursive: true });
    await fs.writeFile(abs, buf);
    const asset = buildMediaAsset(id, kind0, orig, relPath, sha256, buf.length);

    const oldPreviewId = sc.previewAssetId;
    const oldThumbId = sc.previewThumbAssetId ?? null;

    await this.updateProject((p) => {
      const scene = p.scenes[sceneId];
      if (!scene) throw new Error('Scene not found');
      let assets: Record<AssetId, MediaAsset> = { ...p.assets };
      const drop = new Set<AssetId>();
      if (oldPreviewId) drop.add(oldPreviewId);
      if (oldThumbId) drop.add(oldThumbId);
      if (drop.size > 0) {
        assets = Object.fromEntries(
          Object.entries(assets).filter(([k]) => !drop.has(k as AssetId)),
        ) as Record<AssetId, MediaAsset>;
      }
      assets[id] = asset;
      return {
        ...p,
        assets,
        scenes: {
          ...p.scenes,
          [sceneId]: {
            ...scene,
            previewAssetId: id,
            previewAssetType: kind0.type,
            previewThumbAssetId: null,
            previewVideoAutostart: kind0.type === 'video' ? scene.previewVideoAutostart : false,
          },
        },
      };
    });

    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return { project: latest, assetId: id, background: true };
  }

  async finalizeScenePreviewImport(
    sceneId: SceneId,
    assetId: AssetId,
  ): Promise<{ project: Project; changed: boolean }> {
    return this.enqueuePreviewFinalize(() => this.finalizeScenePreviewImportInner(sceneId, assetId));
  }

  private async finalizeScenePreviewImportInner(
    sceneId: SceneId,
    assetId: AssetId,
  ): Promise<{ project: Project; changed: boolean }> {
    const sessionAtStart = this.projectSession;
    const open = this.openProject;
    if (!open) {
      // Queued finalize after close — nothing to apply.
      return { project: undefined as unknown as Project, changed: false };
    }
    const projectAtStart = open.project;
    const sceneAtStart = projectAtStart.scenes[sceneId];
    if (sceneAtStart?.previewAssetId !== assetId) {
      return { project: projectAtStart, changed: false };
    }
    const sourceAsset = projectAtStart.assets[assetId];
    if (!sourceAsset || (sourceAsset.type !== 'image' && sourceAsset.type !== 'video')) {
      return { project: projectAtStart, changed: false };
    }

    const cacheDir = open.cacheDir;
    const stillOpen = () =>
      this.openProject !== null &&
      this.projectSession === sessionAtStart &&
      this.openProject.cacheDir === cacheDir;

    const generatedRelPaths: string[] = [];
    let finalAsset = sourceAsset;
    let finalAssetId = assetId;
    let finalAbs = path.join(cacheDir, sourceAsset.relPath);
    /** Image bytes kept in memory so thumb/optimize do not re-open a path that reconcile may unlink. */
    let imageBytes: Buffer | null = null;

    if (sourceAsset.type === 'image') {
      try {
        imageBytes = await fs.readFile(finalAbs);
      } catch (e) {
        const err = e as NodeJS.ErrnoException;
        if (err?.code === 'ENOENT') {
          const latest = this.getOpenProject();
          return { project: latest ?? projectAtStart, changed: false };
        }
        throw e;
      }
      if (!stillOpen()) {
        return { project: this.getOpenProject() ?? projectAtStart, changed: false };
      }
      const opt = await optimizeImageBufferVisuallyLossless(imageBytes);
      if (!stillOpen()) {
        return { project: this.getOpenProject() ?? projectAtStart, changed: false };
      }
      if (!opt.passthrough) {
        finalAssetId = asAssetId(this.randomId());
        const optimizedName = `${path.parse(sourceAsset.originalName).name}.${opt.ext}`;
        const safeOptimizedName = sanitizeFileName(optimizedName);
        const optimizedRelPath = `assets/${finalAssetId}_${safeOptimizedName}`;
        finalAbs = path.join(cacheDir, optimizedRelPath);
        const optimizedBuffer = Buffer.from(opt.buffer);
        imageBytes = optimizedBuffer;
        await fs.writeFile(finalAbs, optimizedBuffer);
        generatedRelPaths.push(optimizedRelPath);
        const optimizedAsset = buildMediaAsset(
          finalAssetId,
          { type: 'image', mime: opt.mime },
          optimizedName,
          optimizedRelPath,
          crypto.createHash('sha256').update(optimizedBuffer).digest('hex'),
          optimizedBuffer.length,
        );
        if (optimizedAsset.type !== 'image') {
          throw new Error('Optimized preview asset must be an image');
        }
        finalAsset = optimizedAsset;
      }
    }

    if (!stillOpen()) {
      await Promise.all(
        generatedRelPaths.map((relPath) =>
          fs.unlink(path.join(cacheDir, relPath)).catch(() => undefined),
        ),
      );
      return { project: this.getOpenProject() ?? projectAtStart, changed: false };
    }

    const thumbKind = finalAsset.type === 'image' ? 'image' : 'video';
    const thumbBytes =
      thumbKind === 'image' && imageBytes
        ? await generateScenePreviewThumbnailBytes(imageBytes, 'image')
        : await generateScenePreviewThumbnailBytes(finalAbs, thumbKind);
    let thumbAsset: MediaAsset | null = null;
    let thumbId: AssetId | null = null;
    if (thumbBytes !== null && thumbBytes.length > 0) {
      thumbId = asAssetId(this.randomId());
      const thumbRelPath = `assets/${thumbId}_preview_thumb.webp`;
      const thumbAbs = path.join(cacheDir, thumbRelPath);
      await fs.writeFile(thumbAbs, thumbBytes);
      generatedRelPaths.push(thumbRelPath);
      const thumbOrigName = `${path.parse(finalAsset.originalName).name}_preview_thumb.webp`;
      thumbAsset = buildMediaAsset(
        thumbId,
        { type: 'image', mime: 'image/webp' },
        thumbOrigName,
        thumbRelPath,
        crypto.createHash('sha256').update(thumbBytes).digest('hex'),
        thumbBytes.length,
      );
    }

    if (!stillOpen()) {
      await Promise.all(
        generatedRelPaths.map((relPath) =>
          fs.unlink(path.join(cacheDir, relPath)).catch(() => undefined),
        ),
      );
      return { project: this.getOpenProject() ?? projectAtStart, changed: false };
    }

    let applied = false;
    try {
      await this.updateProject((p) => {
        const scene = p.scenes[sceneId];
        if (scene?.previewAssetId !== assetId) {
          return p;
        }
        applied = true;
        const assets: Record<AssetId, MediaAsset> = { ...p.assets, [finalAssetId]: finalAsset };
        if (finalAssetId !== assetId) {
          delete assets[assetId];
        }
        if (thumbAsset !== null && thumbId !== null) {
          assets[thumbId] = thumbAsset;
        }
        return {
          ...p,
          assets,
          scenes: {
            ...p.scenes,
            [sceneId]: {
              ...scene,
              previewAssetId: finalAssetId,
              previewAssetType: finalAsset.type,
              previewThumbAssetId: thumbId,
              previewVideoAutostart: finalAsset.type === 'video' ? scene.previewVideoAutostart : false,
            },
          },
        };
      });
    } catch (e) {
      if (!stillOpen() || (e instanceof Error && e.message === 'No open project')) {
        await Promise.all(
          generatedRelPaths.map((relPath) =>
            fs.unlink(path.join(cacheDir, relPath)).catch(() => undefined),
          ),
        );
        return { project: this.getOpenProject() ?? projectAtStart, changed: false };
      }
      throw e;
    }

    const latest = this.getOpenProject();
    if (!applied) {
      await Promise.all(
        generatedRelPaths.map((relPath) =>
          fs.unlink(path.join(cacheDir, relPath)).catch(() => undefined),
        ),
      );
    }

    return { project: latest ?? projectAtStart, changed: applied };
  }

  async clearScenePreview(sceneId: SceneId): Promise<Project> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    const sc = open.project.scenes[sceneId];
    if (!sc) throw new Error('Scene not found');
    const oldId = sc.previewAssetId;
    const oldThumbId = sc.previewThumbAssetId ?? null;
    if (!oldId && !oldThumbId) {
      return open.project;
    }
    await this.updateProject((p) => {
      const drop = new Set<AssetId>();
      if (oldId) drop.add(oldId);
      if (oldThumbId) drop.add(oldThumbId);
      const assets = Object.fromEntries(
        Object.entries(p.assets).filter(([k]) => !drop.has(k as AssetId)),
      ) as Record<AssetId, MediaAsset>;
      return {
        ...p,
        assets,
        scenes: {
          ...p.scenes,
          [sceneId]: {
            ...p.scenes[sceneId],
            previewAssetId: null,
            previewAssetType: null,
            previewThumbAssetId: null,
            previewVideoAutostart: false,
          },
        },
      };
    });
    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return latest;
  }

  async updateProject(mutator: (draft: Project) => Project): Promise<Project> {
    return this.enqueueProjectUpdate(async () => {
      const open = this.openProject;
      if (!open) throw new Error('No open project');
      const prev = open.project;
      let next = mutator(prev);
      next = await reconcileAssetFiles(prev, next, open.cacheDir);
      open.project = next;
      await this.writeCacheProject(open.cacheDir, next);
      this.queueSave();
      return next;
    });
  }

  async updateScene(sceneId: SceneId, patch: ScenePatch): Promise<Scene> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    const existing = open.project.scenes[sceneId];
    const base: Scene =
      existing ??
      ({
        id: sceneId,
        title: '',
        description: '',
        media: { videos: [], audios: [] },
        settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
        connections: [],
        layout: { x: 0, y: 0 },
        previewAssetId: null,
        previewAssetType: null,
        previewThumbAssetId: null,
        previewVideoAutostart: false,
        previewRotationDeg: 0,
        darkenScene: false,
        traps: [],
        tokens: [],
        npcTokens: [],
        grid: { ...DEFAULT_SCENE_GRID },
      } satisfies Scene);

    const next: Scene = {
      ...base,
      traps: base.traps ?? [],
      tokens: base.tokens ?? [],
      npcTokens: base.npcTokens ?? [],
      grid: base.grid ?? { ...DEFAULT_SCENE_GRID },
      ...(patch.title !== undefined ? { title: patch.title } : null),
      ...(patch.description !== undefined ? { description: patch.description } : null),
      ...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null),
      ...(patch.previewAssetType !== undefined ? { previewAssetType: patch.previewAssetType } : null),
      ...(patch.previewThumbAssetId !== undefined
        ? { previewThumbAssetId: patch.previewThumbAssetId }
        : null),
      ...(patch.previewVideoAutostart !== undefined
        ? { previewVideoAutostart: patch.previewVideoAutostart }
        : null),
      ...(patch.previewRotationDeg !== undefined ? { previewRotationDeg: patch.previewRotationDeg } : null),
      ...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
      ...(patch.traps !== undefined
        ? {
            traps: patch.traps.map((t) => normalizeSceneTrap(t)).filter((t): t is SceneTrap => Boolean(t)),
          }
        : null),
      ...(patch.tokens !== undefined
        ? {
            tokens: patch.tokens
              .map((t) => normalizeSceneToken(t))
              .filter((t): t is SceneToken => Boolean(t)),
          }
        : null),
      ...(patch.npcTokens !== undefined
        ? {
            npcTokens: patch.npcTokens
              .map((t) => normalizeSceneNpcToken(t))
              .filter((t): t is SceneNpcToken => Boolean(t)),
          }
        : null),
      ...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : null),
      ...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null),
      ...(patch.media ? { media: { ...base.media, ...patch.media } } : null),
      ...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null),
    };

    // Keep map markers glued to the art when previewRotationDeg changes (image/video).
    if (patch.previewRotationDeg !== undefined) {
      const fromRot = asPreviewRotationDeg(base.previewRotationDeg);
      const toRot = asPreviewRotationDeg(patch.previewRotationDeg);
      const steps = previewRotationStepsCw(fromRot, toRot);
      if (steps !== 0) {
        if (patch.tokens === undefined) {
          next.tokens = rotateSceneTokensByCwSteps(base.tokens ?? [], steps);
        }
        if (patch.npcTokens === undefined) {
          next.npcTokens = rotateMapMarkersByCwSteps(base.npcTokens ?? [], steps);
        }
        if (patch.traps === undefined) {
          next.traps = rotateMapMarkersByCwSteps(base.traps ?? [], steps);
        }
      }
    }

    await this.updateProject((p) => {
      const scenes = { ...p.scenes, [sceneId]: next };
      const sceneListOrder =
        existing != null
          ? reconcileSceneListOrder(scenes, p.sceneListOrder)
          : prependSceneListOrder(reconcileSceneListOrder(p.scenes, p.sceneListOrder), sceneId);
      return { ...p, scenes, sceneListOrder };
    });
    return next;
  }

  async updateConnections(sceneId: SceneId, connections: SceneId[]): Promise<Scene> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    const existing = open.project.scenes[sceneId];
    if (!existing) throw new Error('Scene not found');
    const next: Scene = { ...existing, connections: [...connections] };
    await this.updateProject((p) => ({ ...p, scenes: { ...p.scenes, [sceneId]: next } }));
    return next;
  }

  async deleteScene(sceneId: SceneId): Promise<Project> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    if (!open.project.scenes[sceneId]) throw new Error('Scene not found');

    await this.updateProject((p) => {
      const sceneGraphNodes = p.sceneGraphNodes.filter((n) => n.sceneId !== sceneId);
      const nodeIds = new Set(sceneGraphNodes.map((n) => n.id));
      const sceneGraphEdges = p.sceneGraphEdges.filter(
        (e) => nodeIds.has(e.sourceGraphNodeId) && nodeIds.has(e.targetGraphNodeId),
      );

      const scenes = Object.fromEntries(
        (Object.entries(p.scenes) as [SceneId, Scene][]).filter(([id]) => id !== sceneId),
      ) as Record<SceneId, Scene>;
      const withGraph: Project = {
        ...p,
        scenes,
        sceneGraphNodes,
        sceneGraphEdges,
      };
      const outgoing = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges);
      const nextScenes = applyConnectionSets(withGraph.scenes, outgoing);

      let currentSceneId = p.currentSceneId;
      if (currentSceneId === sceneId) {
        const ids = Object.keys(nextScenes) as SceneId[];
        currentSceneId = ids[0] ?? null;
      }

      return {
        ...withGraph,
        scenes: nextScenes,
        sceneListOrder: removeFromSceneListOrder(
          reconcileSceneListOrder(withGraph.scenes, p.sceneListOrder),
          sceneId,
        ),
        currentSceneId,
      };
    });
    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return latest;
  }

  async setSceneListOrder(sceneListOrder: SceneId[]): Promise<Project> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    await this.updateProject((p) => ({
      ...p,
      sceneListOrder: reconcileSceneListOrder(p.scenes, sceneListOrder),
    }));
    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return latest;
  }

  async updateSceneGraphNodePosition(nodeId: GraphNodeId, x: number, y: number): Promise<Project> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    await this.updateProject((p) => ({
      ...p,
      sceneGraphNodes: p.sceneGraphNodes.map((n) => (n.id === nodeId ? { ...n, x, y } : n)),
    }));
    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return latest;
  }

  async addSceneGraphNode(sceneId: SceneId, x: number, y: number): Promise<Project> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    if (!open.project.scenes[sceneId]) throw new Error('Scene not found');
    const node: SceneGraphNode = {
      id: asGraphNodeId(`gn_${this.randomId()}`),
      sceneId,
      x,
      y,
      isStartScene: false,
      isSideStoryStart: false,
      sideStoryLineTitle: '',
    };
    await this.updateProject((p) => ({ ...p, sceneGraphNodes: [...p.sceneGraphNodes, node] }));
    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return latest;
  }

  async setSceneGraphNodeStart(graphNodeId: GraphNodeId | null): Promise<Project> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    if (graphNodeId !== null && !open.project.sceneGraphNodes.some((n) => n.id === graphNodeId)) {
      throw new Error('Graph node not found');
    }
    await this.updateProject((p) => {
      return {
        ...p,
        sceneGraphNodes: p.sceneGraphNodes.map((n) => {
          const isMain = graphNodeId !== null && n.id === graphNodeId;
          if (isMain) {
            return { ...n, isStartScene: true, isSideStoryStart: false, sideStoryLineTitle: '' };
          }
          return { ...n, isStartScene: false };
        }),
      };
    });
    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return latest;
  }

  async setSceneGraphNodeSideStoryStart(graphNodeId: GraphNodeId | null): Promise<Project> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    if (graphNodeId === null) {
      throw new Error('Graph node id required');
    }
    if (!open.project.sceneGraphNodes.some((n) => n.id === graphNodeId)) {
      throw new Error('Graph node not found');
    }
    const node = open.project.sceneGraphNodes.find((n) => n.id === graphNodeId);
    if (!node) throw new Error('Graph node not found');
    const enabling = !node.isSideStoryStart;
    if (
      enabling &&
      !canSetSideStoryStart(open.project.sceneGraphNodes, open.project.sceneGraphEdges, graphNodeId)
    ) {
      return open.project;
    }
    await this.updateProject((p) => {
      return {
        ...p,
        sceneGraphNodes: p.sceneGraphNodes.map((n) => {
          if (n.id !== graphNodeId) return n;
          if (enabling) {
            return { ...n, isSideStoryStart: true, isStartScene: false };
          }
          return { ...n, isSideStoryStart: false, sideStoryLineTitle: '' };
        }),
      };
    });
    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return latest;
  }

  async updateSideStoryLineTitle(graphNodeId: GraphNodeId, title: string): Promise<Project> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    const node = open.project.sceneGraphNodes.find((n) => n.id === graphNodeId);
    if (!node?.isSideStoryStart) throw new Error('Not a side story start node');
    await this.updateProject((p) => ({
      ...p,
      sceneGraphNodes: p.sceneGraphNodes.map((n) =>
        n.id === graphNodeId ? { ...n, sideStoryLineTitle: title } : n,
      ),
    }));
    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return latest;
  }

  async removeSceneGraphNode(nodeId: GraphNodeId): Promise<Project> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    const node = open.project.sceneGraphNodes.find((n) => n.id === nodeId);
    if (!node) throw new Error('Graph node not found');

    if (node.isSideStoryStart) {
      const outgoing = open.project.sceneGraphEdges
        .filter((e) => e.sourceGraphNodeId === nodeId)
        .sort((a, b) => a.id.localeCompare(b.id));

      if (outgoing.length > 0) {
        const newStartId = outgoing[0]?.targetGraphNodeId;
        if (!newStartId) throw new Error('Invalid graph edge');
        const nextNodes = open.project.sceneGraphNodes
          .filter((gn) => gn.id !== nodeId)
          .map((gn) =>
            gn.id === newStartId
              ? {
                  ...gn,
                  isSideStoryStart: true,
                  isStartScene: false,
                  sideStoryLineTitle: node.sideStoryLineTitle,
                }
              : gn,
          );
        const nextEdges = open.project.sceneGraphEdges.filter(
          (e) => e.sourceGraphNodeId !== nodeId && e.targetGraphNodeId !== nodeId,
        );
        await this.updateProject((p) => {
          const withGraph = { ...p, sceneGraphNodes: nextNodes, sceneGraphEdges: nextEdges };
          const out = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges);
          return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, out) };
        });
        const latest = this.getOpenProject();
        if (!latest) throw new Error('No open project');
        return latest;
      }

      const removeIds = getSideStoryComponentNodeIds(
        open.project.sceneGraphNodes,
        open.project.sceneGraphEdges,
        nodeId,
      );
      const nextNodes = open.project.sceneGraphNodes.filter((gn) => !removeIds.has(gn.id));
      const nextEdges = open.project.sceneGraphEdges.filter(
        (e) => !removeIds.has(e.sourceGraphNodeId) && !removeIds.has(e.targetGraphNodeId),
      );
      await this.updateProject((p) => {
        const withGraph = { ...p, sceneGraphNodes: nextNodes, sceneGraphEdges: nextEdges };
        const out = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges);
        return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, out) };
      });
      const latest = this.getOpenProject();
      if (!latest) throw new Error('No open project');
      return latest;
    }

    const nextNodes = open.project.sceneGraphNodes.filter((gn) => gn.id !== nodeId);
    const nextEdges = open.project.sceneGraphEdges.filter(
      (e) => e.sourceGraphNodeId !== nodeId && e.targetGraphNodeId !== nodeId,
    );
    await this.updateProject((p) => {
      const withGraph = { ...p, sceneGraphNodes: nextNodes, sceneGraphEdges: nextEdges };
      const outgoing = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges);
      return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, outgoing) };
    });
    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return latest;
  }

  async addSceneGraphEdge(sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId): Promise<Project> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    const { sceneGraphNodes, sceneGraphEdges } = open.project;
    if (isSceneGraphEdgeRejected(sceneGraphNodes, sceneGraphEdges, sourceGraphNodeId, targetGraphNodeId)) {
      return open.project;
    }
    const edge: SceneGraphEdge = {
      id: `e_${this.randomId()}`,
      sourceGraphNodeId,
      targetGraphNodeId,
    };
    await this.updateProject((p) => {
      const nextEdges = [...p.sceneGraphEdges, edge];
      const withGraph = { ...p, sceneGraphEdges: nextEdges };
      const outgoing = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges);
      return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, outgoing) };
    });
    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return latest;
  }

  async removeSceneGraphEdge(edgeId: string): Promise<Project> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    await this.updateProject((p) => {
      const nextEdges = p.sceneGraphEdges.filter((e) => e.id !== edgeId);
      const withGraph = { ...p, sceneGraphEdges: nextEdges };
      const outgoing = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges);
      return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, outgoing) };
    });
    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return latest;
  }

  /**
   * Copies files into cache `assets/` and registers them on the scene + project.assets.
   */
  async importMediaFiles(
    sceneId: SceneId,
    filePaths: string[],
  ): Promise<{ project: Project; imported: MediaAsset[] }> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    const existingScene = open.project.scenes[sceneId];
    if (!existingScene) throw new Error('Scene not found');
    if (filePaths.length === 0) {
      return { project: open.project, imported: [] };
    }

    const staged: MediaAsset[] = [];
    for (const filePath of filePaths) {
      const kind = classifyMediaPath(filePath);
      if (kind?.type !== 'audio') continue;
      const buf = await fs.readFile(filePath);
      const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
      const id = asAssetId(this.randomId());
      const orig = path.basename(filePath);
      const safeOrig = sanitizeFileName(orig);
      const relPath = `assets/${id}_${safeOrig}`;
      const abs = path.join(open.cacheDir, relPath);
      await fs.mkdir(path.dirname(abs), { recursive: true });
      await fs.copyFile(filePath, abs);
      staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length));
    }

    if (staged.length === 0) {
      return { project: open.project, imported: [] };
    }

    await this.updateProject((p) => {
      const sc = p.scenes[sceneId];
      if (!sc) throw new Error('Scene not found');
      const assets = { ...p.assets };
      const media = {
        videos: [...sc.media.videos],
        audios: [...sc.media.audios],
      };
      for (const asset of staged) {
        assets[asset.id] = asset;
        if (asset.type === 'video') media.videos.push(asset.id);
        else
          media.audios.push({
            assetId: asset.id,
            autoplay: sc.settings.autoplayAudio,
            loop: sc.settings.loopAudio,
          });
      }
      return {
        ...p,
        assets,
        scenes: { ...p.scenes, [sceneId]: { ...sc, media } },
      };
    });

    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return { project: latest, imported: staged };
  }

  /**
   * Copies audio files into cache `assets/` and registers them on the project as campaign audio.
   */
  async importCampaignAudioFiles(filePaths: string[]): Promise<{ project: Project; imported: MediaAsset[] }> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    if (filePaths.length === 0) {
      return { project: open.project, imported: [] };
    }

    const staged: MediaAsset[] = [];
    for (const filePath of filePaths) {
      const kind = classifyMediaPath(filePath);
      if (kind?.type !== 'audio') continue;
      const buf = await fs.readFile(filePath);
      const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
      const id = asAssetId(this.randomId());
      const orig = path.basename(filePath);
      const safeOrig = sanitizeFileName(orig);
      const relPath = `assets/${id}_${safeOrig}`;
      const abs = path.join(open.cacheDir, relPath);
      await fs.mkdir(path.dirname(abs), { recursive: true });
      await fs.copyFile(filePath, abs);
      staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length));
    }

    if (staged.length === 0) {
      return { project: open.project, imported: [] };
    }

    await this.updateProject((p) => {
      const assets = { ...p.assets };
      const campaignAudios = [...p.campaignAudios];
      for (const asset of staged) {
        assets[asset.id] = asset;
        if (asset.type !== 'audio') continue;
        campaignAudios.push({ assetId: asset.id, autoplay: false, loop: false });
      }
      return { ...p, assets, campaignAudios };
    });

    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return { project: latest, imported: staged };
  }

  async setCampaignAudios(audios: Project['campaignAudios']): Promise<Project> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    await this.updateProject((p) => ({ ...p, campaignAudios: Array.isArray(audios) ? audios : [] }));
    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return latest;
  }

  /**
   * Создаёт или обновляет материал кампании.
   * При создании `filePath` обязателен; при обновлении можно сменить только имя или только картинку.
   */
  async upsertMaterial(
    input: {
      materialId?: MaterialId;
      name: string;
      filePath?: string;
    },
    onProgress?: (p: { percent: number; stage: string; detail?: string }) => void,
  ): Promise<Project> {
    const report = (percent: number, stage: string, detail?: string) => {
      onProgress?.({ percent, stage, ...(detail ? { detail } : {}) });
    };
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    const name = input.name.trim();
    if (name.length < 1) throw new Error('Material name is required');
    const nameKey = name.toLowerCase();
    const existing = open.project.materials ?? [];
    const editingId = input.materialId ?? null;
    if (existing.some((m) => m.id !== editingId && m.name.trim().toLowerCase() === nameKey)) {
      throw new Error('Material name already exists');
    }

    report(2, 'start', 'Подождите…');

    let nextAssetId: AssetId | null = null;
    let stagedAsset: MediaAsset | null = null;
    if (input.filePath) {
      const kind = classifyMediaPath(input.filePath);
      if (kind?.type !== 'image') throw new Error('Material must be an image (png/jpg/webp)');
      const ext = path.extname(input.filePath).toLowerCase();
      if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
        throw new Error('Material must be an image (png/jpg/webp)');
      }
      report(8, 'read', 'Чтение изображения…');
      let buf = await fs.readFile(input.filePath);
      report(18, 'optimize', 'Оптимизация изображения…');
      try {
        const opt = await optimizeImageBufferVisuallyLossless(buf);
        if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
      } catch {
        // keep original buffer
      }
      report(72, 'write', 'Сохранение файла…');
      const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
      const id = asAssetId(this.randomId());
      const orig = path.basename(input.filePath);
      const safeOrig = sanitizeFileName(orig);
      const relPath = `assets/${id}_${safeOrig}`;
      const abs = path.join(open.cacheDir, relPath);
      await fs.mkdir(path.dirname(abs), { recursive: true });
      await fs.writeFile(abs, buf);
      stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
      nextAssetId = id;
    }

    report(88, 'project', 'Обновление проекта…');
    await this.updateProject((p) => {
      const materials = [...(p.materials ?? [])];
      const assets = { ...p.assets };
      if (stagedAsset) assets[stagedAsset.id] = stagedAsset;

      if (editingId) {
        const idx = materials.findIndex((m) => m.id === editingId);
        if (idx < 0) throw new Error('Material not found');
        const prev = materials[idx]!;
        const assetId = nextAssetId ?? prev.assetId;
        materials[idx] = {
          id: editingId,
          name,
          assetId,
          rotationDeg: prev.rotationDeg ?? 0,
          ...(prev.legend ? { legend: prev.legend } : {}),
        };
      } else {
        if (!nextAssetId) throw new Error('Material image is required');
        materials.push({
          id: asMaterialId(`mat_${this.randomId()}`),
          name,
          assetId: nextAssetId,
          rotationDeg: 0,
        });
      }
      return { ...p, assets, materials };
    });

    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    report(100, 'done', 'Готово');
    return latest;
  }

  async setMaterialRotation(materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270): Promise<Project> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    await this.updateProject((p) => {
      const materials = (p.materials ?? []).map((m) => (m.id === materialId ? { ...m, rotationDeg } : m));
      return { ...p, materials };
    });
    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return latest;
  }

  async setMaterialLegend(materialId: MaterialId, legend: MaterialLegend | null): Promise<Project> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    const normalized = legend ? normalizeMaterialLegend(legend) : undefined;
    await this.updateProject((p) => {
      const materials = (p.materials ?? []).map((m) => {
        if (m.id !== materialId) return m;
        if (
          !normalized ||
          (!normalized.enabled && normalized.items.length === 0 && normalized.markers.length === 0)
        ) {
          const { legend: _drop, ...rest } = m;
          void _drop;
          return rest;
        }
        return { ...m, legend: normalized };
      });
      return { ...p, materials };
    });
    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return latest;
  }

  async deleteMaterial(materialId: MaterialId): Promise<Project> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    await this.updateProject((p) => ({
      ...p,
      materials: (p.materials ?? []).filter((m) => m.id !== materialId),
    }));
    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return latest;
  }

  async setMaterialsOrder(materialIds: MaterialId[]): Promise<Project> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    await this.updateProject((p) => {
      const byId = new Map((p.materials ?? []).map((m) => [m.id, m]));
      const next: NonNullable<Project['materials']> = [];
      for (const id of materialIds) {
        const m = byId.get(id);
        if (m) {
          next.push(m);
          byId.delete(id);
        }
      }
      for (const m of byId.values()) next.push(m);
      return { ...p, materials: next };
    });
    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return latest;
  }

  /**
   * Создаёт или обновляет НПС.
   * При создании `filePath` (аватар) обязателен; при обновлении можно сменить только имя/описание/аватар.
   */
  async upsertNpc(
    input: {
      npcId?: NpcId;
      name: string;
      description?: string;
      filePath?: string;
      groupId?: NpcGroupId | null;
      ringColor?: string;
      disposition?: NpcDisposition;
      imageOffset?: { x: number; y: number };
      imageScale?: number;
    },
    onProgress?: (p: { percent: number; stage: string; detail?: string }) => void,
  ): Promise<Project> {
    const report = (percent: number, stage: string, detail?: string) => {
      onProgress?.({ percent, stage, ...(detail ? { detail } : {}) });
    };
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    const name = input.name.trim();
    if (name.length < 1) throw new Error('NPC name is required');
    const nameKey = name.toLowerCase();
    const existing = open.project.npcs ?? [];
    const editingId = input.npcId ?? null;
    if (existing.some((n) => n.id !== editingId && n.name.trim().toLowerCase() === nameKey)) {
      throw new Error('NPC name already exists');
    }

    report(2, 'start', 'Подождите…');

    let nextAssetId: AssetId | null = null;
    let stagedAsset: MediaAsset | null = null;
    if (input.filePath) {
      const kind = classifyMediaPath(input.filePath);
      if (kind?.type !== 'image') throw new Error('NPC avatar must be an image (png/jpg/webp)');
      const ext = path.extname(input.filePath).toLowerCase();
      if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
        throw new Error('NPC avatar must be an image (png/jpg/webp)');
      }
      report(8, 'read', 'Чтение изображения…');
      let buf = await fs.readFile(input.filePath);
      report(18, 'optimize', 'Оптимизация изображения…');
      try {
        const opt = await optimizeImageBufferVisuallyLossless(buf);
        if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
      } catch {
        // keep original buffer
      }
      report(72, 'write', 'Сохранение файла…');
      const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
      const id = asAssetId(this.randomId());
      const orig = path.basename(input.filePath);
      const safeOrig = sanitizeFileName(orig);
      const relPath = `assets/${id}_${safeOrig}`;
      const abs = path.join(open.cacheDir, relPath);
      await fs.mkdir(path.dirname(abs), { recursive: true });
      await fs.writeFile(abs, buf);
      stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
      nextAssetId = id;
    }

    report(88, 'project', 'Обновление проекта…');
    await this.updateProject((p) => {
      const npcs = [...(p.npcs ?? [])];
      const assets = { ...p.assets };
      if (stagedAsset) assets[stagedAsset.id] = stagedAsset;

      const groupIds = new Set((p.npcGroups ?? []).map((g) => g.id));
      const resolveGroup = (
        raw: NpcGroupId | null | undefined,
        prev: NpcGroupId | null,
      ): NpcGroupId | null => {
        if (raw === undefined) return prev;
        if (raw === null) return null;
        return groupIds.has(raw) ? raw : null;
      };

      if (editingId) {
        const idx = npcs.findIndex((n) => n.id === editingId);
        if (idx < 0) throw new Error('NPC not found');
        const prev = npcs[idx]!;
        npcs[idx] = {
          ...prev,
          name,
          avatarAssetId: nextAssetId ?? prev.avatarAssetId,
          description: typeof input.description === 'string' ? input.description : prev.description,
          groupId: resolveGroup(input.groupId, prev.groupId),
          ...(input.ringColor !== undefined
            ? { ringColor: normalizeHexColor(input.ringColor, DEFAULT_PLAYER_RING_COLOR) }
            : {}),
          ...(input.disposition !== undefined
            ? {
                disposition: normalizeNpcDisposition(input.disposition),
                ringColor: npcDispositionRingColor(normalizeNpcDisposition(input.disposition)),
              }
            : {}),
          ...(input.imageOffset !== undefined
            ? { imageOffset: clampPlayerImageOffset(input.imageOffset) }
            : {}),
          ...(input.imageScale !== undefined
            ? { imageScale: clampPlayerImageScale(input.imageScale) }
            : {}),
        };
      } else {
        if (!nextAssetId) throw new Error('NPC avatar is required');
        const count = npcs.length;
        const disposition = normalizeNpcDisposition(input.disposition ?? DEFAULT_NPC_DISPOSITION);
        npcs.push({
          id: asNpcId(`npc_${this.randomId()}`),
          name,
          avatarAssetId: nextAssetId,
          description: typeof input.description === 'string' ? input.description : '',
          x: 80 + (count % 4) * 220,
          y: 80 + Math.floor(count / 4) * 200,
          groupId: resolveGroup(input.groupId, null),
          disposition,
          ringColor: npcDispositionRingColor(disposition),
          imageOffset: clampPlayerImageOffset(input.imageOffset),
          imageScale: clampPlayerImageScale(input.imageScale),
        });
      }
      return { ...p, assets, npcs };
    });

    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    report(100, 'done', 'Готово');
    return latest;
  }

  async updateNpcFields(
    npcId: NpcId,
    patch: {
      name?: string;
      description?: string;
      groupId?: NpcGroupId | null;
      ringColor?: string;
      disposition?: NpcDisposition;
      imageOffset?: { x: number; y: number };
      imageScale?: number;
    },
  ): Promise<Project> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    const name = typeof patch.name === 'string' ? patch.name.trim() : undefined;
    if (name !== undefined) {
      if (name.length < 1) throw new Error('NPC name is required');
      const nameKey = name.toLowerCase();
      if ((open.project.npcs ?? []).some((n) => n.id !== npcId && n.name.trim().toLowerCase() === nameKey)) {
        throw new Error('NPC name already exists');
      }
    }
    await this.updateProject((p) => {
      const groupIds = new Set((p.npcGroups ?? []).map((g) => g.id));
      const npcs = (p.npcs ?? []).map((n) => {
        if (n.id !== npcId) return n;
        let groupId = n.groupId;
        if (patch.groupId !== undefined) {
          groupId = patch.groupId === null ? null : groupIds.has(patch.groupId) ? patch.groupId : null;
        }
        const disposition =
          patch.disposition !== undefined
            ? normalizeNpcDisposition(patch.disposition)
            : normalizeNpcDisposition(n.disposition);
        return {
          ...n,
          ...(name !== undefined ? { name } : {}),
          ...(typeof patch.description === 'string' ? { description: patch.description } : {}),
          groupId,
          disposition,
          ringColor:
            patch.disposition !== undefined
              ? npcDispositionRingColor(disposition)
              : patch.ringColor !== undefined
                ? normalizeHexColor(patch.ringColor, DEFAULT_PLAYER_RING_COLOR)
                : n.ringColor || npcDispositionRingColor(disposition),
          ...(patch.imageOffset !== undefined
            ? { imageOffset: clampPlayerImageOffset(patch.imageOffset) }
            : {}),
          ...(patch.imageScale !== undefined
            ? { imageScale: clampPlayerImageScale(patch.imageScale) }
            : {}),
        };
      });
      return { ...p, npcs };
    });
    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return latest;
  }

  async updateNpcPosition(npcId: NpcId, x: number, y: number): Promise<Project> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    await this.updateProject((p) => ({
      ...p,
      npcs: (p.npcs ?? []).map((n) => (n.id === npcId ? { ...n, x, y } : n)),
    }));
    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return latest;
  }

  async deleteNpc(npcId: NpcId): Promise<Project> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    await this.updateProject((p) => {
      const scenes: Record<SceneId, Scene> = { ...p.scenes };
      for (const sid of Object.keys(scenes) as SceneId[]) {
        const sc = scenes[sid];
        if (!sc) continue;
        const npcTokens = (sc.npcTokens ?? []).filter((t) => t.npcId !== npcId);
        if (npcTokens.length !== (sc.npcTokens ?? []).length) {
          scenes[sid] = { ...sc, npcTokens };
        }
      }
      return {
        ...p,
        scenes,
        npcs: (p.npcs ?? []).filter((n) => n.id !== npcId),
        npcRelations: (p.npcRelations ?? []).filter(
          (r) => r.sourceNpcId !== npcId && r.targetNpcId !== npcId,
        ),
      };
    });
    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return latest;
  }

  async setNpcsOrder(npcIds: NpcId[]): Promise<Project> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    await this.updateProject((p) => {
      const byId = new Map((p.npcs ?? []).map((n) => [n.id, n]));
      const next: ProjectNpc[] = [];
      for (const id of npcIds) {
        const n = byId.get(id);
        if (n) {
          next.push(n);
          byId.delete(id);
        }
      }
      for (const n of byId.values()) next.push(n);
      return { ...p, npcs: next };
    });
    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return latest;
  }

  async upsertNpcGroup(input: {
    groupId?: NpcGroupId;
    name: string;
    color?: string;
    parentId?: NpcGroupId | null;
  }): Promise<Project> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    const name = input.name.trim();
    if (name.length < 1) throw new Error('Group name is required');
    const color = normalizeHexColor(input.color, DEFAULT_NPC_GROUP_COLOR);
    await this.updateProject((p) => {
      const groups = [...(p.npcGroups ?? [])];
      const editingId = input.groupId ?? null;
      const parentId =
        input.parentId === undefined
          ? editingId
            ? (groups.find((g) => g.id === editingId)?.parentId ?? null)
            : null
          : input.parentId;
      if (parentId && !groups.some((g) => g.id === parentId)) {
        throw new Error('Parent group not found');
      }
      if (editingId && parentId && wouldCreateGroupCycle(groups, editingId, parentId)) {
        throw new Error('Invalid group parent');
      }
      const nameKey = name.toLowerCase();
      const siblingConflict = groups.some(
        (g) => g.id !== editingId && g.parentId === parentId && g.name.trim().toLowerCase() === nameKey,
      );
      if (siblingConflict) throw new Error('Group name already exists');

      if (editingId) {
        const idx = groups.findIndex((g) => g.id === editingId);
        if (idx < 0) throw new Error('Group not found');
        groups[idx] = { ...groups[idx]!, name, color, parentId };
      } else {
        groups.push({
          id: asNpcGroupId(`ng_${this.randomId()}`),
          name,
          color,
          parentId,
        });
      }
      return { ...p, npcGroups: groups };
    });
    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return latest;
  }

  async deleteNpcGroup(groupId: NpcGroupId): Promise<Project> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    await this.updateProject((p) => {
      const groups = p.npcGroups ?? [];
      if (!groups.some((g) => g.id === groupId)) throw new Error('Group not found');
      const parentOfDeleted = groups.find((g) => g.id === groupId)?.parentId ?? null;
      const nextGroups = groups
        .filter((g) => g.id !== groupId)
        .map((g) => (g.parentId === groupId ? { ...g, parentId: parentOfDeleted } : g));
      const npcs = (p.npcs ?? []).map((n) => (n.groupId === groupId ? { ...n, groupId: null } : n));
      return { ...p, npcGroups: nextGroups, npcs };
    });
    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return latest;
  }

  async setNpcGroupsOrder(groupIds: NpcGroupId[]): Promise<Project> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    await this.updateProject((p) => {
      const byId = new Map((p.npcGroups ?? []).map((g) => [g.id, g]));
      const next: ProjectNpcGroup[] = [];
      for (const id of groupIds) {
        const g = byId.get(id);
        if (g) {
          next.push(g);
          byId.delete(id);
        }
      }
      for (const g of byId.values()) next.push(g);
      return { ...p, npcGroups: next };
    });
    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return latest;
  }

  async upsertNpcRelation(input: {
    relationId?: NpcRelationId;
    sourceNpcId: NpcId;
    targetNpcId: NpcId;
    label: string;
  }): Promise<Project> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    const label = input.label.trim();
    if (label.length < 1) throw new Error('Relation label is required');
    if (input.sourceNpcId === input.targetNpcId) throw new Error('Cannot relate NPC to itself');
    const npcs = open.project.npcs ?? [];
    if (!npcs.some((n) => n.id === input.sourceNpcId) || !npcs.some((n) => n.id === input.targetNpcId)) {
      throw new Error('NPC not found');
    }
    await this.updateProject((p) => {
      const relations = [...(p.npcRelations ?? [])];
      if (input.relationId) {
        const idx = relations.findIndex((r) => r.id === input.relationId);
        if (idx < 0) throw new Error('Relation not found');
        relations[idx] = { ...relations[idx]!, label };
      } else {
        relations.push({
          id: asNpcRelationId(`nrel_${this.randomId()}`),
          sourceNpcId: input.sourceNpcId,
          targetNpcId: input.targetNpcId,
          label,
        });
      }
      return { ...p, npcRelations: relations };
    });
    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return latest;
  }

  async deleteNpcRelation(relationId: NpcRelationId): Promise<Project> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');
    await this.updateProject((p) => ({
      ...p,
      npcRelations: (p.npcRelations ?? []).filter((r) => r.id !== relationId),
    }));
    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return latest;
  }

  async saveNow(): Promise<void> {
    const open = this.openProject;
    if (!open) return;
    // Let background preview finalizers finish before packing cache → zip.
    await this.drainProjectMutations();
    await this.projectWriteChain;
    await this.enqueuePack(open.cacheDir, open.zipPath);
  }

  async closeOpenProject(): Promise<void> {
    return this.enqueueProjectSwitch(async () => {
      // Invalidate in-flight finalize early; then wait for queue to settle.
      this.projectSession += 1;
      if (!this.openProject) return;
      await this.drainProjectMutations();
      await this.saveNow();
      await this.drainSavePipeline();
      this.saveQueued = false;
      this.openProject = null;
    });
  }

  async renameOpenProject(name: string, fileBaseName: string): Promise<Project> {
    const open = this.openProject;
    if (!open) throw new Error('No open project');

    const nextName = name.trim();
    const nextBase = fileBaseName.trim();
    if (nextName.length < 3) {
      throw new Error('Название проекта должно быть не короче 3 символов');
    }
    if (nextBase.length < 3) {
      throw new Error('Название файла проекта должно быть не короче 3 символов');
    }

    const sanitizedBase = sanitizeFileName(nextBase);
    if (sanitizedBase !== nextBase) {
      throw new Error('Название файла содержит недопустимые символы');
    }

    await this.ensureRoots();
    const root = getProjectsRootDir();
    const list = await this.listProjects();

    const oldBase = open.project.meta.fileBaseName;
    const nextFileName = projectZipFileNameFromBase(sanitizedBase);

    const nameClash = list.some(
      (p) => p.id !== open.id && p.name.trim().toLowerCase() === nextName.toLowerCase(),
    );
    if (nameClash) {
      throw new Error('Проект с таким названием уже существует');
    }

    const fileClash = list.some(
      (p) => p.id !== open.id && p.fileName.trim().toLowerCase() === nextFileName.toLowerCase(),
    );
    if (fileClash) {
      throw new Error('Файл проекта с таким названием уже существует');
    }

    // Update project meta first (will auto-save).
    await this.updateProject((p) => ({
      ...p,
      meta: { ...p.meta, name: nextName, fileBaseName: sanitizedBase },
    }));

    // Rename zip on disk (cache stays the same, just points to new zip path).
    if (nextBase !== oldBase) {
      const nextZipPath = path.join(root, nextFileName);
      await this.projectWriteChain;
      await this.enqueuePack(open.cacheDir, open.zipPath);
      await replaceFileAtomic(open.zipPath, nextZipPath);
      open.zipPath = nextZipPath;
    }

    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    return latest;
  }

  private queueSave() {
    if (this.saveQueued) return;
    this.saveQueued = true;
    if (this.saveDebounceTimer) {
      clearTimeout(this.saveDebounceTimer);
    }
    this.saveDebounceTimer = setTimeout(() => {
      this.saveDebounceTimer = null;
      void this.flushSave();
    }, 250);
  }

  private async flushSave() {
    if (this.saving) return;
    const open = this.openProject;
    if (!open) return;

    this.saveQueued = false;
    this.saving = true;
    try {
      await this.projectWriteChain;
      await this.enqueuePack(open.cacheDir, open.zipPath);
    } finally {
      this.saving = false;
      // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- may change during async save
      if (this.saveQueued) {
        setTimeout(() => void this.flushSave(), 50);
      }
    }
  }

  private async writeCacheProject(cacheDir: string, project: Project): Promise<void> {
    const sessionAtStart = this.projectSession;
    const run = async (): Promise<void> => {
      await this.packChain;
      if (sessionAtStart !== this.projectSession) {
        return;
      }
      await fs.mkdir(path.join(cacheDir, 'assets'), { recursive: true });
      const now = new Date().toISOString();
      const withUpdated: Project = {
        ...project,
        meta: {
          ...project.meta,
          updatedAt: now,
          appVersion: getAppSemanticVersion(),
        },
      };
      const targetPath = path.join(cacheDir, 'project.json');
      await atomicWriteFile(targetPath, JSON.stringify(withUpdated, null, 2));
      if (sessionAtStart !== this.projectSession) {
        return;
      }
      if (this.openProject?.cacheDir === cacheDir) {
        this.openProject.project = withUpdated;
      }
    };
    const chained = this.projectWriteChain.then(run);
    this.projectWriteChain = chained.catch(() => undefined);
    await chained;
  }

  private async packZipFromCache(cacheDir: string, zipPath: string): Promise<void> {
    const tmpPath = `${zipPath}.tmp`;
    await fs.mkdir(path.dirname(zipPath), { recursive: true });
    await zipDir(cacheDir, tmpPath);
    const st = await fs.stat(tmpPath).catch(() => null);
    if (!st?.isFile() || st.size < 22) {
      await fs.unlink(tmpPath).catch(() => undefined);
      throw new Error('Сборка архива проекта не удалась (пустой или повреждённый временный файл)');
    }
    await replaceFileAtomic(tmpPath, zipPath);
  }

  /**
   * Копирует внешний `.dnd.zip` в каталог проектов и открывает его.
   * Если архив уже лежит в `projects`, только открывает.
   * При конфликте `id` с другим файлом перезаписывает `project.json` в копии с новым id.
   */
  async importProjectFromExternalZip(
    sourcePath: string,
    onProgress?: (p: { stage: 'copy' | 'unzip' | 'done'; percent: number; detail?: string }) => void,
  ): Promise<Project> {
    await this.ensureRoots();
    const resolved = path.resolve(sourcePath);
    const st = await fs.stat(resolved).catch(() => null);
    if (!st?.isFile()) {
      throw new Error('Файл проекта не найден');
    }
    if (!isProjectZipFileName(resolved)) {
      throw new Error('Ожидается файл проекта с расширением .ttrpg.zip или .dnd.zip');
    }

    const root = getProjectsRootDir();
    const rootNorm = path.normalize(root).toLowerCase();
    const dirNorm = path.normalize(path.dirname(resolved)).toLowerCase();
    const baseName = path.basename(resolved);

    let destPath: string;
    let destFileName: string;

    if (dirNorm === rootNorm && isProjectZipFileName(baseName)) {
      destPath = resolved;
      destFileName = baseName;
    } else {
      destFileName = await uniqueProjectZipFileNameInRoot(root, baseName);
      destPath = path.join(root, destFileName);
      if (onProgress) onProgress({ stage: 'copy', percent: 1, detail: 'Копирование…' });
      await copyFileWithProgress(resolved, destPath, (pct) => {
        if (!onProgress) return;
        // Copy is ~70% of the operation; unzip/open happens after.
        onProgress({ stage: 'copy', percent: Math.max(1, Math.min(70, pct)), detail: 'Копирование…' });
      });
    }

    let project = await readProjectJsonFromZip(destPath);
    project = normalizeProject(project);

    const entries = await this.listProjects();
    const othersWithSameId = entries.filter((e) => e.id === project.id && e.fileName !== destFileName);
    if (othersWithSameId.length > 0) {
      const newId = asProjectId(this.randomId());
      const stem = stripProjectZipExtension(destFileName);
      project = {
        ...project,
        id: newId,
        meta: {
          ...project.meta,
          fileBaseName: project.meta.fileBaseName.trim().length ? project.meta.fileBaseName : stem,
        },
      };
      await rewriteProjectJsonInZip(destPath, project);
    }

    this.projectSession += 1;
    const opened = await this.openProjectByIdWithProgress(project.id, (pct) => {
      if (onProgress) onProgress({ stage: 'unzip', percent: pct, detail: 'Распаковка…' });
    });
    if (onProgress) onProgress({ stage: 'done', percent: 100, detail: 'Готово' });
    return opened;
  }

  /**
   * Импорт мира/модуля Foundry VTT (папка или .zip/.fvtt) в новый проект.
   * Создаёт `.ttrpg.zip`, открывает проект и возвращает его.
   */
  async importProjectFromFoundry(
    sourcePath: string,
    onProgress?: (p: FoundryImportProgress) => void,
  ): Promise<Project> {
    await this.ensureRoots();
    const loaded = await loadFoundryDocumentsForImport(sourcePath, onProgress);
    try {
      this.projectSession += 1;
      const projectId = asProjectId(this.randomId());
      const cacheDir = path.join(getProjectsCacheRootDir(), projectId);
      await fs.rm(cacheDir, { recursive: true, force: true });
      await fs.mkdir(path.join(cacheDir, 'assets'), { recursive: true });

      const { project } = await buildProjectFromFoundryDocuments(
        loaded.manifest,
        loaded.docs,
        cacheDir,
        onProgress,
        { projectId },
      );

      const zipPath = path.join(getProjectsRootDir(), projectZipFileNameFromBase(project.meta.fileBaseName));
      const projectPath = path.join(cacheDir, 'project.json');
      this.openProject = {
        id: project.id,
        zipPath,
        cacheDir,
        projectPath,
        project,
      };
      await this.writeCacheProject(cacheDir, project);
      onProgress?.({ stage: 'zip', percent: 90, detail: 'Сборка проекта…' });
      await this.enqueuePack(cacheDir, zipPath);
      onProgress?.({ stage: 'done', percent: 100, detail: 'Готово' });
      return this.openProject.project;
    } finally {
      await loaded.cleanup();
    }
  }

  /** Копия файла проекта в указанный путь (полный путь к `.dnd.zip`). */
  async exportProjectZipToPath(
    projectId: ProjectId,
    destinationPath: string,
    onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
  ): Promise<void> {
    await this.ensureRoots();
    // If exporting the currently open project, make sure pending debounced pack is flushed.
    if (this.openProject?.id === projectId) {
      await this.saveNow();
    }
    const list = await this.listProjects();
    const entry = list.find((p) => p.id === projectId);
    if (!entry) {
      throw new Error('Проект не найден');
    }
    const src = path.join(getProjectsRootDir(), entry.fileName);
    const dest = path.resolve(destinationPath);
    await fs.mkdir(path.dirname(dest), { recursive: true });
    if (onProgress) onProgress({ stage: 'copy', percent: 1, detail: 'Копирование…' });
    await copyFileWithProgress(src, dest, (pct) => {
      if (onProgress) onProgress({ stage: 'copy', percent: pct, detail: 'Копирование…' });
    });
    if (onProgress) onProgress({ stage: 'done', percent: 100, detail: 'Готово' });
  }

  /** Удаляет архив проекта и кэш распаковки с диска. Если проект открыт — сбрасывает сессию. */
  async deleteProjectById(projectId: ProjectId): Promise<void> {
    await this.ensureRoots();
    const list = await this.listProjects();
    const entry = list.find((p) => p.id === projectId);
    if (!entry) {
      throw new Error('Проект не найден');
    }
    const zipPath = path.join(getProjectsRootDir(), entry.fileName);
    const cacheDir = path.join(getProjectsCacheRootDir(), projectId);

    if (this.openProject?.id === projectId) {
      await this.drainSavePipeline();
      this.saveQueued = false;
      this.openProject = null;
      this.projectSession += 1;
    }

    await rmWithRetries(fs.rm, zipPath, { force: true });
    await rmWithRetries(fs.rm, cacheDir, { recursive: true, force: true });

    // Если проект подтянулся миграцией из legacy userData (другое имя приложения),
    // то после удаления из текущей папки он может снова появиться при следующем ensureRoots().
    // Поэтому удаляем и legacy-копии архива.
    for (const legacyRoot of getLegacyProjectsRootDirs()) {
      const legacyZipPath = path.join(legacyRoot, entry.fileName);
      try {
        await rmWithRetries(fs.rm, legacyZipPath, { force: true });
      } catch {
        /* ignore */
      }
    }
  }

  /** Снимок проекта для экспорта/списка линий (распаковывает во временный кэш при необходимости). */
  private async loadProjectSnapshot(
    projectId: ProjectId,
  ): Promise<{ project: Project; cacheDir: string; ownsCache: boolean }> {
    await this.drainSavePipeline();
    if (this.openProject?.id === projectId) {
      await this.saveNow();
      return {
        project: structuredClone(this.openProject.project),
        cacheDir: this.openProject.cacheDir,
        ownsCache: false,
      };
    }
    const list = await this.listProjects();
    const entry = list.find((p) => p.id === projectId);
    if (!entry) throw new Error('Проект не найден');
    const zipPath = path.join(getProjectsRootDir(), entry.fileName);
    const project = normalizeProject(await readProjectJsonFromZip(zipPath));
    const cacheDir = path.join(tmpdir(), `dnd_snap_${crypto.randomBytes(8).toString('hex')}`);
    await unzipToDir(zipPath, cacheDir);
    return { project, cacheDir, ownsCache: true };
  }

  async getProjectStorylines(
    projectId: ProjectId,
    labels: StorylineLabels,
  ): Promise<{ storylines: StorylineListItem[]; npcs: { id: string; name: string }[] }> {
    const snap = await this.loadProjectSnapshot(projectId);
    try {
      return {
        storylines: listExportableStorylines(snap.project, labels),
        npcs: (snap.project.npcs ?? []).map((n) => ({ id: n.id, name: n.name })),
      };
    } finally {
      if (snap.ownsCache) {
        await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined);
      }
    }
  }

  async exportStorylinesZipToPath(
    projectId: ProjectId,
    selections: StorylineSelection[],
    npcIds: string[],
    destinationPath: string,
    labels: StorylineLabels,
    onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void,
    packAppTokens?: (tokenIds: string[], exportRoot: string) => Promise<void>,
  ): Promise<void> {
    if (selections.length === 0) throw new Error('Не выбрана ни одна сюжетная линия');
    await this.ensureRoots();
    const snap = await this.loadProjectSnapshot(projectId);
    const exportCache = path.join(tmpdir(), `dnd_pexport_${crypto.randomBytes(8).toString('hex')}`);
    try {
      const list = await this.listProjects();
      const entry = list.find((p) => p.id === projectId);
      const partial = buildPartialExportProject(snap.project, selections, {
        newProjectId: newExportBundleProjectId(),
        exportTitle: entry?.name ?? snap.project.meta.name,
        labels,
        npcIds,
      });
      await fs.mkdir(path.join(exportCache, 'assets'), { recursive: true });
      const assetIds = Object.keys(partial.assets) as AssetId[];
      for (let i = 0; i < assetIds.length; i += 1) {
        const id = assetIds[i]!;
        const a = partial.assets[id];
        if (!a) continue;
        const srcAbs = path.join(snap.cacheDir, a.relPath);
        const destAbs = path.join(exportCache, a.relPath);
        await fs.mkdir(path.dirname(destAbs), { recursive: true });
        await fs.copyFile(srcAbs, destAbs);
        if (onProgress) {
          const pct = Math.round(((i + 1) / Math.max(1, assetIds.length)) * 75);
          onProgress({ stage: 'zip', percent: pct, detail: 'Сборка архива…' });
        }
      }
      if (packAppTokens) {
        const tokenIds = collectTokenIdsFromProject(partial);
        await packAppTokens(tokenIds, exportCache);
      }
      const now = new Date().toISOString();
      const toWrite: Project = {
        ...partial,
        meta: { ...partial.meta, updatedAt: now, appVersion: getAppSemanticVersion() },
      };
      await atomicWriteFile(path.join(exportCache, 'project.json'), JSON.stringify(toWrite, null, 2));
      const tmpZip = `${path.resolve(destinationPath)}.tmp`;
      if (onProgress) onProgress({ stage: 'zip', percent: 90, detail: 'Сборка архива…' });
      await zipDir(exportCache, tmpZip);
      await replaceFileAtomic(tmpZip, path.resolve(destinationPath));
      if (onProgress) onProgress({ stage: 'done', percent: 100, detail: 'Готово' });
    } finally {
      await fs.rm(exportCache, { recursive: true, force: true }).catch(() => undefined);
      if (snap.ownsCache) {
        await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined);
      }
    }
  }

  async readExternalProjectForImport(sourcePath: string): Promise<Project> {
    const resolved = path.resolve(sourcePath);
    const st = await fs.stat(resolved).catch(() => null);
    if (!st?.isFile()) throw new Error('Файл проекта не найден');
    if (!isProjectZipFileName(resolved)) {
      throw new Error('Ожидается файл проекта с расширением .ttrpg.zip или .dnd.zip');
    }
    return normalizeProject(await readProjectJsonFromZip(resolved));
  }

  async peekImportFromProjectId(
    sourceProjectId: ProjectId,
    labels: StorylineLabels,
    targetHasMainStart: boolean,
  ): Promise<{
    sourceProjectId: ProjectId;
    projectName: string;
    storylines: StorylineListItem[];
    sourceProject: Project;
  }> {
    const snap = await this.loadProjectSnapshot(sourceProjectId);
    try {
      return {
        sourceProjectId,
        projectName: snap.project.meta.name,
        storylines: listExportableStorylines(snap.project, labels).map((item) => {
          if (item.selection.kind === 'main' && targetHasMainStart) {
            return { ...item, disabled: true, disabledReason: 'main_exists' };
          }
          return item;
        }),
        sourceProject: structuredClone(snap.project),
      };
    } finally {
      if (snap.ownsCache) {
        await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined);
      }
    }
  }

  async peekImportFromZipPath(
    sourcePath: string,
    labels: StorylineLabels,
    targetHasMainStart: boolean,
  ): Promise<{
    filePath: string;
    projectName: string;
    storylines: StorylineListItem[];
    sourceProject: Project;
  }> {
    const project = await this.readExternalProjectForImport(sourcePath);
    return {
      filePath: path.resolve(sourcePath),
      projectName: project.meta.name,
      storylines: listImportableStorylines(project, labels, targetHasMainStart),
      sourceProject: project,
    };
  }

  private assertStorylineMergeAllowed(selections: StorylineSelection[]): void {
    if (!this.openProject) throw new Error('Нет открытого проекта');
    if (selections.length === 0) throw new Error('Не выбрана ни одна сюжетная линия');
    if (
      selections.some((s) => s.kind === 'main') &&
      this.openProject.project.sceneGraphNodes.some((n) => n.isStartScene)
    ) {
      throw new Error('В проекте уже есть основная сюжетная линия');
    }
  }

  private async mergeStorylinesFromSourceCache(
    source: Project,
    sourceCache: string,
    selections: StorylineSelection[],
    sceneResolutions: SceneImportResolution[],
    onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
    npcResolutions?: NpcImportResolution[],
    importAppTokens?: (sourceCacheDir: string) => Promise<Map<string, string>>,
  ): Promise<{ project: Project; report: StorylineImportMergeReport }> {
    if (!this.openProject) throw new Error('Нет открытого проекта');
    let sourceForMerge = source;
    if (importAppTokens) {
      const remap = await importAppTokens(sourceCache);
      sourceForMerge = remapProjectSceneTokenIds(source, remap);
    }
    const offsetX = computeGraphImportOffsetX(this.openProject.project);
    const {
      project: merged,
      report,
      assetCopies,
    } = mergeStorylinesIntoProject(this.openProject.project, sourceForMerge, selections, sceneResolutions, {
      graphOffsetX: offsetX,
      ...(npcResolutions ? { npcResolutions } : {}),
    });

    const targetCache = this.openProject.cacheDir;
    await fs.mkdir(path.join(targetCache, 'assets'), { recursive: true });
    for (let i = 0; i < assetCopies.length; i += 1) {
      const { fromId, toId } = assetCopies[i]!;
      const srcAsset = source.assets[fromId];
      const tgtMeta = merged.assets[toId];
      if (!srcAsset || !tgtMeta) continue;
      const srcAbs = path.join(sourceCache, srcAsset.relPath);
      const destAbs = path.join(targetCache, tgtMeta.relPath);
      await fs.mkdir(path.dirname(destAbs), { recursive: true });
      await fs.copyFile(srcAbs, destAbs);
      if (onProgress) {
        const pct = Math.round(((i + 1) / Math.max(1, assetCopies.length)) * 90);
        onProgress({ stage: 'copy', percent: pct, detail: 'Копирование материалов…' });
      }
    }

    const normalized = normalizeProject(merged);
    const reconciled = await reconcileAssetFiles(this.openProject.project, normalized, targetCache);
    await this.updateProject(() => reconciled);
    const latest = this.getOpenProject();
    if (!latest) throw new Error('No open project');
    if (onProgress) onProgress({ stage: 'done', percent: 100, detail: 'Готово' });
    return { project: latest, report };
  }

  async mergeStorylinesFromProjectId(
    sourceProjectId: ProjectId,
    selections: StorylineSelection[],
    sceneResolutions: SceneImportResolution[],
    onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
    npcResolutions?: NpcImportResolution[],
    importAppTokens?: (sourceCacheDir: string) => Promise<Map<string, string>>,
  ): Promise<{ project: Project; report: StorylineImportMergeReport }> {
    this.assertStorylineMergeAllowed(selections);
    const snap = await this.loadProjectSnapshot(sourceProjectId);
    try {
      return await this.mergeStorylinesFromSourceCache(
        snap.project,
        snap.cacheDir,
        selections,
        sceneResolutions,
        onProgress,
        npcResolutions,
        importAppTokens,
      );
    } finally {
      if (snap.ownsCache) {
        await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined);
      }
    }
  }

  async mergeStorylinesFromExternalZip(
    sourcePath: string,
    selections: StorylineSelection[],
    sceneResolutions: SceneImportResolution[],
    onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
    npcResolutions?: NpcImportResolution[],
    importAppTokens?: (sourceCacheDir: string) => Promise<Map<string, string>>,
  ): Promise<{ project: Project; report: StorylineImportMergeReport }> {
    this.assertStorylineMergeAllowed(selections);
    const source = await this.readExternalProjectForImport(sourcePath);
    const sourceCache = path.join(tmpdir(), `dnd_mimport_${crypto.randomBytes(8).toString('hex')}`);
    await unzipToDir(path.resolve(sourcePath), sourceCache);

    try {
      return await this.mergeStorylinesFromSourceCache(
        source,
        sourceCache,
        selections,
        sceneResolutions,
        onProgress,
        npcResolutions,
        importAppTokens,
      );
    } finally {
      await fs.rm(sourceCache, { recursive: true, force: true }).catch(() => undefined);
    }
  }

  private randomId(): string {
    return crypto.randomBytes(16).toString('hex');
  }

  // id is stored in project.json inside the zip
}

function recomputeOutgoing(nodes: SceneGraphNode[], edges: SceneGraphEdge[]): Map<SceneId, Set<SceneId>> {
  const gnMap = new Map(nodes.map((n) => [n.id, n]));
  const outgoing = new Map<SceneId, Set<SceneId>>();
  for (const e of edges) {
    const a = gnMap.get(e.sourceGraphNodeId);
    const b = gnMap.get(e.targetGraphNodeId);
    if (!a || !b) continue;
    if (a.sceneId === b.sceneId) continue;
    let set = outgoing.get(a.sceneId);
    if (!set) {
      set = new Set();
      outgoing.set(a.sceneId, set);
    }
    set.add(b.sceneId);
  }
  return outgoing;
}

function applyConnectionSets(
  scenes: Record<SceneId, Scene>,
  outgoing: Map<SceneId, Set<SceneId>>,
): Record<SceneId, Scene> {
  const next: Record<SceneId, Scene> = { ...scenes };
  for (const sid of Object.keys(next) as SceneId[]) {
    const prev = next[sid];
    if (prev === undefined) continue;
    const c = outgoing.get(sid);
    next[sid] = { ...prev, connections: c ? [...c] : [] };
  }
  return next;
}

function migrateSceneGraphFromLegacy(scenes: Record<SceneId, Scene>): {
  sceneGraphNodes: SceneGraphNode[];
  sceneGraphEdges: SceneGraphEdge[];
} {
  const sceneList = Object.values(scenes);
  const sceneGraphNodes: SceneGraphNode[] = sceneList.map((s) => ({
    id: asGraphNodeId(`gn_legacy_${s.id}`),
    sceneId: s.id,
    x: s.layout.x,
    y: s.layout.y,
    isStartScene: false,
    isSideStoryStart: false,
    sideStoryLineTitle: '',
  }));
  const byScene = new Map(sceneGraphNodes.map((n) => [n.sceneId, n]));
  const sceneGraphEdges: SceneGraphEdge[] = [];
  for (const s of sceneList) {
    const srcGn = byScene.get(s.id);
    if (!srcGn) continue;
    for (const to of s.connections) {
      const tgtGn = byScene.get(to);
      if (!tgtGn) continue;
      if (srcGn.sceneId === tgtGn.sceneId) continue;
      sceneGraphEdges.push({
        id: `e_legacy_${String(srcGn.id)}_${String(tgtGn.id)}`,
        sourceGraphNodeId: srcGn.id,
        targetGraphNodeId: tgtGn.id,
      });
    }
  }
  return { sceneGraphNodes, sceneGraphEdges };
}

/** Один флаг `isStartScene` на весь проект; лишние true сбрасываются. */
function normalizeSceneGraphNodeFlags(nodes: SceneGraphNode[]): SceneGraphNode[] {
  const withDefaults = nodes.map((n) => {
    const raw = n as unknown as {
      isStartScene?: boolean;
      isSideStoryStart?: boolean;
      sideStoryLineTitle?: string;
    };
    const isStartScene = raw.isStartScene === true;
    const isSideStoryStart = raw.isSideStoryStart === true && !isStartScene;
    return {
      ...n,
      isStartScene,
      isSideStoryStart,
      sideStoryLineTitle: isSideStoryStart ? (raw.sideStoryLineTitle ?? '').trim() : '',
    };
  });
  const starters = withDefaults.filter((n) => n.isStartScene);
  if (starters.length <= 1) {
    return withDefaults;
  }
  const keep = starters[0];
  if (!keep) return withDefaults;
  const keepId = keep.id;
  return withDefaults.map((n) => ({ ...n, isStartScene: n.id === keepId }));
}

function normalizeScene(s: Scene): Scene {
  const raw = s.media as unknown as { videos?: AssetId[]; audios?: unknown[] };
  const layoutIn = (s as { layout?: Scene['layout'] }).layout;
  const rot = (s as unknown as { previewRotationDeg?: number }).previewRotationDeg;
  const previewRotationDeg: Scene['previewRotationDeg'] = rot === 90 || rot === 180 || rot === 270 ? rot : 0;
  const legacyPreviewId =
    (s as unknown as { previewImageAssetId?: AssetId | null }).previewImageAssetId ?? null;
  const previewAssetId =
    (s as unknown as { previewAssetId?: AssetId | null }).previewAssetId ?? legacyPreviewId;
  const previewAssetType =
    (s as unknown as { previewAssetType?: 'image' | 'video' | null }).previewAssetType ??
    (legacyPreviewId ? 'image' : null);
  const previewVideoAutostart = Boolean(
    (s as unknown as { previewVideoAutostostart?: boolean; previewVideoAutostart?: boolean })
      .previewVideoAutostart,
  );
  const previewThumbAssetId =
    (s as unknown as { previewThumbAssetId?: AssetId | null }).previewThumbAssetId ?? null;
  const darkenScene = Boolean((s as unknown as { darkenScene?: boolean }).darkenScene);
  const rawTraps = (s as unknown as { traps?: unknown[] }).traps;
  const traps = (Array.isArray(rawTraps) ? rawTraps : [])
    .map((t) => normalizeSceneTrap(t))
    .filter((t): t is SceneTrap => Boolean(t));
  const rawTokens = (s as unknown as { tokens?: unknown[] }).tokens;
  const tokens = (Array.isArray(rawTokens) ? rawTokens : [])
    .map((t) => normalizeSceneToken(t))
    .filter((t): t is SceneToken => Boolean(t));
  const rawNpcTokens = (s as unknown as { npcTokens?: unknown[] }).npcTokens;
  const npcTokens = (Array.isArray(rawNpcTokens) ? rawNpcTokens : [])
    .map((t) => normalizeSceneNpcToken(t))
    .filter((t): t is SceneNpcToken => Boolean(t));
  const grid = normalizeSceneGrid((s as unknown as { grid?: unknown }).grid);

  const rawAudios = Array.isArray(raw.audios) ? raw.audios : [];
  const audios = rawAudios
    .map((a) => {
      if (typeof a === 'string') {
        return {
          assetId: a as AssetId,
          autoplay: Boolean((s.settings as { autoplayAudio?: boolean } | undefined)?.autoplayAudio),
          loop: Boolean((s.settings as { loopAudio?: boolean } | undefined)?.loopAudio),
        };
      }
      if (a && typeof a === 'object') {
        const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean };
        if (!obj.assetId) return null;
        return { assetId: obj.assetId, autoplay: Boolean(obj.autoplay), loop: Boolean(obj.loop) };
      }
      return null;
    })
    .filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x));

  return {
    ...s,
    previewAssetId: previewAssetId ?? null,
    previewAssetType,
    previewThumbAssetId,
    previewVideoAutostart,
    previewRotationDeg,
    darkenScene,
    traps,
    tokens,
    npcTokens,
    grid,
    layout: layoutIn ?? { x: 0, y: 0 },
    media: {
      videos: raw.videos ?? [],
      audios,
    },
  };
}

function normalizeProject(p: Project): Project {
  const scenes: Record<SceneId, Scene> = {};
  for (const sid of Object.keys(p.scenes) as SceneId[]) {
    const rawScene = p.scenes[sid];
    if (!rawScene) continue;
    scenes[sid] = normalizeScene(rawScene);
  }
  const schemaVersion = (p.meta as { schemaVersion?: number }).schemaVersion ?? 1;
  const rawNodes = (p as { sceneGraphNodes?: SceneGraphNode[] }).sceneGraphNodes;
  const rawEdges = (p as { sceneGraphEdges?: SceneGraphEdge[] }).sceneGraphEdges;
  let sceneGraphNodes: SceneGraphNode[] = Array.isArray(rawNodes) ? rawNodes : [];
  let sceneGraphEdges: SceneGraphEdge[] = Array.isArray(rawEdges) ? rawEdges : [];
  const needsLegacyGraphMigration = schemaVersion < PROJECT_SCHEMA_VERSION && sceneGraphNodes.length === 0;
  if (needsLegacyGraphMigration) {
    const migrated = migrateSceneGraphFromLegacy(scenes);
    sceneGraphNodes = migrated.sceneGraphNodes;
    sceneGraphEdges = migrated.sceneGraphEdges;
  }
  sceneGraphNodes = normalizeSceneGraphNodeFlags(sceneGraphNodes);
  const currentGraphNodeId = (p as { currentGraphNodeId?: GraphNodeId | null }).currentGraphNodeId ?? null;
  const rawCampaignAudios = (p as unknown as { campaignAudios?: unknown[] }).campaignAudios;
  const campaignAudios = (Array.isArray(rawCampaignAudios) ? rawCampaignAudios : [])
    .map((a) => {
      if (typeof a === 'string') return { assetId: a as AssetId, autoplay: false, loop: false };
      if (a && typeof a === 'object') {
        const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean };
        if (!obj.assetId) return null;
        return { assetId: obj.assetId, autoplay: Boolean(obj.autoplay), loop: Boolean(obj.loop) };
      }
      return null;
    })
    .filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x));
  const rawMaterials = (p as unknown as { materials?: unknown[] }).materials;
  const materials = (Array.isArray(rawMaterials) ? rawMaterials : [])
    .map((m) => {
      if (!m || typeof m !== 'object') return null;
      const obj = m as {
        id?: string;
        name?: string;
        assetId?: AssetId;
        rotationDeg?: number;
        legend?: unknown;
      };
      if (!obj.id || !obj.assetId || typeof obj.name !== 'string') return null;
      const name = obj.name.trim();
      if (!name) return null;
      const rot = obj.rotationDeg;
      const rotationDeg: 0 | 90 | 180 | 270 = rot === 90 || rot === 180 || rot === 270 ? rot : 0;
      const legend = normalizeMaterialLegend(obj.legend);
      return {
        id: asMaterialId(String(obj.id)),
        name,
        assetId: obj.assetId,
        rotationDeg,
        ...(legend ? { legend } : {}),
      };
    })
    .filter(
      (
        x,
      ): x is {
        id: MaterialId;
        name: string;
        assetId: AssetId;
        rotationDeg: 0 | 90 | 180 | 270;
        legend?: MaterialLegend;
      } => Boolean(x),
    );
  const npcGroups = normalizeNpcGroups((p as unknown as { npcGroups?: unknown }).npcGroups);
  const groupIdSet = new Set(npcGroups.map((g) => g.id));
  const rawNpcs = (p as unknown as { npcs?: unknown[] }).npcs;
  const npcs: ProjectNpc[] = (Array.isArray(rawNpcs) ? rawNpcs : [])
    .map((n, index) => {
      if (!n || typeof n !== 'object') return null;
      const obj = n as {
        id?: string;
        name?: string;
        avatarAssetId?: AssetId;
        description?: string;
        x?: number;
        y?: number;
        groupId?: string | null;
        ringColor?: string;
        imageOffset?: unknown;
      };
      if (!obj.id || !obj.avatarAssetId || typeof obj.name !== 'string') return null;
      const name = obj.name.trim();
      if (!name) return null;
      const x = typeof obj.x === 'number' && Number.isFinite(obj.x) ? obj.x : 80 + (index % 4) * 220;
      const y =
        typeof obj.y === 'number' && Number.isFinite(obj.y) ? obj.y : 80 + Math.floor(index / 4) * 200;
      return {
        id: asNpcId(String(obj.id)),
        name,
        avatarAssetId: obj.avatarAssetId,
        description: typeof obj.description === 'string' ? obj.description : '',
        x,
        y,
        groupId: resolveNpcGroupId(obj.groupId, groupIdSet),
        disposition: normalizeNpcDisposition((obj as { disposition?: unknown }).disposition),
        ringColor: npcDispositionRingColor(
          normalizeNpcDisposition((obj as { disposition?: unknown }).disposition),
        ),
        imageOffset: clampPlayerImageOffset(obj.imageOffset),
        imageScale: clampPlayerImageScale((obj as { imageScale?: unknown }).imageScale),
      };
    })
    .filter((x): x is ProjectNpc => Boolean(x));
  const npcIdSet = new Set(npcs.map((n) => n.id));
  const rawNpcRelations = (p as unknown as { npcRelations?: unknown[] }).npcRelations;
  const npcRelations: ProjectNpcRelation[] = (Array.isArray(rawNpcRelations) ? rawNpcRelations : [])
    .map((r) => {
      if (!r || typeof r !== 'object') return null;
      const obj = r as {
        id?: string;
        sourceNpcId?: string;
        targetNpcId?: string;
        /** legacy undirected fields — трактуем как source→target */
        npcAId?: string;
        npcBId?: string;
        label?: string;
      };
      const rawSource = obj.sourceNpcId ?? obj.npcAId;
      const rawTarget = obj.targetNpcId ?? obj.npcBId;
      if (!obj.id || !rawSource || !rawTarget || typeof obj.label !== 'string') return null;
      const label = obj.label.trim();
      if (!label) return null;
      const sourceNpcId = asNpcId(String(rawSource));
      const targetNpcId = asNpcId(String(rawTarget));
      if (sourceNpcId === targetNpcId) return null;
      if (!npcIdSet.has(sourceNpcId) || !npcIdSet.has(targetNpcId)) return null;
      return { id: asNpcRelationId(String(obj.id)), sourceNpcId, targetNpcId, label };
    })
    .filter((x): x is ProjectNpcRelation => Boolean(x));
  const metaRaw = p.meta as unknown as { createdWithAppVersion?: string; appVersion?: string };
  const createdWithAppVersion = (() => {
    const c = metaRaw.createdWithAppVersion?.trim();
    if (c && c.length > 0) return c;
    const a = metaRaw.appVersion?.trim();
    if (a && a.length > 0) return a;
    return '0.0.0';
  })();
  const scenesPruned: Record<SceneId, Scene> = {};
  for (const sid of Object.keys(scenes) as SceneId[]) {
    const sc = scenes[sid];
    if (!sc) continue;
    scenesPruned[sid] = {
      ...sc,
      npcTokens: (sc.npcTokens ?? []).filter((t) => npcIdSet.has(t.npcId)),
    };
  }
  return {
    ...p,
    meta: {
      ...p.meta,
      fileBaseName: (p.meta as unknown as { fileBaseName?: string }).fileBaseName?.trim().length
        ? (p.meta as unknown as { fileBaseName: string }).fileBaseName
        : '',
      createdWithAppVersion,
      schemaVersion: PROJECT_SCHEMA_VERSION,
    },
    scenes: scenesPruned,
    campaignAudios,
    materials,
    npcs,
    npcGroups,
    npcRelations,
    sceneGraphNodes,
    sceneGraphEdges,
    currentGraphNodeId,
    sceneListOrder: reconcileSceneListOrder(
      scenesPruned,
      (p as { sceneListOrder?: SceneId[] }).sceneListOrder,
    ),
  };
}

function sanitizeFileName(name: string): string {
  const trimmed = name.trim();
  const safe = trimmed.replace(/[<>:"/\\|?*]/gu, '_');
  return safe.length > 0 ? safe : 'Untitled';
}

async function uniqueProjectZipFileNameInRoot(root: string, preferredBaseFileName: string): Promise<string> {
  const stem = sanitizeFileName(stripProjectZipExtension(path.basename(preferredBaseFileName)) || 'project');
  let candidate = projectZipFileNameFromBase(stem);
  let n = 0;
  for (;;) {
    try {
      await fs.access(path.join(root, candidate));
      n += 1;
      candidate = projectZipFileNameFromBase(`${stem}_${String(n)}`);
    } catch {
      return candidate;
    }
  }
}

async function copyFileWithProgress(
  src: string,
  dest: string,
  onPercent: (pct: number) => void,
): Promise<void> {
  const st = await fs.stat(src);
  const total = st.size || 0;
  if (total <= 0) {
    await fs.copyFile(src, dest);
    onPercent(100);
    return;
  }
  await fs.mkdir(path.dirname(dest), { recursive: true });
  await new Promise<void>((resolve, reject) => {
    let done = 0;
    const rs = fssync.createReadStream(src);
    const ws = fssync.createWriteStream(dest);
    const onErr = (e: unknown) => reject(e instanceof Error ? e : new Error(String(e)));
    rs.on('error', onErr);
    ws.on('error', onErr);
    rs.on('data', (chunk: Buffer) => {
      done += chunk.length;
      const pct = Math.round((done / total) * 100);
      try {
        onPercent(Math.max(0, Math.min(100, pct)));
      } catch {
        // ignore
      }
    });
    ws.on('close', () => resolve());
    rs.pipe(ws);
  });
}

type MediaKind = { type: MediaAssetType; mime: string };

function classifyMediaPath(filePath: string): MediaKind | null {
  const ext = path.extname(filePath).toLowerCase();
  switch (ext) {
    case '.png':
      return { type: 'image', mime: 'image/png' };
    case '.jpg':
    case '.jpeg':
      return { type: 'image', mime: 'image/jpeg' };
    case '.webp':
      return { type: 'image', mime: 'image/webp' };
    case '.gif':
      return { type: 'image', mime: 'image/gif' };
    case '.bmp':
      return { type: 'image', mime: 'image/bmp' };
    case '.mp4':
      return { type: 'video', mime: 'video/mp4' };
    case '.webm':
      return { type: 'video', mime: 'video/webm' };
    case '.mov':
      return { type: 'video', mime: 'video/quicktime' };
    case '.mp3':
      return { type: 'audio', mime: 'audio/mpeg' };
    case '.wav':
      return { type: 'audio', mime: 'audio/wav' };
    case '.ogg':
      return { type: 'audio', mime: 'audio/ogg' };
    case '.m4a':
      return { type: 'audio', mime: 'audio/mp4' };
    case '.aac':
      return { type: 'audio', mime: 'audio/aac' };
    default:
      return null;
  }
}

function buildMediaAsset(
  id: AssetId,
  kind: MediaKind,
  originalName: string,
  relPath: string,
  sha256: string,
  sizeBytes: number,
): MediaAsset {
  const createdAt = new Date().toISOString();
  const base = {
    id,
    mime: kind.mime,
    originalName,
    relPath,
    sha256,
    sizeBytes,
    createdAt,
  };
  if (kind.type === 'image') return { ...base, type: 'image' };
  if (kind.type === 'video') return { ...base, type: 'video' };
  return { ...base, type: 'audio' };
}

async function atomicWriteFile(filePath: string, contents: string): Promise<void> {
  const dir = path.dirname(filePath);
  await fs.mkdir(dir, { recursive: true });
  const tmp = path.join(dir, `.tmp_${path.basename(filePath)}_${crypto.randomBytes(8).toString('hex')}`);
  await fs.writeFile(tmp, contents, 'utf8');
  await replaceFileAtomic(tmp, filePath);
}

/** Уже сжатые контейнеры/кодеки — в ZIP кладём без deflate, качество не трогаем; project.json и сырьё — deflate 9. */
function zipOptionsForRelativeEntry(rel: string): { compressionLevel: number } {
  const norm = rel.replace(/\\/gu, '/').toLowerCase();
  if (norm === 'project.json') {
    return { compressionLevel: 9 };
  }
  if (norm.startsWith('assets/')) {
    const ext = path.extname(norm).toLowerCase();
    if (
      ext === '.jpg' ||
      ext === '.jpeg' ||
      ext === '.mp4' ||
      ext === '.webm' ||
      ext === '.mov' ||
      ext === '.mp3' ||
      ext === '.m4a' ||
      ext === '.aac' ||
      ext === '.ogg' ||
      ext === '.webp' ||
      ext === '.gif'
    ) {
      return { compressionLevel: 0 };
    }
  }
  return { compressionLevel: 9 };
}

async function zipDir(srcDir: string, outZipPath: string): Promise<void> {
  const zipfile = new ZipFile();
  const all = await listFilesRecursive(srcDir);
  for (const abs of all) {
    const rel = path.relative(srcDir, abs).replace(/\\/gu, '/');
    // Bufferize: yazl.addFile opens lazily and can ENOENT if reconcile unlinks mid-pack.
    let buf: Buffer;
    try {
      buf = await fs.readFile(abs);
    } catch (e) {
      const err = e as NodeJS.ErrnoException;
      if (err?.code === 'ENOENT') continue;
      throw e;
    }
    zipfile.addBuffer(buf, rel, zipOptionsForRelativeEntry(rel));
  }
  await fs.mkdir(path.dirname(outZipPath), { recursive: true });
  const out = fssync.createWriteStream(outZipPath);
  const done = new Promise<void>((resolve, reject) => {
    out.on('close', resolve);
    out.on('error', reject);
  });
  zipfile.outputStream.pipe(out);
  zipfile.end();
  await done;
}

async function rewriteProjectJsonInZip(zipPath: string, project: Project): Promise<void> {
  const work = path.join(tmpdir(), `dnd_rewrite_${crypto.randomBytes(8).toString('hex')}`);
  await fs.mkdir(work, { recursive: true });
  try {
    await unzipToDir(zipPath, work);
    await atomicWriteFile(path.join(work, 'project.json'), JSON.stringify(project, null, 2));
    const outTmp = `${zipPath}.rewrite.tmp`;
    await zipDir(work, outTmp);
    await replaceFileAtomic(outTmp, zipPath);
  } finally {
    await fs.rm(work, { recursive: true, force: true });
  }
}

async function listFilesRecursive(dir: string): Promise<string[]> {
  const entries = await fs.readdir(dir, { withFileTypes: true });
  const out: string[] = [];
  for (const e of entries) {
    if (e.name.startsWith('.tmp_')) {
      continue;
    }
    const abs = path.join(dir, e.name);
    if (e.isDirectory()) {
      out.push(...(await listFilesRecursive(abs)));
    } else if (e.isFile()) {
      out.push(abs);
    }
  }
  return out;
}
