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

import { ipcChannels, type UpdaterCheckResponse } 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, MediaAsset, Project, ProjectId, 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 styles from './EditorApp.module.css';
import { buildNextSceneCardById } from './graph/sceneCardById';
import {
  DND_SCENE_ID_MIME,
  SceneGraph,
  type SceneGraphSceneCard,
  type SceneGraphUiStrings,
} from './graph/SceneGraph';
import { useEditorI18n } from './i18n/EditorI18nContext';
import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals';
import type { ProjectNoticeCode } from './state/projectState';
import { useProjectState } from './state/projectState';

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 [renameOpen, setRenameOpen] = useState(false);
  const [exportModalOpen, setExportModalOpen] = useState(false);
  const [previewBusy, setPreviewBusy] = useState(false);
  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 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 sceneCardById = useStableSceneCardById(state.project);
  const graphUi = useMemo<SceneGraphUiStrings>(
    () => ({
      badgeStart: t('graph.badgeStart'),
      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'),
      delete: t('common.delete'),
    }),
    [t],
  );
  const fileMenuBtnRef = useRef<HTMLButtonElement | null>(null);
  const projectMenuBtnRef = useRef<HTMLButtonElement | null>(null);
  const settingsMenuBtnRef = 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 scenes = useMemo<SceneCard[]>(() => {
    const p = state.project;
    if (!p) return [];
    const createdAtSortKey = (sceneId: string): number => {
      // sceneId создаётся как `${prefix}_${rand}_${Date.now().toString(16)}`
      const last = sceneId.split('_').at(-1) ?? '';
      const n = Number.parseInt(last, 16);
      return Number.isFinite(n) ? n : 0;
    };
    return Object.values(p.scenes)
      .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,
      }))
      .sort((a, b) => createdAtSortKey(b.id) - createdAtSortKey(a.id));
  }, [state.project, state.selectedSceneId]);

  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 graphStartSceneId = useMemo(() => {
    const p = state.project;
    if (!p) return null;
    const gn = p.sceneGraphNodes.find((n) => n.isStartScene);
    return gn?.sceneId ?? null;
  }, [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 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(() => {
    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 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}
      <LayoutShell
        bodyOverlay={bodyOverlay}
        topBar={
          <div className={styles.topBarRow}>
            <button
              type="button"
              className={styles.brandButton}
              onClick={() => {
                void actions.closeProject();
              }}
              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);
                  setSettingsMenuOpen((v) => {
                    const next = !v;
                    if (next) setSettingsLangSubOpen(false);
                    return next;
                  });
                }}
              >
                {t('top.settings')}
              </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);
                  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);
                    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={!licenseActive || !graphStartGraphNodeId}
                  title={
                    !licenseActive
                      ? t('top.afterLicense')
                      : graphStartSceneId
                        ? undefined
                        : t('top.setStartScene')
                  }
                  onClick={() => {
                    if (!licenseActive || !graphStartGraphNodeId) return;
                    void (async () => {
                      await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, {
                        graphNodeId: graphStartGraphNodeId,
                      });
                      await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {});
                    })();
                  }}
                >
                  {t('top.run')}
                </Button>
              ) : null}
            </div>
          </div>
        }
        left={
          <div className={styles.editorSidebar}>
            {state.project ? (
              <>
                <div className={styles.gridTools}>
                  <Input value={query} onChange={setQuery} placeholder={t('scenes.search')} />
                  <Button variant="primary" onClick={() => void actions.createScene()}>
                    {t('scenes.new')}
                  </Button>
                </div>
                <div className={styles.spacer14} />
                <div className={styles.sidebarScroll}>
                  <div className={styles.sceneListGrid}>
                    {filtered.map((s) => (
                      <SceneListCard
                        key={s.id}
                        scene={s}
                        onSelect={() => void actions.selectScene(s.id)}
                        onDeleteScene={(id) => void actions.deleteScene(id)}
                      />
                    ))}
                  </div>
                </div>
              </>
            ) : (
              <ProjectPicker
                projects={state.projects}
                licenseActive={licenseActive}
                onCreate={actions.createProject}
                onOpen={actions.openProject}
                onDelete={actions.deleteProject}
              />
            )}
          </div>
        }
        center={
          <div className={styles.editorGraphHost}>
            {state.project ? (
              <SceneGraph
                graphUi={graphUi}
                sceneGraphNodes={state.project.sceneGraphNodes}
                sceneGraphEdges={state.project.sceneGraphEdges}
                sceneCardById={sceneCardById}
                currentSceneId={state.selectedSceneId}
                onCurrentSceneChange={(id) => void actions.selectScene(id)}
                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)}
                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),
                          });
                        }
                      })();
                    }}
                  />
                  <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];
                      return (
                        <SceneInspector
                          title={sc?.title ?? ''}
                          description={sc?.description ?? ''}
                          previewAssetId={sc?.previewAssetId ?? null}
                          previewAssetType={sc?.previewAssetType ?? null}
                          previewVideoAutostart={sc?.previewVideoAutostart ?? false}
                          previewRotationDeg={sc?.previewRotationDeg ?? 0}
                          previewBusy={previewBusy}
                          mediaAssets={sceneMediaAssets}
                          audioRefs={sceneAudioRefs}
                          onAudioRefsChange={(next) =>
                            void actions.updateScene(sid, { media: { audios: next } })
                          }
                          onPreviewVideoAutostartChange={(next) =>
                            void actions.updateScene(sid, { previewVideoAutostart: next })
                          }
                          onTitleChange={(title) => void actions.updateScene(sid, { title })}
                          onDescriptionChange={(description) =>
                            void actions.updateScene(sid, { description })
                          }
                          onImportPreview={() => {
                            setPreviewBusy(true);
                            void (async () => {
                              try {
                                await actions.importScenePreview(sid);
                              } catch (e) {
                                setAppNotice({
                                  title: t('common.error'),
                                  message: e instanceof Error ? e.message : String(e),
                                });
                              } finally {
                                setPreviewBusy(false);
                              }
                            })();
                          }}
                          onClearPreview={() => void actions.clearScenePreview(sid)}
                          onRotatePreview={(previewRotationDeg) =>
                            void actions.updateScene(sid, { previewRotationDeg })
                          }
                          onUploadMedia={() => void actions.importMediaToScene(sid)}
                        />
                      );
                    })()
                  ) : (
                    <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}
      />
      {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={() => {
                  setProjectMenuOpen(false);
                  void actions.closeProject();
                }}
              >
                {t('projectMenu.home')}
              </button>
              <button
                type="button"
                role="menuitem"
                className={styles.fileMenuItem}
                onClick={() => {
                  setProjectMenuOpen(false);
                  void actions.importProject();
                }}
              >
                {t('projectMenu.import')}
              </button>
              <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)}
          onSave={async (name, fileBaseName) => {
            await actions.renameProject(name, fileBaseName);
          }}
        />
      ) : null}
      <ExportProjectModal
        open={exportModalOpen}
        projects={state.projects}
        initialProjectId={exportModalInitialProjectId}
        onClose={() => setExportModalOpen(false)}
        onExport={async (projectId) => {
          await actions.exportProject(projectId);
        }}
      />
      <CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} />
      <SimpleMessageModal
        open={appNotice !== null}
        title={appNotice?.title ?? t('common.message')}
        message={appNotice?.message ?? ''}
        onClose={() => setAppNotice(null)}
      />
    </>
  );
}

type ExportProjectModalProps = {
  open: boolean;
  projects: { id: ProjectId; name: string; fileName: string }[];
  initialProjectId: ProjectId | null;
  onClose: () => void;
  onExport: (projectId: ProjectId) => Promise<void>;
};

function ExportProjectModal({
  open,
  projects,
  initialProjectId,
  onClose,
  onExport,
}: ExportProjectModalProps) {
  const { t } = useEditorI18n();
  const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!open) return;
    setProjectId(initialProjectId);
    setSaving(false);
    setError(null);
  }, [initialProjectId, open]);

  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;

  const canExport = projectId !== null && projects.some((p) => p.id === projectId);

  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('export.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('export.project')}</div>
          <select
            className={styles.selectInput}
            value={projectId ?? ''}
            onChange={(e) => setProjectId((e.target.value as ProjectId) || null)}
            disabled={projects.length === 0}
          >
            {projects.map((p) => (
              <option key={p.id} value={p.id}>
                {p.name} ({p.fileName})
              </option>
            ))}
          </select>
          <div className={styles.muted}>{t('export.hint')}</div>
        </div>

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

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

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

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);

  useEffect(() => {
    if (!open) return;
    startTransition(() => {
      setPhase('checking');
      setRes(null);
    });
    void getDndApi()
      .invoke(ipcChannels.updater.check, {})
      .then((r) => {
        setRes(r);
        setPhase('done');
      })
      .catch((e: unknown) => {
        const message = e instanceof Error ? e.message : String(e);
        setRes({ outcome: 'error', message });
        setPhase('done');
      });
  }, [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 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>
        <div className={styles.modalFooter}>
          {showUpdateIdle ? (
            <>
              <Button variant="ghost" disabled={downloadBusy} onClick={onClose}>
                {t('common.close')}
              </Button>
              <Button
                variant="primary"
                disabled={downloadBusy}
                onClick={() => {
                  setDownloadBusy(true);
                  void getDndApi()
                    .invoke(ipcChannels.updater.downloadAndRestart, {})
                    .then((r) => {
                      if (!r.ok) {
                        setDownloadBusy(false);
                        setRes({ outcome: 'error', message: r.message });
                      }
                    })
                    .catch((e: unknown) => {
                      setDownloadBusy(false);
                      setRes({
                        outcome: 'error',
                        message: e instanceof Error ? e.message : String(e),
                      });
                    });
                }}
              >
                {t('updates.download')}
              </Button>
            </>
          ) : showUpdateBusy ? (
            <Button variant="primary" disabled>
              {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}>{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;
  onCreate: (name: string) => Promise<void>;
  onOpen: (id: ProjectId) => Promise<void>;
  onDelete: (id: ProjectId) => Promise<void>;
};

function ProjectPicker({ projects, licenseActive, onCreate, onOpen, onDelete }: ProjectPickerProps) {
  const { t, locale } = useEditorI18n();
  const [name, setName] = useState(() => t('picker.defaultName'));
  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);

  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]);

  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')} />
        <Button
          variant="primary"
          disabled={!licenseActive}
          title={!licenseActive ? t('top.afterLicense') : undefined}
          onClick={() => {
            if (!licenseActive) return;
            void onCreate(name);
          }}
        >
          {t('picker.create')}
        </Button>
      </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}>
        <div className={styles.projectList}>
          {projects.map((p) => (
            <div key={p.id} className={styles.projectCard}>
              <div
                className={styles.projectCardBody}
                onClick={() => {
                  if (!licenseActive) return;
                  void onOpen(p.id);
                }}
                role="button"
                tabIndex={0}
                title={!licenseActive ? t('picker.openDisabled') : undefined}
                onKeyDown={(e) => {
                  if (e.key === 'Enter' || e.key === ' ') {
                    if (!licenseActive) return;
                    void onOpen(p.id);
                  }
                }}
              >
                <div className={styles.projectCardName}>{p.name}</div>
                <div className={styles.projectCardMeta}>
                  {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}
                title={!licenseActive ? t('top.afterLicense') : undefined}
                onClick={(e) => {
                  e.stopPropagation();
                  if (!licenseActive) return;
                  const r = e.currentTarget.getBoundingClientRect();
                  const menuW = 220;
                  const left = Math.max(8, Math.min(r.right - menuW, window.innerWidth - menuW - 8));
                  setRowMenuPos({ left, top: r.bottom + 8 });
                  setRowMenuFor((cur) => (cur === p.id ? null : p.id));
                }}
              >
                ⋮
              </button>
            </div>
          ))}
          {projects.length === 0 ? <div className={styles.muted}>{t('picker.empty')}</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;
                  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;
  previewAssetId: AssetId | null;
  previewAssetType: 'image' | 'video' | null;
  previewVideoAutostart: boolean;
  previewRotationDeg: 0 | 90 | 180 | 270;
  previewBusy: boolean;
  mediaAssets: MediaAsset[];
  audioRefs: SceneAudioRef[];
  onAudioRefsChange: (next: SceneAudioRef[]) => void;
  onPreviewVideoAutostartChange: (next: boolean) => void;
  onTitleChange: (v: string) => void;
  onDescriptionChange: (v: string) => void;
  onImportPreview: () => void;
  onClearPreview: () => void;
  onRotatePreview: (deg: 0 | 90 | 180 | 270) => void;
  onUploadMedia: () => void;
};

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

function CampaignInspector({
  mediaAssets,
  audioRefs,
  onAudioRefsChange,
  onUploadAudio,
}: CampaignInspectorProps) {
  const { t } = useEditorI18n();
  const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]);
  return (
    <div className={styles.sceneInspector}>
      <div className={styles.labelSm}>{t('campaign.label')}</div>
      <div className={styles.audioDrop}>
        {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,
  previewAssetId,
  previewAssetType,
  previewVideoAutostart,
  previewRotationDeg,
  previewBusy,
  mediaAssets,
  audioRefs,
  onAudioRefsChange,
  onPreviewVideoAutostartChange,
  onTitleChange,
  onDescriptionChange,
  onImportPreview,
  onClearPreview,
  onRotatePreview,
  onUploadMedia,
}: SceneInspectorProps) {
  const { t } = useEditorI18n();
  const previewUrl = useAssetUrl(previewAssetId);
  const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]);
  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.labelSm}>{t('scene.description')}</div>
      <textarea
        className={styles.textarea}
        value={description}
        onChange={(e) => onDescriptionChange(e.target.value)}
      />
      <div className={styles.spacer6} />
      <div className={styles.labelSm}>{t('scene.preview')}</div>
      <div className={styles.hint}>{t('scene.previewHint')}</div>
      <div className={styles.previewBox}>
        {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}>{t('scene.previewBusy')}</div>
            </div>
          </div>
        ) : null}
      </div>
      <div className={styles.actionsRow}>
        <Button variant="primary" 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>
      <div className={styles.spacer6} />
      <div className={styles.labelSm}>{t('scene.audio')}</div>
      <div className={styles.audioDrop}>
        {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;
  onSelect: () => void;
  onDeleteScene: (sceneId: SceneId) => void;
};

function SceneListCard({ scene, onSelect, onDeleteScene }: 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 : ''].filter(Boolean).join(' ');
  return (
    <div
      draggable
      className={cardClass}
      onDragStart={(e) => {
        e.dataTransfer.setData(DND_SCENE_ID_MIME, scene.id);
        e.dataTransfer.effectAllowed = 'copy';
      }}
      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"
              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>
  );
}
