import React, { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';

import { moveSceneInListOrder, reconcileSceneListOrder } from '../../shared/graph/sceneListOrder';
import type {
  NpcImportResolution,
  SceneImportResolution,
  StorylineImportMergeReport,
  StorylineSelection,
} from '../../shared/graph/storylineExportImport';
import {
  ipcChannels,
  type UpdaterCheckResponse,
  type UpdaterProgressEvent,
} from '../../shared/ipc/contracts';
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
import { PROJECT_ZIP_EXTENSION } from '../../shared/project/projectZipExtension';
import type {
  AssetId,
  GraphNodeId,
  MaterialId,
  MediaAsset,
  Project,
  ProjectId,
  ProjectMaterial,
  SceneAudioRef,
  SceneId,
} from '../../shared/types';
import { AppLogo } from '../shared/branding/AppLogo';
import { getDndApi } from '../shared/dndApi';
import { RotatedImage } from '../shared/RotatedImage';
import { Button, Input } from '../shared/ui/controls';
import { LayoutShell } from '../shared/ui/LayoutShell';
import { useAssetUrl } from '../shared/useAssetImageUrl';

import { AppAboutModal, InstructionsModal } from './about/EditorAboutModals';
import styles from './EditorApp.module.css';
import {
  filterAudioFilePaths,
  partitionSceneMediaDrops,
  pickFirstPreviewFilePath,
  sceneTitleFromMediaPath,
  useFileDropZone,
} from './fileDrop';
import { FoundryImportModal } from './FoundryImportModal';
import { buildNextSceneCardById } from './graph/sceneCardById';
import {
  DND_SCENE_ID_MIME,
  SceneGraph,
  type SceneGraphSceneCard,
  type SceneGraphUiStrings,
} from './graph/SceneGraph';
import type { HelpSectionId } from './help/helpSections';
import { useEditorI18n } from './i18n/EditorI18nContext';
import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals';
import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals';
import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml';
import { SceneDescriptionModal } from './SceneDescriptionModal';
import type { ProjectNoticeCode } from './state/projectState';
import { useProjectState } from './state/projectState';
import {
  buildNpcResolutionsForImport,
  buildSceneResolutionsForImport,
  computeImportConflicts,
  computeNpcImportConflicts,
  ExportProjectModal,
  ImportReportModal,
  ImportSourceModal,
  ImportStorylinesModal,
  NpcConflictModal,
  SceneConflictModal,
  useStorylineLabels,
  type ImportPeekResult,
  type ImportSourceSelection,
} from './StorylineTransferModals';

type SceneCard = {
  id: SceneId;
  title: string;
  active: boolean;
  previewAssetId: AssetId | null;
  previewThumbAssetId: AssetId | null;
  previewAssetType: 'image' | 'video' | null;
  previewVideoAutostart: boolean;
  previewRotationDeg: 0 | 90 | 180 | 270;
};

/** Лёгкая карта сцен для графа: стабильные ссылки на объекты, пока не меняются поля карточки. */
function useStableSceneCardById(project: Project | null): Record<SceneId, SceneGraphSceneCard> {
  const recordRef = useRef<Record<SceneId, SceneGraphSceneCard>>({});
  const projectIdRef = useRef<ProjectId | null>(null);

  /* Ref cache: avoid new Record / per-scene object identities when only irrelevant Scene fields change
   * (e.g. description). react-hooks/refs disallows ref access during render; this is intentional. */
  /* eslint-disable react-hooks/refs -- stable graph input identity */
  return useMemo(() => {
    if (!project) {
      recordRef.current = {};
      projectIdRef.current = null;
      return {};
    }
    if (projectIdRef.current !== project.id) {
      recordRef.current = {};
      projectIdRef.current = project.id;
    }

    const prevRecord = recordRef.current;
    const nextMap = buildNextSceneCardById(prevRecord, project);
    recordRef.current = nextMap;
    return nextMap;
  }, [project]);
  /* eslint-enable react-hooks/refs */
}

export function EditorApp() {
  const { t, locale, setLocale } = useEditorI18n();
  const [appVersionText, setAppVersionText] = useState<string | null>(null);
  const [query, setQuery] = useState('');
  const [fileMenuOpen, setFileMenuOpen] = useState(false);
  const [projectMenuOpen, setProjectMenuOpen] = useState(false);
  const [settingsMenuOpen, setSettingsMenuOpen] = useState(false);
  const [settingsLangSubOpen, setSettingsLangSubOpen] = useState(false);
  const [aboutMenuOpen, setAboutMenuOpen] = useState(false);
  const [appAboutOpen, setAppAboutOpen] = useState(false);
  const [instructionsOpen, setInstructionsOpen] = useState(false);
  const [instructionsSection, setInstructionsSection] = useState<HelpSectionId>('overview');
  const [renameOpen, setRenameOpen] = useState(false);
  const [exportModalOpen, setExportModalOpen] = useState(false);
  const [importSourceOpen, setImportSourceOpen] = useState(false);
  const [foundryImportOpen, setFoundryImportOpen] = useState(false);
  const [importPeek, setImportPeek] = useState<ImportPeekResult | null>(null);
  const [importStorylinesOpen, setImportStorylinesOpen] = useState(false);
  const [importConflictsOpen, setImportConflictsOpen] = useState(false);
  const [importConflicts, setImportConflicts] = useState<ReturnType<typeof computeImportConflicts>>([]);
  const [importNpcConflictsOpen, setImportNpcConflictsOpen] = useState(false);
  const [importNpcConflicts, setImportNpcConflicts] = useState<
    ReturnType<typeof computeNpcImportConflicts>
  >([]);
  const [pendingImportSelections, setPendingImportSelections] = useState<StorylineSelection[]>([]);
  const [pendingSceneResolutions, setPendingSceneResolutions] = useState<SceneImportResolution[]>([]);
  const [importReportOpen, setImportReportOpen] = useState(false);
  const [importReport, setImportReport] = useState<StorylineImportMergeReport | null>(null);
  const [previewDialogSceneId, setPreviewDialogSceneId] = useState<SceneId | null>(null);
  const [presentationOpen, setPresentationOpen] = useState(false);
  const [licenseSnap, setLicenseSnap] = useState<LicenseSnapshot | null>(null);
  const [checkUpdatesOpen, setCheckUpdatesOpen] = useState(false);
  const [appPackaged, setAppPackaged] = useState(false);
  const [licenseKeyModalOpen, setLicenseKeyModalOpen] = useState(false);
  const [eulaModalOpen, setEulaModalOpen] = useState(false);
  const [aboutLicenseOpen, setAboutLicenseOpen] = useState(false);
  const [openKeyAfterEula, setOpenKeyAfterEula] = useState(false);
  const licenseActive = licenseSnap?.active === true;
  const [appNotice, setAppNotice] = useState<{ title?: string; message: string } | null>(null);
  const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false);
  const [materialEdit, setMaterialEdit] = useState<ProjectMaterial | null | 'new'>(null);
  const onProjectNotice = useCallback(
    (code: ProjectNoticeCode) => {
      const handlers: Record<ProjectNoticeCode, () => void> = {
        campaign_audio_empty: () =>
          setAppNotice({ title: t('common.message'), message: t('notice.campaignAudioEmpty') }),
      };
      handlers[code]();
    },
    [t],
  );
  const [state, actions] = useProjectState(licenseActive, { onNotice: onProjectNotice });
  const renameFromPickerRef = useRef(false);
  /** Синхронно на dragStart, чтобы файловый drop колонки не успел сработать. */
  const draggingListSceneIdRef = useRef<SceneId | null>(null);

  const showSceneDropSkippedNotice = useCallback(
    (rejected: { name: string; reason: 'unsupported' | 'no_path' }[]) => {
      if (rejected.length === 0) return;
      const reasonText = (reason: 'unsupported' | 'no_path') =>
        reason === 'no_path' ? t('scenes.dropSkippedNoPath') : t('scenes.dropSkippedUnsupported');
      const lines = rejected.map((r) => `• ${r.name} — ${reasonText(r.reason)}`);
      setAppNotice({
        title: t('scenes.dropSkippedTitle'),
        message: `${t('scenes.dropSkippedIntro')}\n${lines.join('\n')}`,
      });
    },
    [t],
  );

  const onScenesColumnDrop = useCallback(
    (entries: { path: string; name: string }[]) => {
      if (!state.project || state.creatingScene || state.sceneBatchImport) return;
      const { accepted, rejected } = partitionSceneMediaDrops(entries);
      if (accepted.length === 0) {
        showSceneDropSkippedNotice(rejected);
        return;
      }
      void (async () => {
        try {
          await actions.createScenesFromMediaPaths(
            accepted.map((entry) => ({
              filePath: entry.path,
              title: sceneTitleFromMediaPath(entry.path),
            })),
          );
          showSceneDropSkippedNotice(rejected);
        } catch (e) {
          setAppNotice({
            title: t('common.error'),
            message: e instanceof Error ? e.message : String(e),
          });
        }
      })();
    },
    [actions, showSceneDropSkippedNotice, state.creatingScene, state.project, state.sceneBatchImport, t],
  );

  const scenesColumnDrop = useFileDropZone({
    disabled: !state.project || state.creatingScene || state.sceneBatchImport !== null,
    isBlocked: () => draggingListSceneIdRef.current !== null,
    onDropEntries: onScenesColumnDrop,
  });

  const sceneCardById = useStableSceneCardById(state.project);
  const graphUi = useMemo<SceneGraphUiStrings>(
    () => ({
      badgeStart: t('graph.badgeStart'),
      badgeSideStory: t('graph.badgeSideStory'),
      untitled: t('graph.untitled'),
      videoBadge: t('graph.videoBadge'),
      audioBadge: t('graph.audioBadge'),
      loop: t('graph.loop'),
      autoplay: t('graph.autoplay'),
      previewAutostart: t('graph.previewAutostart'),
      videoLoop: t('graph.videoLoop'),
      zoomBar: t('graph.zoomBar'),
      zoomIn: t('graph.zoomIn'),
      zoomOut: t('graph.zoomOut'),
      fitAll: t('graph.fitAll'),
      closeMenu: t('common.closeMenu'),
      startScene: t('graph.startScene'),
      unsetStartScene: t('graph.unsetStartScene'),
      sideStoryStartScene: t('graph.sideStoryStartScene'),
      unsetSideStoryStartScene: t('graph.unsetSideStoryStartScene'),
      runFromScene: t('graph.runFromScene'),
      delete: t('common.delete'),
    }),
    [t],
  );
  const fileMenuBtnRef = useRef<HTMLButtonElement | null>(null);
  const projectMenuBtnRef = useRef<HTMLButtonElement | null>(null);
  const settingsMenuBtnRef = useRef<HTMLButtonElement | null>(null);
  const aboutMenuBtnRef = useRef<HTMLButtonElement | null>(null);
  const [fileMenuPos, setFileMenuPos] = useState<{ left: number; top: number } | null>(null);
  const [projectMenuPos, setProjectMenuPos] = useState<{ left: number; top: number } | null>(null);
  const [settingsMenuPos, setSettingsMenuPos] = useState<{ left: number; top: number } | null>(null);
  const [aboutMenuPos, setAboutMenuPos] = useState<{ left: number; top: number } | null>(null);
  const [selectedGraphNodeId, setSelectedGraphNodeId] = useState<GraphNodeId | null>(null);
  const [sceneListDrop, setSceneListDrop] = useState<{
    targetId: SceneId;
    place: 'before' | 'after';
  } | null>(null);
  const [draggingListSceneId, setDraggingListSceneId] = useState<SceneId | null>(null);
  const sceneListScrollRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    if (!draggingListSceneId) return;
    const onWheel = (e: WheelEvent) => {
      const el = sceneListScrollRef.current;
      if (!el) return;
      // Во время HTML5-drag браузер глотает обычный scroll — крутим вручную.
      e.preventDefault();
      el.scrollTop += e.deltaY;
    };
    window.addEventListener('wheel', onWheel, { passive: false, capture: true });
    return () => window.removeEventListener('wheel', onWheel, { capture: true });
  }, [draggingListSceneId]);

  useEffect(() => {
    setSelectedGraphNodeId(null);
  }, [state.project?.id, state.openingProjectId]);

  useEffect(() => {
    if (!state.project || !selectedGraphNodeId) return;
    if (!state.project.sceneGraphNodes.some((n) => n.id === selectedGraphNodeId)) {
      setSelectedGraphNodeId(null);
    }
  }, [selectedGraphNodeId, state.project?.sceneGraphNodes]);

  const scenes = useMemo<SceneCard[]>(() => {
    const p = state.project;
    if (!p) return [];
    const order = reconcileSceneListOrder(p.scenes, p.sceneListOrder);
    return order
      .map((id) => p.scenes[id])
      .filter((s): s is NonNullable<typeof s> => Boolean(s))
      .map((s) => ({
        id: s.id,
        title: s.title,
        active: s.id === state.selectedSceneId,
        previewAssetId: s.previewAssetId,
        previewThumbAssetId: s.previewThumbAssetId,
        previewAssetType: s.previewAssetType,
        previewVideoAutostart: s.previewVideoAutostart,
        previewRotationDeg: s.previewRotationDeg,
      }));
  }, [state.project, state.selectedSceneId]);

  const sceneListReorderEnabled = query.trim().length === 0;

  const filtered = useMemo(
    () => scenes.filter((s) => s.title.toLowerCase().includes(query.trim().toLowerCase())),
    [query, scenes],
  );

  const sceneMediaAssets = useMemo<MediaAsset[]>(() => {
    const p = state.project;
    const sid = state.selectedSceneId;
    if (!p || !sid) return [];
    const scene = p.scenes[sid];
    if (!scene) return [];
    const ids = [...scene.media.videos, ...scene.media.audios.map((a) => a.assetId)];
    return ids.map((id) => p.assets[id]).filter((a): a is MediaAsset => Boolean(a));
  }, [state.project, state.selectedSceneId]);

  const sceneAudioRefs = useMemo<SceneAudioRef[]>(() => {
    const p = state.project;
    const sid = state.selectedSceneId;
    if (!p || !sid) return [];
    const scene = p.scenes[sid];
    if (!scene) return [];
    return scene.media.audios;
  }, [state.project, state.selectedSceneId]);

  const campaignAudioRefs = useMemo<SceneAudioRef[]>(() => {
    return state.project?.campaignAudios ?? [];
  }, [state.project]);

  const campaignAudioAssets = useMemo<MediaAsset[]>(() => {
    const p = state.project;
    if (!p) return [];
    return campaignAudioRefs.map((r) => p.assets[r.assetId]).filter((a): a is MediaAsset => Boolean(a));
  }, [campaignAudioRefs, state.project]);

  const graphStartGraphNodeId = useMemo(() => {
    const p = state.project;
    if (!p) return null;
    const gn = p.sceneGraphNodes.find((n) => n.isStartScene);
    return gn?.id ?? null;
  }, [state.project]);

  const runDisabled = !licenseActive || !graphStartGraphNodeId;
  const runHelpSection: HelpSectionId = !licenseActive ? 'license' : 'graph';
  const runHelpTooltip = !licenseActive ? t('top.afterLicense') : t('top.setStartScene');

  const launchFromGraphNode = useCallback(
    (graphNodeId: GraphNodeId) => {
      if (!licenseActive) return;
      void (async () => {
        await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId });
        await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {});
      })();
    },
    [licenseActive],
  );

  const currentProjectName = state.project?.meta.name ?? '';
  const currentFileBaseName = state.project?.meta.fileBaseName ?? '';
  const existingProjectNames = useMemo(() => state.projects.map((p) => p.name), [state.projects]);
  const existingFileBaseNames = useMemo(() => {
    return state.projects.map((p) => p.fileName.replace(/\.dnd\.zip$/iu, ''));
  }, [state.projects]);

  useEffect(() => {
    if (!fileMenuOpen) return;
    const r = fileMenuBtnRef.current?.getBoundingClientRect() ?? null;
    queueMicrotask(() => {
      if (r) {
        setFileMenuPos({ left: r.left, top: r.bottom + 10 });
      } else {
        setFileMenuPos(null);
      }
    });
    const onDown = (e: MouseEvent) => {
      const t = e.target as HTMLElement | null;
      if (!t) return;
      if (t.closest('[data-filemenu-root="1"]')) return;
      setFileMenuOpen(false);
    };
    window.addEventListener('mousedown', onDown);
    return () => window.removeEventListener('mousedown', onDown);
  }, [fileMenuOpen]);

  useEffect(() => {
    if (!projectMenuOpen) return;
    const r = projectMenuBtnRef.current?.getBoundingClientRect() ?? null;
    queueMicrotask(() => {
      if (r) {
        setProjectMenuPos({ left: r.left, top: r.bottom + 10 });
      } else {
        setProjectMenuPos(null);
      }
    });
    const onDown = (e: MouseEvent) => {
      const t = e.target as HTMLElement | null;
      if (!t) return;
      if (t.closest('[data-projectmenu-root="1"]')) return;
      setProjectMenuOpen(false);
    };
    window.addEventListener('mousedown', onDown);
    return () => window.removeEventListener('mousedown', onDown);
  }, [projectMenuOpen]);

  useEffect(() => {
    if (!settingsMenuOpen) return;
    const r = settingsMenuBtnRef.current?.getBoundingClientRect() ?? null;
    queueMicrotask(() => {
      if (r) {
        setSettingsMenuPos({ left: r.left, top: r.bottom + 10 });
      } else {
        setSettingsMenuPos(null);
      }
    });
    const onDown = (e: MouseEvent) => {
      const t = e.target as HTMLElement | null;
      if (!t) return;
      if (t.closest('[data-settingsmenu-root="1"]')) return;
      setSettingsMenuOpen(false);
    };
    window.addEventListener('mousedown', onDown);
    return () => window.removeEventListener('mousedown', onDown);
  }, [settingsMenuOpen]);

  useEffect(() => {
    if (!settingsMenuOpen) setSettingsLangSubOpen(false);
  }, [settingsMenuOpen]);

  useEffect(() => {
    if (!aboutMenuOpen) return;
    const r = aboutMenuBtnRef.current?.getBoundingClientRect() ?? null;
    queueMicrotask(() => {
      if (r) {
        setAboutMenuPos({ left: r.left, top: r.bottom + 10 });
      } else {
        setAboutMenuPos(null);
      }
    });
    const onDown = (e: MouseEvent) => {
      const t = e.target as HTMLElement | null;
      if (!t) return;
      if (t.closest('[data-aboutmenu-root="1"]')) return;
      setAboutMenuOpen(false);
    };
    window.addEventListener('mousedown', onDown);
    return () => window.removeEventListener('mousedown', onDown);
  }, [aboutMenuOpen]);

  useEffect(() => {
    let off: (() => void) | null = null;
    void (async () => {
      try {
        const snap = await getDndApi().invoke(ipcChannels.windows.getMultiWindowState, {});
        setPresentationOpen(snap.open);
      } catch {
        // ignore
      }
      off = getDndApi().on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => {
        setPresentationOpen(open);
      });
    })();
    return () => {
      off?.();
    };
  }, []);

  const reloadLicense = useCallback(() => {
    void (async () => {
      try {
        const s = await getDndApi().invoke(ipcChannels.license.getStatus, {});
        setLicenseSnap(s);
      } catch {
        setLicenseSnap(null);
      }
    })();
  }, []);

  useEffect(() => {
    reloadLicense();
    const unsub = getDndApi().on(ipcChannels.license.statusChanged, () => {
      reloadLicense();
    });
    return unsub;
  }, [reloadLicense]);

  useEffect(() => {
    void (async () => {
      try {
        const r = await getDndApi().invoke(ipcChannels.app.getVersion, {});
        const label = r.buildNumber ? `v${r.version} · ${r.buildNumber}` : `v${r.version}`;
        setAppVersionText(label);
        setAppPackaged(r.packaged);
      } catch {
        setAppVersionText(null);
      }
    })();
  }, []);

  const exportModalInitialProjectId = state.project?.id ?? state.projects[0]?.id ?? null;
  const storylineLabels = useStorylineLabels();
  const targetHasMainStart = state.project?.sceneGraphNodes.some((n) => n.isStartScene) ?? false;

  const loadProjectStorylines = useCallback(
    (projectId: ProjectId) => actions.getProjectStorylines(projectId, storylineLabels),
    [actions, storylineLabels],
  );

  const clearImportFlow = useCallback(() => {
    setImportPeek(null);
    setImportStorylinesOpen(false);
    setImportConflictsOpen(false);
    setImportNpcConflictsOpen(false);
    setPendingImportSelections([]);
    setPendingSceneResolutions([]);
    setImportConflicts([]);
    setImportNpcConflicts([]);
  }, []);

  const runStorylineMerge = useCallback(
    async (
      selections: StorylineSelection[],
      sceneResolutions: SceneImportResolution[],
      npcResolutions: NpcImportResolution[],
    ) => {
      if (!importPeek) return;
      const { report } =
        importPeek.kind === 'project' && importPeek.sourceProjectId
          ? await actions.mergeImportFromProject(
              importPeek.sourceProjectId,
              selections,
              sceneResolutions,
              npcResolutions,
            )
          : await actions.mergeImportZip(
              importPeek.filePath!,
              selections,
              sceneResolutions,
              npcResolutions,
            );
      setImportReport(report);
      setImportReportOpen(true);
      clearImportFlow();
    },
    [actions, clearImportFlow, importPeek],
  );

  const continueImportAfterScenes = useCallback(
    (selections: StorylineSelection[], sceneResolutions: SceneImportResolution[]) => {
      if (!importPeek || !state.project) return;
      const npcConflicts = computeNpcImportConflicts(
        state.project,
        importPeek.sourceProject,
        selections,
      );
      setPendingImportSelections(selections);
      setPendingSceneResolutions(sceneResolutions);
      setImportConflictsOpen(false);
      if (npcConflicts.length > 0) {
        setImportNpcConflicts(npcConflicts);
        setImportNpcConflictsOpen(true);
        return;
      }
      const npcResolutions = buildNpcResolutionsForImport(
        state.project,
        importPeek.sourceProject,
        selections,
        [],
        [],
      );
      void runStorylineMerge(selections, sceneResolutions, npcResolutions);
    },
    [importPeek, runStorylineMerge, state.project],
  );

  const handleImportSourceContinue = useCallback(
    async (selection: ImportSourceSelection) => {
      if (!state.project) {
        if (selection.kind === 'file') {
          await actions.importProjectFromPath(selection.filePath);
          setImportSourceOpen(false);
        }
        return;
      }
      const peek =
        selection.kind === 'project'
          ? await actions.peekImportFromProject(
              selection.sourceProjectId,
              storylineLabels,
              targetHasMainStart,
            )
          : await actions.peekImportZipPath(selection.filePath, storylineLabels, targetHasMainStart);
      setImportPeek({
        kind: selection.kind,
        ...(selection.kind === 'file'
          ? { filePath: selection.filePath }
          : { sourceProjectId: selection.sourceProjectId }),
        projectName: peek.projectName,
        storylines: peek.storylines,
        sourceProject: peek.sourceProject,
      });
      setImportSourceOpen(false);
      setImportStorylinesOpen(true);
    },
    [actions, state.project, storylineLabels, targetHasMainStart],
  );

  const handleImportProject = useCallback(() => {
    setProjectMenuOpen(false);
    setImportSourceOpen(true);
  }, []);

  const handleImportFoundry = useCallback(() => {
    setProjectMenuOpen(false);
    setFoundryImportOpen(true);
  }, []);

  const goHome = useCallback(() => {
    setProjectMenuOpen(false);
    setExportModalOpen(false);
    setImportSourceOpen(false);
    setFoundryImportOpen(false);
    setImportStorylinesOpen(false);
    setImportConflictsOpen(false);
    setImportReportOpen(false);
    setImportPeek(null);
    void actions.closeProject();
  }, [actions]);

  const bodyOverlay =
    licenseSnap === null ? (
      <div>
        <div className={styles.licenseBlockTitle}>{t('license.checkingTitle')}</div>
        <div className={styles.muted}>{t('license.checkingWait')}</div>
      </div>
    ) : !licenseSnap.active ? (
      <div>
        <div className={styles.licenseBlockTitle}>{t('license.requiredTitle')}</div>
        <div className={styles.muted}>{t('license.requiredHint')}</div>
      </div>
    ) : undefined;

  return (
    <>
      {presentationOpen
        ? createPortal(
            <div className={styles.editorLockOverlay} role="dialog" aria-label={t('presentation.overlay')}>
              <div className={styles.editorLockModal}>
                <div className={styles.editorLockTitle}>{t('presentation.title')}</div>
                <div className={styles.editorLockText}>{t('presentation.body')}</div>
              </div>
            </div>,
            document.body,
          )
        : null}
      {state.zipProgress
        ? createPortal(
            <div className={styles.progressOverlay} role="dialog" aria-label={t('zip.progress')}>
              <div className={styles.progressModal}>
                <div className={styles.progressTitle}>
                  {state.zipProgress.kind === 'import' ? t('zip.importTitle') : t('zip.exportTitle')}
                </div>
                <div className={styles.progressBar}>
                  <div
                    className={styles.progressFill}
                    style={{ width: `${String(Math.max(0, Math.min(100, state.zipProgress.percent)))}%` }}
                  />
                </div>
                <div className={styles.progressMeta}>
                  <div>{state.zipProgress.detail ?? state.zipProgress.stage}</div>
                  <div>{state.zipProgress.percent}%</div>
                </div>
              </div>
            </div>,
            document.body,
          )
        : null}
      {state.sceneBatchImport
        ? createPortal(
            <div className={styles.editorLockOverlay} role="dialog" aria-label={t('scenes.batchTitle')}>
              <div className={styles.progressModal}>
                <div className={styles.progressTitle}>{t('scenes.batchTitle')}</div>
                <div className={styles.progressBar}>
                  <div
                    className={styles.progressFill}
                    style={{
                      width: `${String(
                        Math.max(
                          0,
                          Math.min(
                            100,
                            Math.round(
                              (state.sceneBatchImport.current / Math.max(1, state.sceneBatchImport.total)) *
                                100,
                            ),
                          ),
                        ),
                      )}%`,
                    }}
                  />
                </div>
                <div className={styles.progressMeta}>
                  <div>
                    {t('scenes.batchProgress')
                      .replace('{current}', String(state.sceneBatchImport.current))
                      .replace('{total}', String(state.sceneBatchImport.total))}
                    {state.sceneBatchImport.fileName
                      ? `: ${state.sceneBatchImport.fileName}`
                      : ''}
                  </div>
                  <div>
                    {Math.round(
                      (state.sceneBatchImport.current / Math.max(1, state.sceneBatchImport.total)) * 100,
                    )}
                    %
                  </div>
                </div>
              </div>
            </div>,
            document.body,
          )
        : null}
      <LayoutShell
        bodyOverlay={bodyOverlay}
        topBar={
          <div className={styles.topBarRow}>
            <button
              type="button"
              className={styles.brandButton}
              onClick={() => {
                goHome();
              }}
              title={t('top.backToProjects')}
            >
              <AppLogo className={styles.brandLogo} size={26} />
              <div className={styles.brandTitle}>{t('app.brandTitle')}</div>
            </button>
            <div className={styles.fileToolbar}>
              <button
                ref={settingsMenuBtnRef}
                type="button"
                data-settingsmenu-root="1"
                className={styles.fileMenuTrigger}
                onClick={() => {
                  setFileMenuOpen(false);
                  setProjectMenuOpen(false);
                  setAboutMenuOpen(false);
                  setSettingsMenuOpen((v) => {
                    const next = !v;
                    if (next) setSettingsLangSubOpen(false);
                    return next;
                  });
                }}
              >
                {t('top.settings')}
              </button>
              <button
                ref={aboutMenuBtnRef}
                type="button"
                data-aboutmenu-root="1"
                className={styles.fileMenuTrigger}
                onClick={() => {
                  setFileMenuOpen(false);
                  setProjectMenuOpen(false);
                  setSettingsMenuOpen(false);
                  setSettingsLangSubOpen(false);
                  setAboutMenuOpen((v) => !v);
                }}
              >
                {t('top.aboutApp')}
              </button>
              <button
                ref={projectMenuBtnRef}
                type="button"
                data-projectmenu-root="1"
                className={styles.fileMenuTrigger}
                disabled={!licenseActive}
                title={!licenseActive ? t('top.afterLicense') : undefined}
                onClick={() => {
                  if (!licenseActive) return;
                  setFileMenuOpen(false);
                  setSettingsMenuOpen(false);
                  setAboutMenuOpen(false);
                  setProjectMenuOpen((v) => !v);
                }}
              >
                {t('top.project')}
              </button>
              {state.project ? (
                <button
                  ref={fileMenuBtnRef}
                  type="button"
                  data-filemenu-root="1"
                  className={styles.fileMenuTrigger}
                  disabled={!licenseActive}
                  title={!licenseActive ? t('top.afterLicense') : undefined}
                  onClick={() => {
                    if (!licenseActive) return;
                    setProjectMenuOpen(false);
                    setSettingsMenuOpen(false);
                    setAboutMenuOpen(false);
                    setFileMenuOpen((v) => !v);
                  }}
                >
                  {t('top.file')}
                </button>
              ) : null}
            </div>
            <div className={styles.flex1} />
            {appVersionText ? (
              <div className={styles.appVersion} title={t('top.appVersion')}>
                {appVersionText}
              </div>
            ) : null}
            <div className={styles.headerActions}>
              {state.project ? (
                <>
                  <Button
                    variant="primary"
                    disabled={runDisabled}
                    onClick={() => {
                      if (!licenseActive || !graphStartGraphNodeId) return;
                      launchFromGraphNode(graphStartGraphNodeId);
                    }}
                  >
                    {t('top.run')}
                  </Button>
                  {runDisabled ? (
                    <Button
                      variant="ghost"
                      iconOnly
                      ariaLabel={t('top.runHelpAria')}
                      title={runHelpTooltip}
                      tooltipPlacement="bottom-left"
                      onClick={() => {
                        setInstructionsSection(runHelpSection);
                        setInstructionsOpen(true);
                      }}
                    >
                      ?
                    </Button>
                  ) : null}
                </>
              ) : null}
            </div>
          </div>
        }
        left={
          <div
            className={[
              styles.editorSidebar,
              state.project && scenesColumnDrop.dragOver ? styles.editorSidebarDragOver : '',
            ].join(' ')}
            onDragEnter={state.project ? scenesColumnDrop.onDragEnter : undefined}
            onDragLeave={state.project ? scenesColumnDrop.onDragLeave : undefined}
            onDragOver={state.project ? scenesColumnDrop.onDragOver : undefined}
            onDrop={state.project ? scenesColumnDrop.onDrop : undefined}
          >
            {state.project && scenesColumnDrop.dragOver ? (
              <div className={styles.dropHintOverlay}>{t('scenes.dropHint')}</div>
            ) : null}
            {state.project ? (
              <>
                <div className={styles.gridTools}>
                  <Input value={query} onChange={setQuery} placeholder={t('scenes.search')} />
                  <Button
                    variant="primary"
                    disabled={state.creatingScene || state.sceneBatchImport !== null}
                    onClick={() => void actions.createScene()}
                  >
                    {t('scenes.new')}
                  </Button>
                </div>
                <div className={styles.spacer14} />
                <div className={styles.sidebarScroll} ref={sceneListScrollRef}>
                  <div className={styles.sceneListGrid}>
                    {filtered.map((s) => (
                      <SceneListCard
                        key={s.id}
                        scene={s}
                        reorderEnabled={sceneListReorderEnabled}
                        listDragActive={draggingListSceneId !== null}
                        dropPlace={
                          sceneListDrop?.targetId === s.id ? sceneListDrop.place : null
                        }
                        isDragging={draggingListSceneId === s.id}
                        onSelect={() => {
                          setSelectedGraphNodeId(null);
                          void actions.selectScene(s.id);
                        }}
                        onDeleteScene={(id) => void actions.deleteScene(id)}
                        onDragListStart={(id) => {
                          draggingListSceneIdRef.current = id;
                          setDraggingListSceneId(id);
                        }}
                        onDragListEnd={() => {
                          draggingListSceneIdRef.current = null;
                          setDraggingListSceneId(null);
                          setSceneListDrop(null);
                        }}
                        onDragListOver={(targetId, place) => {
                          if (!sceneListReorderEnabled || !draggingListSceneIdRef.current) return;
                          if (draggingListSceneIdRef.current === targetId) {
                            setSceneListDrop(null);
                            return;
                          }
                          setSceneListDrop((cur) =>
                            cur?.targetId === targetId && cur.place === place
                              ? cur
                              : { targetId, place },
                          );
                        }}
                        onDropListReorder={(draggedId, targetId, place) => {
                          if (!sceneListReorderEnabled || !state.project) {
                            draggingListSceneIdRef.current = null;
                            setSceneListDrop(null);
                            setDraggingListSceneId(null);
                            return;
                          }
                          const order = reconcileSceneListOrder(
                            state.project.scenes,
                            state.project.sceneListOrder,
                          );
                          const next = moveSceneInListOrder(order, draggedId, targetId, place);
                          draggingListSceneIdRef.current = null;
                          setSceneListDrop(null);
                          setDraggingListSceneId(null);
                          void actions.setSceneListOrder(next);
                        }}
                      />
                    ))}
                  </div>
                </div>
              </>
            ) : (
              <ProjectPicker
                projects={state.projects}
                licenseActive={licenseActive}
                openingProjectId={state.openingProjectId}
                onCreate={actions.createProject}
                onOpen={actions.openProject}
                onRename={(id) => {
                  renameFromPickerRef.current = true;
                  void (async () => {
                    try {
                      await actions.openProject(id);
                      setRenameOpen(true);
                    } catch (e) {
                      renameFromPickerRef.current = false;
                      setAppNotice({
                        title: t('common.error'),
                        message: e instanceof Error ? e.message : String(e),
                      });
                    }
                  })();
                }}
                onDelete={actions.deleteProject}
              />
            )}
          </div>
        }
        center={
          <div className={styles.editorGraphHost}>
            {state.project ? (
              <SceneGraph
                graphUi={graphUi}
                sceneGraphNodes={state.project.sceneGraphNodes}
                sceneGraphEdges={state.project.sceneGraphEdges}
                sceneCardById={sceneCardById}
                selectedGraphNodeId={selectedGraphNodeId}
                onGraphNodeSelect={(graphNodeId, sceneId) => {
                  setSelectedGraphNodeId(graphNodeId);
                  void actions.selectScene(sceneId);
                }}
                onConnect={(sourceGn, targetGn) => void actions.addSceneGraphEdge(sourceGn, targetGn)}
                onDisconnect={(edgeId) => void actions.removeSceneGraphEdge(edgeId)}
                onNodePositionCommit={(nodeId, x, y) =>
                  void actions.updateSceneGraphNodePosition(nodeId, x, y)
                }
                onRemoveGraphNodes={(ids) => {
                  void Promise.all(ids.map((id) => actions.removeSceneGraphNode(id)));
                }}
                onRemoveGraphNode={(id) => void actions.removeSceneGraphNode(id)}
                onSetGraphNodeStart={(graphNodeId) => void actions.setSceneGraphNodeStart(graphNodeId)}
                onSetGraphNodeSideStoryStart={(graphNodeId) =>
                  void actions.setSceneGraphNodeSideStoryStart(graphNodeId)
                }
                onRunFromGraphNode={launchFromGraphNode}
                onDropSceneFromList={(sceneId, x, y) => void actions.addSceneGraphNode(sceneId, x, y)}
              />
            ) : (
              <div className={styles.centerEmpty} />
            )}
          </div>
        }
        right={
          <div className={styles.editorInspector}>
            <div className={styles.inspectorScroll}>
              {state.project ? (
                <>
                  <div className={styles.inspectorTitle}>{t('scenes.inspectorGame')}</div>
                  <CampaignInspector
                    audioRefs={campaignAudioRefs}
                    mediaAssets={campaignAudioAssets}
                    onAudioRefsChange={(next) => void actions.updateCampaignAudios(next)}
                    onUploadAudio={() => {
                      void (async () => {
                        try {
                          await actions.importCampaignAudio();
                        } catch (e) {
                          setAppNotice({
                            title: t('common.error'),
                            message: e instanceof Error ? e.message : String(e),
                          });
                        }
                      })();
                    }}
                    onDropAudio={(filePaths) => {
                      void (async () => {
                        try {
                          await actions.importCampaignAudioFromPaths(filePaths);
                        } catch (e) {
                          setAppNotice({
                            title: t('common.error'),
                            message: e instanceof Error ? e.message : String(e),
                          });
                        }
                      })();
                    }}
                  />
                  <div className={styles.spacer6} />
                  <div className={styles.gamePropsButtons}>
                    <Button onClick={() => setMaterialsManagerOpen(true)}>{t('materials.open')}</Button>
                    <Button
                      onClick={() => {
                        void getDndApi()
                          .invoke(ipcChannels.windows.openNpcsEditor, {})
                          .catch((err) => {
                            console.error('[editor] openNpcsEditor failed', err);
                          });
                      }}
                    >
                      {t('npcs.open')}
                    </Button>
                  </div>
                  <div className={styles.spacer18} />
                  <div className={styles.inspectorTitle}>{t('scenes.inspectorScene')}</div>
                  {state.selectedSceneId ? (
                    (() => {
                      const proj = state.project;
                      const sid = state.selectedSceneId;
                      const sc = proj.scenes[sid];
                      const previewImport = state.scenePreviewImports[sid] ?? null;
                      const previewBusy = previewDialogSceneId === sid || previewImport !== null;
                      const previewBusyText =
                        previewDialogSceneId === sid
                          ? t('scene.previewBusySelecting')
                          : previewImport?.phase === 'done'
                            ? t('scene.previewReady')
                            : previewImport?.phase === 'error'
                              ? t('scene.previewFailed')
                              : previewImport !== null
                                ? t('scene.previewOptimizing')
                                : t('scene.previewBusy');
                      const sideStoryStartNodes = proj.sceneGraphNodes.filter(
                        (n) => n.sceneId === sid && n.isSideStoryStart,
                      );
                      return (
                        <SceneInspector
                          title={sc?.title ?? ''}
                          description={sc?.description ?? ''}
                          sideStoryStartNodes={sideStoryStartNodes}
                          previewAssetId={sc?.previewAssetId ?? null}
                          previewAssetType={sc?.previewAssetType ?? null}
                          previewVideoAutostart={sc?.previewVideoAutostart ?? false}
                          previewRotationDeg={sc?.previewRotationDeg ?? 0}
                          darkenScene={sc?.darkenScene ?? false}
                          previewBusy={previewBusy}
                          previewBusyText={previewBusyText}
                          mediaAssets={sceneMediaAssets}
                          audioRefs={sceneAudioRefs}
                          onAudioRefsChange={(next) =>
                            void actions.updateScene(sid, { media: { audios: next } })
                          }
                          onPreviewVideoAutostartChange={(next) =>
                            void actions.updateScene(sid, { previewVideoAutostart: next })
                          }
                          onDarkenSceneChange={(next) => void actions.updateScene(sid, { darkenScene: next })}
                          onTitleChange={(title) => void actions.updateScene(sid, { title })}
                          onDescriptionChange={(description) =>
                            void actions.updateScene(sid, { description })
                          }
                          onImportPreview={() => {
                            if (previewBusy) return;
                            setPreviewDialogSceneId(sid);
                            void (async () => {
                              try {
                                await actions.importScenePreview(sid);
                              } catch (e) {
                                setAppNotice({
                                  title: t('common.error'),
                                  message: e instanceof Error ? e.message : String(e),
                                });
                              } finally {
                                setPreviewDialogSceneId((cur) => (cur === sid ? null : cur));
                              }
                            })();
                          }}
                          onClearPreview={() => void actions.clearScenePreview(sid)}
                          onRotatePreview={(previewRotationDeg) =>
                            void actions.updateScene(sid, { previewRotationDeg })
                          }
                          onUploadMedia={() => void actions.importMediaToScene(sid)}
                          onDropAudio={(filePaths) => {
                            void (async () => {
                              try {
                                await actions.importMediaToSceneFromPaths(sid, filePaths);
                              } catch (e) {
                                setAppNotice({
                                  title: t('common.error'),
                                  message: e instanceof Error ? e.message : String(e),
                                });
                              }
                            })();
                          }}
                          onDropPreview={(filePath) => {
                            if (previewBusy) return;
                            setPreviewDialogSceneId(sid);
                            void (async () => {
                              try {
                                await actions.importScenePreviewFromPath(sid, filePath);
                              } catch (e) {
                                setAppNotice({
                                  title: t('common.error'),
                                  message: e instanceof Error ? e.message : String(e),
                                });
                              } finally {
                                setPreviewDialogSceneId((cur) => (cur === sid ? null : cur));
                              }
                            })();
                          }}
                          onSideStoryLineTitleChange={(graphNodeId, title) =>
                            void actions.updateSideStoryLineTitle(graphNodeId, title)
                          }
                        />
                      );
                    })()
                  ) : (
                    <div className={styles.muted}>{t('scenes.selectHint')}</div>
                  )}
                </>
              ) : (
                <div className={styles.muted}>{t('scenes.openProjectHint')}</div>
              )}
            </div>
          </div>
        }
      />
      {settingsMenuOpen && settingsMenuPos
        ? createPortal(
            <div
              role="menu"
              data-settingsmenu-root="1"
              className={styles.fileMenu}
              style={{ left: settingsMenuPos.left, top: settingsMenuPos.top }}
            >
              <button
                type="button"
                role="menuitem"
                className={styles.fileMenuItem}
                onClick={() => {
                  setSettingsMenuOpen(false);
                  setSettingsLangSubOpen(false);
                  if ((licenseSnap?.eulaAcceptedVersion ?? null) === EULA_CURRENT_VERSION) {
                    setLicenseKeyModalOpen(true);
                  } else {
                    setOpenKeyAfterEula(true);
                    setEulaModalOpen(true);
                  }
                }}
              >
                {t('menu.enterKey')}
              </button>
              <button
                type="button"
                role="menuitem"
                className={styles.fileMenuItem}
                onClick={() => {
                  setSettingsMenuOpen(false);
                  setSettingsLangSubOpen(false);
                  setAboutLicenseOpen(true);
                }}
              >
                {t('menu.aboutLicense')}
              </button>
              {licenseActive && appPackaged ? (
                <button
                  type="button"
                  role="menuitem"
                  className={styles.fileMenuItem}
                  onClick={() => {
                    setSettingsMenuOpen(false);
                    setSettingsLangSubOpen(false);
                    setCheckUpdatesOpen(true);
                  }}
                >
                  {t('menu.checkUpdates')}
                </button>
              ) : null}
              <div className={styles.fileMenuSubHost} role="presentation">
                <button
                  type="button"
                  role="menuitem"
                  aria-haspopup="menu"
                  aria-expanded={settingsLangSubOpen}
                  className={[styles.fileMenuItem, styles.fileMenuItemExpand].join(' ')}
                  onClick={(e) => {
                    e.stopPropagation();
                    setSettingsLangSubOpen((o) => !o);
                  }}
                >
                  <span>{t('menu.language')}</span>
                  <span aria-hidden>›</span>
                </button>
                {settingsLangSubOpen ? (
                  <div
                    role="menu"
                    aria-label={t('menu.language')}
                    className={styles.fileMenuSub}
                    data-settings-submenu-root="1"
                  >
                    <button
                      type="button"
                      role="menuitem"
                      className={styles.fileMenuItem}
                      onClick={() => {
                        setSettingsMenuOpen(false);
                        setSettingsLangSubOpen(false);
                        setLocale('ru');
                      }}
                    >
                      {locale === 'ru' ? '✓ ' : ''}
                      {t('menu.langRu')}
                    </button>
                    <button
                      type="button"
                      role="menuitem"
                      className={styles.fileMenuItem}
                      onClick={() => {
                        setSettingsMenuOpen(false);
                        setSettingsLangSubOpen(false);
                        setLocale('en');
                      }}
                    >
                      {locale === 'en' ? '✓ ' : ''}
                      {t('menu.langEn')}
                    </button>
                  </div>
                ) : null}
              </div>
            </div>,
            document.body,
          )
        : null}
      <LicenseTokenModal
        open={licenseKeyModalOpen}
        onClose={() => setLicenseKeyModalOpen(false)}
        onSaved={() => {
          reloadLicense();
        }}
      />
      <EulaModal
        open={eulaModalOpen}
        onClose={() => {
          setEulaModalOpen(false);
          setOpenKeyAfterEula(false);
        }}
        onAccepted={() => {
          if (openKeyAfterEula) {
            setLicenseKeyModalOpen(true);
          }
          setOpenKeyAfterEula(false);
        }}
      />
      <LicenseAboutModal
        open={aboutLicenseOpen}
        onClose={() => setAboutLicenseOpen(false)}
        snapshot={licenseSnap}
      />
      <AppAboutModal open={appAboutOpen} onClose={() => setAppAboutOpen(false)} appVersion={appVersionText} />
      <InstructionsModal
        open={instructionsOpen}
        initialSection={instructionsSection}
        onClose={() => setInstructionsOpen(false)}
      />
      {aboutMenuOpen && aboutMenuPos
        ? createPortal(
            <div
              role="menu"
              data-aboutmenu-root="1"
              className={styles.fileMenu}
              style={{ left: aboutMenuPos.left, top: aboutMenuPos.top }}
            >
              <button
                type="button"
                role="menuitem"
                className={styles.fileMenuItem}
                onClick={() => {
                  setAboutMenuOpen(false);
                  setAppAboutOpen(true);
                }}
              >
                {t('menu.aboutProgram')}
              </button>
              <button
                type="button"
                role="menuitem"
                className={styles.fileMenuItem}
                onClick={() => {
                  setAboutMenuOpen(false);
                  setInstructionsSection('overview');
                  setInstructionsOpen(true);
                }}
              >
                {t('menu.instructions')}
              </button>
            </div>,
            document.body,
          )
        : null}
      {projectMenuOpen && projectMenuPos
        ? createPortal(
            <div
              role="menu"
              data-projectmenu-root="1"
              className={styles.fileMenu}
              style={{ left: projectMenuPos.left, top: projectMenuPos.top }}
            >
              <button
                type="button"
                role="menuitem"
                className={styles.fileMenuItem}
                onClick={() => {
                  goHome();
                }}
              >
                {t('projectMenu.home')}
              </button>
              <button
                type="button"
                role="menuitem"
                className={styles.fileMenuItem}
                onClick={() => {
                  void handleImportProject();
                }}
              >
                {t('projectMenu.import')}
              </button>
              {!state.project ? (
                <button
                  type="button"
                  role="menuitem"
                  className={styles.fileMenuItem}
                  onClick={() => {
                    handleImportFoundry();
                  }}
                >
                  {t('projectMenu.importFoundry')}
                </button>
              ) : null}
              <button
                type="button"
                role="menuitem"
                className={styles.fileMenuItem}
                disabled={state.projects.length === 0}
                title={state.projects.length === 0 ? t('projectMenu.noProjects') : undefined}
                onClick={() => {
                  setProjectMenuOpen(false);
                  setExportModalOpen(true);
                }}
              >
                {t('projectMenu.export')}
              </button>
            </div>,
            document.body,
          )
        : null}
      {fileMenuOpen && fileMenuPos && state.project
        ? createPortal(
            <div
              role="menu"
              data-filemenu-root="1"
              className={styles.fileMenu}
              style={{ left: fileMenuPos.left, top: fileMenuPos.top }}
            >
              <button
                type="button"
                role="menuitem"
                className={styles.fileMenuItem}
                onClick={() => {
                  setFileMenuOpen(false);
                  setRenameOpen(true);
                }}
              >
                {t('fileMenu.rename')}
              </button>
            </div>,
            document.body,
          )
        : null}
      {state.project ? (
        <RenameProjectModal
          open={renameOpen}
          projectNameInitial={currentProjectName}
          fileBaseNameInitial={currentFileBaseName}
          existingProjectNames={existingProjectNames}
          existingFileBaseNames={existingFileBaseNames}
          onClose={() => {
            setRenameOpen(false);
            if (renameFromPickerRef.current) {
              renameFromPickerRef.current = false;
              void actions.closeProject();
            }
          }}
          onSave={async (name, fileBaseName) => {
            await actions.renameProject(name, fileBaseName);
          }}
        />
      ) : null}
      <ExportProjectModal
        open={exportModalOpen}
        projects={state.projects}
        initialProjectId={exportModalInitialProjectId}
        storylineLabels={storylineLabels}
        loadStorylines={loadProjectStorylines}
        onClose={() => setExportModalOpen(false)}
        onExport={async (projectId, selections) => {
          await actions.exportProject(projectId, selections, storylineLabels);
        }}
      />
      <ImportSourceModal
        open={importSourceOpen}
        canImportFromProject={state.project !== null}
        projects={state.projects}
        currentProjectId={state.project?.id ?? null}
        pickFile={actions.pickImportZipFile}
        onClose={() => setImportSourceOpen(false)}
        onContinue={handleImportSourceContinue}
      />
      <FoundryImportModal
        open={foundryImportOpen}
        pickSource={actions.pickFoundrySource}
        onClose={() => setFoundryImportOpen(false)}
        onImport={async (selection) => {
          // Сразу закрываем диалог, чтобы прогресс импорта не оказался под ним.
          setFoundryImportOpen(false);
          await actions.importFoundryProject(selection.sourcePath);
        }}
      />
      <ImportStorylinesModal
        open={importStorylinesOpen}
        sourceName={importPeek?.projectName ?? ''}
        storylines={importPeek?.storylines ?? []}
        onClose={() => {
          setImportStorylinesOpen(false);
          setImportPeek(null);
        }}
        onContinue={(selections) => {
          if (!importPeek || !state.project) return;
          const conflicts = computeImportConflicts(state.project, importPeek.sourceProject, selections);
          setPendingImportSelections(selections);
          setImportStorylinesOpen(false);
          if (conflicts.length > 0) {
            setImportConflicts(conflicts);
            setImportConflictsOpen(true);
            return;
          }
          const resolutions = buildSceneResolutionsForImport(
            state.project,
            importPeek.sourceProject,
            selections,
            [],
            [],
          );
          continueImportAfterScenes(selections, resolutions);
        }}
      />
      <SceneConflictModal
        open={importConflictsOpen}
        conflicts={importConflicts}
        onClose={clearImportFlow}
        onConfirm={(userResolutions) => {
          if (!importPeek || !state.project) return;
          const resolutions = buildSceneResolutionsForImport(
            state.project,
            importPeek.sourceProject,
            pendingImportSelections,
            importConflicts,
            userResolutions,
          );
          continueImportAfterScenes(pendingImportSelections, resolutions);
        }}
      />
      <NpcConflictModal
        open={importNpcConflictsOpen}
        conflicts={importNpcConflicts}
        onClose={clearImportFlow}
        onConfirm={(userNpcResolutions) => {
          if (!importPeek || !state.project) return;
          const npcResolutions = buildNpcResolutionsForImport(
            state.project,
            importPeek.sourceProject,
            pendingImportSelections,
            importNpcConflicts,
            userNpcResolutions,
          );
          void runStorylineMerge(pendingImportSelections, pendingSceneResolutions, npcResolutions);
        }}
      />
      <ImportReportModal
        open={importReportOpen}
        report={importReport}
        onClose={() => {
          setImportReportOpen(false);
          setImportReport(null);
        }}
      />
      <CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} />
      <MaterialsManagerModal
        open={materialsManagerOpen}
        materials={state.project?.materials ?? []}
        onClose={() => setMaterialsManagerOpen(false)}
        onAdd={() => setMaterialEdit('new')}
        onEdit={(m) => setMaterialEdit(m)}
        onDelete={async (materialId: MaterialId) => {
          try {
            await actions.deleteMaterial(materialId);
          } catch (e) {
            setAppNotice({
              title: t('common.error'),
              message: e instanceof Error ? e.message : String(e),
            });
          }
        }}
        onReorder={async (materialIds) => {
          try {
            await actions.setMaterialsOrder(materialIds);
          } catch (e) {
            setAppNotice({
              title: t('common.error'),
              message: e instanceof Error ? e.message : String(e),
            });
          }
        }}
        onRotate={(materialId, rotationDeg) => {
          void actions.setMaterialRotation(materialId, rotationDeg).catch((e) => {
            setAppNotice({
              title: t('common.error'),
              message: e instanceof Error ? e.message : String(e),
            });
          });
        }}
        onLegendChange={async (materialId, legend) => {
          try {
            await actions.setMaterialLegend(materialId, legend);
          } catch (e) {
            setAppNotice({
              title: t('common.error'),
              message: e instanceof Error ? e.message : String(e),
            });
          }
        }}
      />
      <MaterialEditModal
        open={materialEdit !== null}
        initial={materialEdit && materialEdit !== 'new' ? materialEdit : null}
        existingNames={(state.project?.materials ?? []).map((m) => m.name)}
        onClose={() => setMaterialEdit(null)}
        onPickImage={() => actions.pickMaterialImage()}
        onSave={async ({ name, filePath }) => {
          const materialId = materialEdit && materialEdit !== 'new' ? materialEdit.id : undefined;
          await actions.upsertMaterial({
            ...(materialId ? { materialId } : {}),
            name,
            ...(filePath ? { filePath } : {}),
          });
        }}
      />
      <SimpleMessageModal
        open={appNotice !== null}
        title={appNotice?.title ?? t('common.message')}
        message={appNotice?.message ?? ''}
        onClose={() => setAppNotice(null)}
      />
    </>
  );
}

type CheckUpdatesModalProps = {
  open: boolean;
  onClose: () => void;
};

function formatUpdaterStageLabel(
  t: (key: string, vars?: Record<string, string | number>) => string,
  ev: UpdaterProgressEvent | null,
): string {
  if (!ev) return t('updates.stage.checking');
  const percentSuffix =
    ev.phase === 'downloading' && ev.percent !== undefined
      ? t('updates.stagePercent', { percent: ev.percent })
      : '';
  switch (ev.phase) {
    case 'checking':
      return t('updates.stage.checking');
    case 'available':
      return t('updates.stage.available', { version: ev.version ?? '?' });
    case 'not-available':
      return t('updates.stage.not-available');
    case 'downloading':
      return t('updates.stage.downloading', { percent: percentSuffix });
    case 'installing':
      return t('updates.stage.installing');
    case 'error':
      return ev.message ? `${t('updates.stage.error')}: ${ev.message}` : t('updates.stage.error');
    default:
      return t('updates.stage.checking');
  }
}

function CheckUpdatesModal({ open, onClose }: CheckUpdatesModalProps) {
  const { t } = useEditorI18n();
  const [phase, setPhase] = useState<'idle' | 'checking' | 'done'>('idle');
  const [res, setRes] = useState<UpdaterCheckResponse | null>(null);
  const [downloadBusy, setDownloadBusy] = useState(false);
  const [progress, setProgress] = useState<UpdaterProgressEvent | null>(null);

  useEffect(() => {
    if (!open) return;
    startTransition(() => {
      setPhase('checking');
      setRes(null);
      setProgress({ phase: 'checking' });
      setDownloadBusy(false);
    });
    void getDndApi()
      .invoke(ipcChannels.updater.check, {})
      .then((r) => {
        setRes(r);
        setPhase('done');
        if (r.outcome === 'available') {
          setProgress({ phase: 'available', version: r.version });
        } else if (r.outcome === 'current') {
          setProgress({ phase: 'not-available', version: r.currentVersion });
        } else if (r.outcome === 'error') {
          setProgress({ phase: 'error', message: r.message });
        }
      })
      .catch((e: unknown) => {
        const message = e instanceof Error ? e.message : String(e);
        setRes({ outcome: 'error', message });
        setPhase('done');
        setProgress({ phase: 'error', message });
      });
  }, [open]);

  useEffect(() => {
    if (!open) return;
    return getDndApi().on(ipcChannels.updater.progress, (ev) => {
      setProgress(ev);
    });
  }, [open]);

  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape' && !downloadBusy) onClose();
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [downloadBusy, onClose, open]);

  if (!open) return null;

  const stageLine = t('updates.stageLine', { stage: formatUpdaterStageLabel(t, progress) });

  const body =
    phase === 'checking' || res === null ? (
      <div className={styles.muted}>{t('updates.checking')}</div>
    ) : res.outcome === 'available' ? (
      <div className={styles.muted}>{t('updates.available', { version: res.version })}</div>
    ) : res.outcome === 'current' ? (
      <div className={styles.muted}>{t('updates.current', { version: res.currentVersion })}</div>
    ) : res.outcome === 'error' ? (
      <div className={styles.muted}>{t('updates.error', { message: res.message })}</div>
    ) : res.outcome === 'not_packaged' ? (
      <div className={styles.muted}>{t('updates.notPackaged')}</div>
    ) : (
      <div className={styles.muted}>{t('updates.noLicense')}</div>
    );

  const showUpdateIdle = phase === 'done' && res !== null && res.outcome === 'available' && !downloadBusy;
  const showUpdateBusy = phase === 'done' && res !== null && res.outcome === 'available' && downloadBusy;

  return createPortal(
    <>
      <button
        type="button"
        aria-label={t('common.close')}
        onClick={() => {
          if (!downloadBusy) onClose();
        }}
        className={styles.modalBackdrop}
      />
      <div role="dialog" aria-modal="true" className={styles.modalDialog}>
        <div className={styles.modalHeader}>
          <div className={styles.modalTitle}>{t('updates.dialogTitle')}</div>
          <button
            type="button"
            aria-label={t('common.close')}
            disabled={downloadBusy}
            onClick={onClose}
            className={styles.modalClose}
          >
            ×
          </button>
        </div>
        <div className={styles.fieldGrid}>
          {body}
          <div className={styles.muted}>{stageLine}</div>
        </div>
        <div className={styles.modalFooter}>
          {showUpdateIdle ? (
            <>
              <Button variant="ghost" disabled={downloadBusy} onClick={onClose}>
                {t('common.close')}
              </Button>
              <Button
                variant="primary"
                disabled={downloadBusy}
                onClick={() => {
                  setDownloadBusy(true);
                  setProgress({ phase: 'downloading', version: res.version, percent: 0 });
                  void getDndApi()
                    .invoke(ipcChannels.updater.downloadAndRestart, { version: res.version })
                    .then((r) => {
                      if (!r.ok) {
                        setDownloadBusy(false);
                        setRes({ outcome: 'error', message: r.message });
                        setProgress({ phase: 'error', message: r.message });
                      }
                    })
                    .catch((e: unknown) => {
                      const message = e instanceof Error ? e.message : String(e);
                      setDownloadBusy(false);
                      setRes({ outcome: 'error', message });
                      setProgress({ phase: 'error', message });
                    });
                }}
              >
                {t('updates.download')}
              </Button>
            </>
          ) : showUpdateBusy ? (
            <Button variant="primary" disabled>
              {progress?.phase === 'installing' ? t('updates.stage.installing') : t('updates.downloading')}
            </Button>
          ) : (
            <Button variant="primary" disabled={downloadBusy} onClick={onClose}>
              {t('common.close')}
            </Button>
          )}
        </div>
      </div>
    </>,
    document.body,
  );
}

type SimpleMessageModalProps = {
  open: boolean;
  title?: string;
  message: string;
  onClose: () => void;
};

function SimpleMessageModal({ open, title, message, onClose }: SimpleMessageModalProps) {
  const { t } = useEditorI18n();
  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [onClose, open]);

  if (!open) return null;

  return createPortal(
    <>
      <button
        type="button"
        aria-label={t('common.close')}
        onClick={onClose}
        className={styles.modalBackdrop}
      />
      <div role="dialog" aria-modal="true" className={styles.modalDialog}>
        <div className={styles.modalHeader}>
          <div className={styles.modalTitle}>{title ?? t('common.message')}</div>
          <button
            type="button"
            aria-label={t('common.close')}
            onClick={onClose}
            className={styles.modalClose}
          >
            ×
          </button>
        </div>
        <div className={styles.fieldGrid}>
          <div className={[styles.muted, styles.noticeMessage].join(' ')}>{message}</div>
        </div>
        <div className={styles.modalFooter}>
          <Button onClick={onClose}>{t('common.close')}</Button>
          <Button variant="primary" onClick={onClose}>
            {t('common.understood')}
          </Button>
        </div>
      </div>
    </>,
    document.body,
  );
}

type ConfirmDeleteProjectModalProps = {
  open: boolean;
  projectName: string;
  busy: boolean;
  onCancel: () => void;
  onConfirm: () => void | Promise<void>;
};

function ConfirmDeleteProjectModal({
  open,
  projectName,
  busy,
  onCancel,
  onConfirm,
}: ConfirmDeleteProjectModalProps) {
  const { t } = useEditorI18n();
  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape' && !busy) onCancel();
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [busy, onCancel, open]);

  if (!open) return null;

  return createPortal(
    <>
      <button
        type="button"
        aria-label={t('common.close')}
        onClick={() => {
          if (!busy) onCancel();
        }}
        className={styles.modalBackdrop}
      />
      <div role="dialog" aria-modal="true" className={styles.modalDialog}>
        <div className={styles.modalHeader}>
          <div className={styles.modalTitle}>{t('confirmDelete.title')}</div>
          <button
            type="button"
            aria-label={t('common.close')}
            disabled={busy}
            onClick={onCancel}
            className={styles.modalClose}
          >
            ×
          </button>
        </div>
        <div className={styles.fieldGrid}>
          <div className={styles.muted}>{t('confirmDelete.body', { name: projectName })}</div>
        </div>
        <div className={styles.modalFooter}>
          <Button onClick={onCancel} disabled={busy}>
            {t('common.cancel')}
          </Button>
          <Button
            variant="primary"
            disabled={busy}
            onClick={() => {
              void onConfirm();
            }}
          >
            {t('common.delete')}
          </Button>
        </div>
      </div>
    </>,
    document.body,
  );
}

function isValidFileBaseName(input: string): boolean {
  const trimmed = input.trim();
  if (trimmed.length < 3) return false;
  return !/[<>:"/\\|?*]/gu.test(trimmed);
}

function normalizeName(input: string): string {
  return input.trim().toLowerCase();
}

type RenameProjectModalProps = {
  open: boolean;
  projectNameInitial: string;
  fileBaseNameInitial: string;
  existingProjectNames: string[];
  existingFileBaseNames: string[];
  onClose: () => void;
  onSave: (projectName: string, fileBaseName: string) => Promise<void>;
};

function RenameProjectModal({
  open,
  projectNameInitial,
  fileBaseNameInitial,
  existingProjectNames,
  existingFileBaseNames,
  onClose,
  onSave,
}: RenameProjectModalProps) {
  const { t } = useEditorI18n();
  const [projectName, setProjectName] = useState(projectNameInitial);
  const [fileBaseName, setFileBaseName] = useState(fileBaseNameInitial);
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!open) return;
    setProjectName(projectNameInitial);
    setFileBaseName(fileBaseNameInitial);
    setSaving(false);
    setError(null);
  }, [fileBaseNameInitial, open, projectNameInitial]);

  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [onClose, open]);

  const trimmedProjectName = projectName.trim();
  const trimmedFileBase = fileBaseName.trim();
  const projectNameOk = trimmedProjectName.length >= 3;
  const fileNameOk = isValidFileBaseName(trimmedFileBase);

  const projectNameDup =
    normalizeName(trimmedProjectName) !== normalizeName(projectNameInitial) &&
    existingProjectNames.some((n) => normalizeName(n) === normalizeName(trimmedProjectName));
  const fileNameDup =
    normalizeName(trimmedFileBase) !== normalizeName(fileBaseNameInitial) &&
    existingFileBaseNames.some((n) => normalizeName(n) === normalizeName(trimmedFileBase));

  const canSave = projectNameOk && fileNameOk && !projectNameDup && !fileNameDup && !saving;

  if (!open) return null;

  return createPortal(
    <>
      <button
        type="button"
        aria-label={t('common.close')}
        onClick={onClose}
        className={styles.modalBackdrop}
      />
      <div role="dialog" aria-modal="true" className={styles.modalDialog}>
        <div className={styles.modalHeader}>
          <div className={styles.modalTitle}>{t('rename.title')}</div>
          <button
            type="button"
            aria-label={t('common.close')}
            onClick={onClose}
            className={styles.modalClose}
          >
            ×
          </button>
        </div>

        <div className={styles.fieldGrid}>
          <div className={styles.fieldLabel}>{t('rename.projectName')}</div>
          <Input value={projectName} onChange={setProjectName} placeholder={t('rename.projectPlaceholder')} />
          {!projectNameOk ? <div className={styles.fieldError}>{t('rename.projectMin')}</div> : null}
          {projectNameDup ? <div className={styles.fieldError}>{t('rename.projectDup')}</div> : null}
        </div>

        <div className={styles.fieldGrid}>
          <div className={styles.fieldLabel}>{t('rename.fileName')}</div>
          <div className={styles.rowFlex}>
            <div className={styles.flex1}>
              <Input value={fileBaseName} onChange={setFileBaseName} placeholder="my_campaign" />
            </div>
            <div className={styles.fileSuffix}>{PROJECT_ZIP_EXTENSION}</div>
          </div>
          {!fileNameOk ? <div className={styles.fieldError}>{t('rename.fileInvalid')}</div> : null}
          {fileNameDup ? <div className={styles.fieldError}>{t('rename.fileDup')}</div> : null}
        </div>

        {error ? <div className={styles.fieldError}>{error}</div> : null}

        <div className={styles.modalFooter}>
          <Button onClick={onClose} disabled={saving} title={saving ? t('rename.saving') : undefined}>
            {t('common.cancel')}
          </Button>
          <Button
            variant="primary"
            disabled={!canSave}
            onClick={() => {
              if (!canSave) return;
              void (async () => {
                setSaving(true);
                setError(null);
                try {
                  await onSave(trimmedProjectName, trimmedFileBase);
                  onClose();
                } catch (e) {
                  setError(e instanceof Error ? e.message : String(e));
                } finally {
                  setSaving(false);
                }
              })();
            }}
          >
            {t('common.save')}
          </Button>
        </div>
      </div>
    </>,
    document.body,
  );
}

type ProjectPickerProps = {
  projects: { id: ProjectId; name: string; updatedAt: string }[];
  licenseActive: boolean;
  openingProjectId: ProjectId | null;
  onCreate: (name: string) => Promise<void>;
  onOpen: (id: ProjectId) => Promise<void>;
  onRename: (id: ProjectId) => void;
  onDelete: (id: ProjectId) => Promise<void>;
};

function ProjectPicker({
  projects,
  licenseActive,
  openingProjectId,
  onCreate,
  onOpen,
  onRename,
  onDelete,
}: ProjectPickerProps) {
  const { t, locale } = useEditorI18n();
  const [name, setName] = useState(() => t('picker.defaultName'));
  const [projectQuery, setProjectQuery] = useState('');
  const [rowMenuFor, setRowMenuFor] = useState<ProjectId | null>(null);
  const [rowMenuPos, setRowMenuPos] = useState<{ left: number; top: number } | null>(null);
  const [pendingDelete, setPendingDelete] = useState<{ id: ProjectId; name: string } | null>(null);
  const [deleteSubmitting, setDeleteSubmitting] = useState(false);
  const [deleteError, setDeleteError] = useState<string | null>(null);
  const projectListScrollRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    if (!rowMenuFor) return;
    const onDown = (e: MouseEvent) => {
      const tgt = e.target as HTMLElement | null;
      if (!tgt) return;
      if (tgt.closest('[data-project-row-menu-root="1"]')) return;
      setRowMenuFor(null);
      setRowMenuPos(null);
    };
    window.addEventListener('mousedown', onDown);
    return () => window.removeEventListener('mousedown', onDown);
  }, [rowMenuFor]);

  const trimmedName = name.trim();
  const nameOk = trimmedName.length >= 3;
  const nameDup = projects.some((p) => normalizeName(p.name) === normalizeName(trimmedName));
  const canCreate = licenseActive && nameOk && !nameDup;

  const filteredProjects = useMemo(() => {
    const q = projectQuery.trim().toLowerCase();
    if (!q) return projects;
    return projects.filter((p) => p.name.toLowerCase().includes(q));
  }, [projectQuery, projects]);

  return (
    <div className={styles.projectPicker}>
      <div className={styles.projectPickerTitle}>{t('picker.title')}</div>
      <div className={styles.projectPickerForm}>
        <Input value={name} onChange={setName} placeholder={t('picker.newPlaceholder')} />
        {!nameOk ? <div className={styles.fieldError}>{t('rename.projectMin')}</div> : null}
        {nameOk && nameDup ? <div className={styles.fieldError}>{t('rename.projectDup')}</div> : null}
        <Button
          variant="primary"
          disabled={!canCreate}
          title={!licenseActive ? t('top.afterLicense') : undefined}
          onClick={() => {
            if (!canCreate) return;
            void onCreate(trimmedName);
          }}
        >
          {t('picker.create')}
        </Button>
      </div>
      <div className={styles.spacer6} />
      <div className={styles.gridTools}>
        <Input value={projectQuery} onChange={setProjectQuery} placeholder={t('picker.search')} />
      </div>
      <div className={styles.spacer6} />
      <div className={styles.sectionLabel}>{t('picker.existing')}</div>
      {!licenseActive && projects.length > 0 ? (
        <>
          <div className={styles.muted}>{t('picker.lockedHint')}</div>
          <div className={styles.spacer6} />
        </>
      ) : null}
      <div className={styles.projectListScroll} ref={projectListScrollRef}>
        <div className={styles.projectList}>
          {filteredProjects.map((p) => {
            const isOpening = openingProjectId === p.id;
            const openDisabled = !licenseActive || openingProjectId !== null;
            return (
              <div key={p.id} className={styles.projectCard}>
                <div
                  className={[
                    styles.projectCardBody,
                    isOpening ? styles.projectCardBodyOpening : null,
                    openDisabled && !isOpening ? styles.projectCardBodyDisabled : null,
                  ]
                    .filter((className): className is string => Boolean(className))
                    .join(' ')}
                  onClick={() => {
                    if (openDisabled) return;
                    void onOpen(p.id);
                  }}
                  onDoubleClick={(e) => {
                    e.preventDefault();
                    e.stopPropagation();
                  }}
                  role="button"
                  tabIndex={openDisabled ? -1 : 0}
                  aria-busy={isOpening}
                  title={
                    !licenseActive ? t('picker.openDisabled') : isOpening ? t('picker.opening') : undefined
                  }
                  onKeyDown={(e) => {
                    if (e.key === 'Enter' || e.key === ' ') {
                      if (openDisabled) return;
                      void onOpen(p.id);
                    }
                  }}
                >
                  <div className={styles.projectCardName}>{p.name}</div>
                  <div className={styles.projectCardMeta}>
                    {isOpening
                      ? t('picker.opening')
                      : new Date(p.updatedAt).toLocaleString(locale === 'en' ? 'en-US' : 'ru-RU')}
                  </div>
                </div>
                <button
                  type="button"
                  className={styles.projectCardMenuBtn}
                  data-project-row-menu-root="1"
                  aria-label={t('picker.projectMenu')}
                  aria-haspopup="menu"
                  aria-expanded={rowMenuFor === p.id}
                  disabled={!licenseActive || openingProjectId !== null}
                  title={
                    !licenseActive
                      ? t('top.afterLicense')
                      : openingProjectId !== null
                        ? t('picker.opening')
                        : undefined
                  }
                  onClick={(e) => {
                    e.stopPropagation();
                    if (!licenseActive || openingProjectId !== null) return;
                    const r = e.currentTarget.getBoundingClientRect();
                    const menuW = 220;
                    const menuH = 96;
                    const gap = 8;
                    const left = Math.max(8, Math.min(r.right - menuW, window.innerWidth - menuW - 8));
                    const scrollEl = projectListScrollRef.current;
                    const scrollAtEnd =
                      !!scrollEl &&
                      scrollEl.scrollHeight > scrollEl.clientHeight &&
                      scrollEl.scrollTop + scrollEl.clientHeight >= scrollEl.scrollHeight - 2;
                    const openUp = scrollAtEnd || r.bottom + gap + menuH > window.innerHeight - 8;
                    const top = openUp ? Math.max(8, r.top - menuH - gap) : r.bottom + gap;
                    setRowMenuPos({ left, top });
                    setRowMenuFor((cur) => (cur === p.id ? null : p.id));
                  }}
                >
                  ⋮
                </button>
              </div>
            );
          })}
          {projects.length === 0 ? <div className={styles.muted}>{t('picker.empty')}</div> : null}
          {projects.length > 0 && filteredProjects.length === 0 ? (
            <div className={styles.muted}>{t('picker.searchEmpty')}</div>
          ) : null}
        </div>
      </div>
      {rowMenuFor && rowMenuPos
        ? createPortal(
            <div
              role="menu"
              data-project-row-menu-root="1"
              className={styles.fileMenu}
              style={{ left: rowMenuPos.left, top: rowMenuPos.top }}
            >
              <button
                type="button"
                role="menuitem"
                className={styles.fileMenuItem}
                onClick={() => {
                  const id = rowMenuFor;
                  setRowMenuFor(null);
                  setRowMenuPos(null);
                  if (!id) return;
                  onRename(id);
                }}
              >
                {t('fileMenu.rename')}
              </button>
              <button
                type="button"
                role="menuitem"
                className={styles.fileMenuItemDanger}
                onClick={() => {
                  const id = rowMenuFor;
                  const proj = projects.find((x) => x.id === id);
                  setRowMenuFor(null);
                  setRowMenuPos(null);
                  if (!id || !proj) return;
                  setPendingDelete({ id, name: proj.name });
                }}
              >
                {t('common.delete')}
              </button>
            </div>,
            document.body,
          )
        : null}
      <ConfirmDeleteProjectModal
        open={pendingDelete !== null}
        projectName={pendingDelete?.name ?? ''}
        busy={deleteSubmitting}
        onCancel={() => {
          if (deleteSubmitting) return;
          setPendingDelete(null);
        }}
        onConfirm={async () => {
          if (!pendingDelete) return;
          const { id } = pendingDelete;
          setDeleteSubmitting(true);
          try {
            await onDelete(id);
            setPendingDelete(null);
          } catch (e) {
            setDeleteError(e instanceof Error ? e.message : String(e));
            setPendingDelete(null);
          } finally {
            setDeleteSubmitting(false);
          }
        }}
      />
      <SimpleMessageModal
        open={deleteError !== null}
        title={t('confirmDelete.failedTitle')}
        message={deleteError ?? ''}
        onClose={() => setDeleteError(null)}
      />
    </div>
  );
}

type SceneInspectorProps = {
  title: string;
  description: string;
  sideStoryStartNodes: { id: GraphNodeId; sideStoryLineTitle: string }[];
  previewAssetId: AssetId | null;
  previewAssetType: 'image' | 'video' | null;
  previewVideoAutostart: boolean;
  previewRotationDeg: 0 | 90 | 180 | 270;
  darkenScene: boolean;
  previewBusy: boolean;
  previewBusyText: string;
  mediaAssets: MediaAsset[];
  audioRefs: SceneAudioRef[];
  onAudioRefsChange: (next: SceneAudioRef[]) => void;
  onPreviewVideoAutostartChange: (next: boolean) => void;
  onDarkenSceneChange: (next: boolean) => void;
  onTitleChange: (v: string) => void;
  onDescriptionChange: (v: string) => void;
  onImportPreview: () => void;
  onClearPreview: () => void;
  onRotatePreview: (deg: 0 | 90 | 180 | 270) => void;
  onUploadMedia: () => void;
  onDropAudio: (filePaths: string[]) => void;
  onDropPreview: (filePath: string) => void;
  onSideStoryLineTitleChange: (graphNodeId: GraphNodeId, title: string) => void;
};

type CampaignInspectorProps = {
  mediaAssets: MediaAsset[];
  audioRefs: SceneAudioRef[];
  onAudioRefsChange: (next: SceneAudioRef[]) => void;
  onUploadAudio: () => void;
  onDropAudio: (filePaths: string[]) => void;
};

function CampaignInspector({
  mediaAssets,
  audioRefs,
  onAudioRefsChange,
  onUploadAudio,
  onDropAudio,
}: CampaignInspectorProps) {
  const { t } = useEditorI18n();
  const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]);
  const audioDrop = useFileDropZone({
    onDropPaths: onDropAudio,
    filterPaths: filterAudioFilePaths,
  });
  return (
    <div className={styles.sceneInspector}>
      <div className={styles.labelSm}>{t('campaign.label')}</div>
      <div
        className={[styles.audioDrop, audioDrop.dragOver ? styles.audioDropDragOver : ''].join(' ')}
        onDragEnter={audioDrop.onDragEnter}
        onDragLeave={audioDrop.onDragLeave}
        onDragOver={audioDrop.onDragOver}
        onDrop={audioDrop.onDrop}
      >
        {audioDrop.dragOver ? (
          <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div>
        ) : null}
        {mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
          <div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
        ) : (
          <div className={styles.audioList}>
            {mediaAssets
              .filter((a) => a.type === 'audio')
              .map((a) => (
                <div key={a.id} className={styles.audioRow}>
                  <span className={styles.audioName}>{a.originalName}</span>
                  <span className={styles.audioControls}>
                    <label className={styles.checkboxLabelSm}>
                      <input
                        type="checkbox"
                        checked={audioById.get(a.id)?.autoplay ?? false}
                        onChange={(e) => {
                          const next = audioRefs.map((x) =>
                            x.assetId === a.id ? { ...x, autoplay: e.target.checked } : x,
                          );
                          onAudioRefsChange(next);
                        }}
                      />
                      <span className={styles.spanXs}>{t('campaign.auto')}</span>
                    </label>
                    <label className={styles.checkboxLabelSm}>
                      <input
                        type="checkbox"
                        checked={audioById.get(a.id)?.loop ?? false}
                        onChange={(e) => {
                          const next = audioRefs.map((x) =>
                            x.assetId === a.id ? { ...x, loop: e.target.checked } : x,
                          );
                          onAudioRefsChange(next);
                        }}
                      />
                      <span className={styles.spanXs}>{t('campaign.loop')}</span>
                    </label>
                    <button
                      type="button"
                      title={t('campaign.removeTitle')}
                      className={styles.audioRemove}
                      onClick={() => {
                        onAudioRefsChange(audioRefs.filter((x) => x.assetId !== a.id));
                      }}
                    >
                      <svg
                        className={styles.audioRemoveIcon}
                        viewBox="0 0 24 24"
                        width={16}
                        height={16}
                        aria-hidden
                      >
                        <path
                          fill="currentColor"
                          d="M9 3h6a1 1 0 0 1 1 1v1h4v2H4V5h4V4a1 1 0 0 1 1-1zm1 5h2v9h-2V8zm4 0h2v9h-2V8zM7 8h2v9H7V8zm9-3H8v1h8V5zM6 21a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8H6v13z"
                        />
                      </svg>
                    </button>
                  </span>
                </div>
              ))}
          </div>
        )}
        <Button onClick={onUploadAudio}>{t('campaign.upload')}</Button>
      </div>
    </div>
  );
}

function SceneInspector({
  title,
  description,
  sideStoryStartNodes,
  previewAssetId,
  previewAssetType,
  previewVideoAutostart,
  previewRotationDeg,
  darkenScene,
  previewBusy,
  previewBusyText,
  mediaAssets,
  audioRefs,
  onAudioRefsChange,
  onPreviewVideoAutostartChange,
  onDarkenSceneChange,
  onTitleChange,
  onDescriptionChange,
  onImportPreview,
  onClearPreview,
  onRotatePreview,
  onUploadMedia,
  onDropAudio,
  onDropPreview,
  onSideStoryLineTitleChange,
}: SceneInspectorProps) {
  const { t } = useEditorI18n();
  const [descriptionModalOpen, setDescriptionModalOpen] = useState(false);
  const previewUrl = useAssetUrl(previewAssetId);
  const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]);
  const descriptionEmpty = isSceneDescriptionEmpty(description);
  const descriptionPreviewHtml = useMemo(
    () => (descriptionEmpty ? '' : sanitizeSceneDescriptionHtml(description)),
    [description, descriptionEmpty],
  );
  const previewDrop = useFileDropZone({
    disabled: previewBusy,
    onDropPaths: (paths) => {
      const filePath = pickFirstPreviewFilePath(paths);
      if (filePath) onDropPreview(filePath);
    },
  });
  const sceneAudioDrop = useFileDropZone({
    onDropPaths: onDropAudio,
    filterPaths: filterAudioFilePaths,
  });
  return (
    <div className={styles.sceneInspector}>
      <div className={styles.labelSm}>{t('scene.title')}</div>
      <Input value={title} onChange={onTitleChange} />
      <div className={styles.spacer8} />
      <div className={styles.labelRow}>
        <div className={styles.labelSm}>{t('scene.description')}</div>
        <Button
          iconOnly
          title={t('common.edit')}
          ariaLabel={t('common.edit')}
          onClick={() => setDescriptionModalOpen(true)}
        >
          <svg viewBox="0 0 24 24" width={14} height={14} aria-hidden>
            <path
              fill="currentColor"
              d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"
            />
          </svg>
        </Button>
      </div>
      {descriptionEmpty ? (
        <div className={styles.descriptionEmpty}>{t('scene.descriptionEmpty')}</div>
      ) : (
        <div
          className={styles.descriptionPreview}
          dangerouslySetInnerHTML={{ __html: descriptionPreviewHtml }}
        />
      )}
      {descriptionModalOpen ? (
        <SceneDescriptionModal
          initialHtml={description}
          onClose={() => setDescriptionModalOpen(false)}
          onSave={(html) => {
            onDescriptionChange(html);
            setDescriptionModalOpen(false);
          }}
        />
      ) : null}
      {sideStoryStartNodes.length > 0 ? (
        <>
          <div className={styles.spacer8} />
          {sideStoryStartNodes.map((gn) => (
            <div key={gn.id}>
              <div className={styles.labelSm}>{t('scene.sideStoryLineTitle')}</div>
              <Input
                value={gn.sideStoryLineTitle}
                onChange={(v) => onSideStoryLineTitleChange(gn.id, v)}
              />
              <div className={styles.spacer8} />
            </div>
          ))}
        </>
      ) : null}
      <div className={styles.spacer6} />
      <div className={styles.labelSm}>{t('scene.preview')}</div>
      <div className={styles.hint}>{t('scene.previewHint')}</div>
      <div
        className={[styles.previewBox, previewDrop.dragOver ? styles.previewBoxDragOver : ''].join(' ')}
        onDragEnter={previewDrop.onDragEnter}
        onDragLeave={previewDrop.onDragLeave}
        onDragOver={previewDrop.onDragOver}
        onDrop={previewDrop.onDrop}
      >
        {previewDrop.dragOver ? (
          <div className={styles.dropHintOverlay}>{t('drop.hintPreview')}</div>
        ) : null}
        {previewUrl && previewAssetType === 'image' ? (
          <div className={styles.previewFill}>
            <RotatedImage
              url={previewUrl}
              rotationDeg={previewRotationDeg}
              mode="cover"
              style={{ width: '100%', height: '100%' }}
            />
          </div>
        ) : previewUrl && previewAssetType === 'video' ? (
          <div className={styles.previewFill}>
            <video
              src={previewUrl}
              muted
              playsInline
              autoPlay={previewVideoAutostart}
              loop
              preload="metadata"
              className={styles.videoCover}
            />
          </div>
        ) : (
          <div className={styles.previewEmpty}>{t('scene.previewEmpty')}</div>
        )}
        {previewBusy ? (
          <div className={styles.previewBusyOverlay} aria-live="polite">
            <div className={styles.previewBusyModal}>
              <div className={styles.previewSpinner} aria-hidden />
              <div className={styles.previewBusyText}>{previewBusyText}</div>
            </div>
          </div>
        ) : null}
      </div>
      <div className={styles.actionsRow}>
        <Button variant="primary" disabled={previewBusy} onClick={onImportPreview}>
          {previewAssetId ? t('scene.change') : t('campaign.upload')}
        </Button>
        {previewAssetId ? <Button onClick={onClearPreview}>{t('scene.clear')}</Button> : null}
        {previewAssetId && previewAssetType === 'video' ? (
          <label className={styles.checkboxLabel}>
            <input
              type="checkbox"
              checked={previewVideoAutostart}
              onChange={(e) => onPreviewVideoAutostartChange(e.target.checked)}
            />
            <span className={styles.spanSm}>{t('scene.autostart')}</span>
          </label>
        ) : null}
        {previewAssetId && previewAssetType === 'image' ? (
          <Button
            onClick={() => {
              const next = ((previewRotationDeg + 90) % 360) as 0 | 90 | 180 | 270;
              onRotatePreview(next);
            }}
          >
            {t('scene.rotate')}
          </Button>
        ) : null}
      </div>
      {previewAssetId && previewAssetType === 'image' ? (
        <>
          <div className={styles.spacer6} />
          <label className={styles.checkboxLabel}>
            <input
              type="checkbox"
              checked={darkenScene}
              onChange={(e) => onDarkenSceneChange(e.target.checked)}
            />
            <span className={styles.spanSm}>{t('scene.darkenScene')}</span>
          </label>
          <div className={styles.spacer6} />
          <Button
            onClick={() => {
              void getDndApi().invoke(ipcChannels.windows.openSceneEditor, {});
            }}
          >
            Редактор сцены
          </Button>
        </>
      ) : null}
      <div className={styles.spacer6} />
      <div className={styles.labelSm}>{t('scene.audio')}</div>
      <div
        className={[styles.audioDrop, sceneAudioDrop.dragOver ? styles.audioDropDragOver : ''].join(' ')}
        onDragEnter={sceneAudioDrop.onDragEnter}
        onDragLeave={sceneAudioDrop.onDragLeave}
        onDragOver={sceneAudioDrop.onDragOver}
        onDrop={sceneAudioDrop.onDrop}
      >
        {sceneAudioDrop.dragOver ? (
          <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div>
        ) : null}
        {mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
          <div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
        ) : (
          <div className={styles.audioList}>
            {mediaAssets
              .filter((a) => a.type === 'audio')
              .map((a) => (
                <div key={a.id} className={styles.audioRow}>
                  <span className={styles.audioName}>{a.originalName}</span>
                  <span className={styles.audioControls}>
                    <label className={styles.checkboxLabelSm}>
                      <input
                        type="checkbox"
                        checked={audioById.get(a.id)?.autoplay ?? false}
                        onChange={(e) => {
                          const next = audioRefs.map((x) =>
                            x.assetId === a.id ? { ...x, autoplay: e.target.checked } : x,
                          );
                          onAudioRefsChange(next);
                        }}
                      />
                      <span className={styles.spanXs}>{t('campaign.auto')}</span>
                    </label>
                    <label className={styles.checkboxLabelSm}>
                      <input
                        type="checkbox"
                        checked={audioById.get(a.id)?.loop ?? false}
                        onChange={(e) => {
                          const next = audioRefs.map((x) =>
                            x.assetId === a.id ? { ...x, loop: e.target.checked } : x,
                          );
                          onAudioRefsChange(next);
                        }}
                      />
                      <span className={styles.spanXs}>{t('campaign.loop')}</span>
                    </label>
                    <button
                      type="button"
                      title={t('scene.removeTitle')}
                      className={styles.audioRemove}
                      onClick={() => {
                        onAudioRefsChange(audioRefs.filter((x) => x.assetId !== a.id));
                      }}
                    >
                      <svg
                        className={styles.audioRemoveIcon}
                        viewBox="0 0 24 24"
                        width={16}
                        height={16}
                        aria-hidden
                      >
                        <path
                          fill="currentColor"
                          d="M9 3h6a1 1 0 0 1 1 1v1h4v2H4V5h4V4a1 1 0 0 1 1-1zm1 5h2v9h-2V8zm4 0h2v9h-2V8zM7 8h2v9H7V8zm9-3H8v1h8V5zM6 21a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8H6v13z"
                        />
                      </svg>
                    </button>
                  </span>
                </div>
              ))}
          </div>
        )}
        <Button onClick={onUploadMedia}>{t('campaign.upload')}</Button>
      </div>
      <div className={styles.spacer6} />
      <div className={styles.labelSm}>{t('scene.branching')}</div>
      <div className={styles.hintBlock}>{t('scene.branchingHint')}</div>
    </div>
  );
}

type SceneListCardProps = {
  scene: SceneCard;
  reorderEnabled: boolean;
  listDragActive: boolean;
  dropPlace: 'before' | 'after' | null;
  isDragging: boolean;
  onSelect: () => void;
  onDeleteScene: (sceneId: SceneId) => void;
  onDragListStart: (sceneId: SceneId) => void;
  onDragListEnd: () => void;
  onDragListOver: (targetId: SceneId, place: 'before' | 'after') => void;
  onDropListReorder: (draggedId: SceneId, targetId: SceneId, place: 'before' | 'after') => void;
};

function SceneListCard({
  scene,
  reorderEnabled,
  listDragActive,
  dropPlace,
  isDragging,
  onSelect,
  onDeleteScene,
  onDragListStart,
  onDragListEnd,
  onDragListOver,
  onDropListReorder,
}: SceneListCardProps) {
  const { t } = useEditorI18n();
  const thumbUrl = useAssetUrl(scene.previewThumbAssetId);
  const previewUrl = useAssetUrl(scene.previewAssetId);
  const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);

  useEffect(() => {
    if (!menu) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') setMenu(null);
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [menu]);

  const menuPos = useMemo(() => {
    if (!menu) return null;
    const pad = 8;
    const mw = 180;
    const mh = 48;
    return {
      x: Math.max(pad, Math.min(menu.x, window.innerWidth - mw - pad)),
      y: Math.max(pad, Math.min(menu.y, window.innerHeight - mh - pad)),
    };
  }, [menu]);

  const cardClass = [
    styles.sceneCard,
    scene.active ? styles.sceneCardActive : '',
    isDragging ? styles.sceneCardDragging : '',
    dropPlace === 'before' ? styles.sceneCardDropBefore : '',
    dropPlace === 'after' ? styles.sceneCardDropAfter : '',
  ]
    .filter(Boolean)
    .join(' ');

  return (
    <div
      draggable
      className={cardClass}
      onDragStart={(e) => {
        // Сразу блокируем файловый drop колонки (до React re-render).
        onDragListStart(scene.id);
        e.dataTransfer.setData(DND_SCENE_ID_MIME, scene.id);
        e.dataTransfer.effectAllowed = reorderEnabled ? 'copyMove' : 'copy';
      }}
      onDragEnd={() => onDragListEnd()}
      onDragOver={(e) => {
        if (!reorderEnabled || !listDragActive) return;
        e.preventDefault();
        e.stopPropagation();
        e.dataTransfer.dropEffect = 'move';
        const rect = e.currentTarget.getBoundingClientRect();
        const place: 'before' | 'after' = e.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
        onDragListOver(scene.id, place);
      }}
      onDrop={(e) => {
        if (!reorderEnabled || !listDragActive) return;
        const draggedId = e.dataTransfer.getData(DND_SCENE_ID_MIME);
        if (!draggedId) return;
        e.preventDefault();
        e.stopPropagation();
        const rect = e.currentTarget.getBoundingClientRect();
        const place: 'before' | 'after' = e.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
        onDropListReorder(draggedId as SceneId, scene.id, place);
      }}
      onClick={onSelect}
      role="button"
      tabIndex={0}
      onKeyDown={(e) => {
        if (e.key === 'Enter' || e.key === ' ') onSelect();
      }}
    >
      <div className={thumbUrl || previewUrl ? styles.sceneThumb : styles.sceneThumbEmpty}>
        {thumbUrl ? (
          <div className={styles.sceneThumbInner}>
            <RotatedImage
              url={thumbUrl}
              rotationDeg={scene.previewRotationDeg}
              mode="cover"
              loading="lazy"
              decoding="async"
            />
          </div>
        ) : previewUrl && scene.previewAssetType === 'image' ? (
          <div className={styles.sceneThumbInner}>
            <RotatedImage
              url={previewUrl}
              rotationDeg={scene.previewRotationDeg}
              mode="cover"
              loading="lazy"
              decoding="async"
            />
          </div>
        ) : previewUrl && scene.previewAssetType === 'video' ? (
          <div className={styles.sceneThumbInner}>
            <video
              src={previewUrl}
              muted
              playsInline
              preload="metadata"
              draggable={false}
              className={styles.sceneThumbVideo}
              onLoadedData={(e) => {
                const v = e.currentTarget;
                try {
                  v.currentTime = 0;
                  v.pause();
                } catch {
                  // ignore
                }
              }}
            />
          </div>
        ) : (
          <div className={styles.sceneThumbEmptyInner} aria-hidden />
        )}
      </div>
      <div className={styles.sceneCardBody}>
        <div className={styles.sceneCardHeader}>
          {scene.active ? <div className={styles.badgeCurrent}>{t('sceneCard.current')}</div> : null}
          <button
            type="button"
            aria-label={t('sceneCard.menu')}
            className={styles.sceneMenuBtn}
            onMouseDown={(e) => e.stopPropagation()}
            onClick={(e) => {
              e.stopPropagation();
              setMenu({ x: e.clientX, y: e.clientY });
            }}
          >
            ⋮
          </button>
        </div>
        <div className={styles.sceneCardTitle}>{scene.title}</div>
      </div>
      {menu && menuPos
        ? createPortal(
            <>
              <button
                type="button"
                aria-label={t('common.closeMenu')}
                className={styles.menuBackdrop}
                onClick={() => setMenu(null)}
              />
              <div
                role="menu"
                tabIndex={-1}
                className={styles.sceneCtxMenu}
                style={{ left: menuPos.x, top: menuPos.y }}
                onClick={(e) => e.stopPropagation()}
                onKeyDown={(e) => {
                  if (e.key === 'Escape') setMenu(null);
                }}
              >
                <button
                  type="button"
                  role="menuitem"
                  className={styles.sceneCtxDanger}
                  onClick={() => {
                    onDeleteScene(scene.id);
                    setMenu(null);
                  }}
                >
                  {t('common.delete')}
                </button>
              </div>
            </>,
            document.body,
          )
        : null}
    </div>
  );
}
