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

import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import type {
  NpcDisposition,
  NpcId,
  ProjectNpc,
  SceneGrid,
  SceneNpcToken,
  SceneToken,
  SceneTrap,
  SceneTrapType,
  TokenId,
} from '../../shared/types';
import {
  asSceneNpcTokenId,
  clampSceneNpcTokenSizeN,
  DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
} from '../../shared/types/appPlayers';
import {
  normalizeNpcDisposition,
  npcDispositionRingColor,
  otherNpcDispositions,
} from '../../shared/types/npcDisposition';
import {
  asSceneTokenId,
  asTokenId,
  clampSceneTokenSizeN,
  DEFAULT_SCENE_TOKEN_SIZE_N,
} from '../../shared/types/appTokens';
import {
  clampSceneGridSizeN,
  DEFAULT_SCENE_GRID,
  SCENE_GRID_SIZE_MAX,
  SCENE_GRID_SIZE_MIN,
  sceneGridTokenFitFactor,
  sceneGridTypeLabelRu,
} from '../../shared/types/sceneGrid';
import {
  asSceneTrapId,
  DEFAULT_SCENE_TRAP_SIZE_N,
  SCENE_TRAP_TYPES,
  trapTypeLabelRu,
} from '../../shared/types/sceneTraps';
import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
import editorStyles from '../editor/EditorApp.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import { EllipsisText } from '../shared/ui/EllipsisText';
import ellipsisStyles from '../shared/ui/ellipsisText.module.css';
import { ContainedVideo } from '../shared/ContainedVideo';
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
import { RotatedImage } from '../shared/RotatedImage';
import { TokenPathsOverlay } from '../shared/tokens/TokenPathsOverlay';
import { useAppTokens } from '../shared/tokens/useAppTokens';
import { TrapGlyph } from '../shared/traps/TrapGlyph';
import { Button, Input, Select } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { sampleTokenPathAtDistance } from '../../shared/types/tokenPath';

import styles from './SceneEditorApp.module.css';
import { SceneTokenMarker } from './SceneTokenMarker';
import { TokenEditModal } from './TokenEditModal';
import { TOKEN_DND_MIME, TokenTile } from './TokenTile';

function dispositionMakeKey(d: NpcDisposition): string {
  if (d === 'hostile') return 'npcs.makeHostile';
  if (d === 'friendly') return 'npcs.makeFriendly';
  return 'npcs.makeNeutral';
}
function isTypingTarget(el: EventTarget | null): boolean {
  if (!(el instanceof HTMLElement)) return false;
  const tag = el.tagName;
  if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
  return el.isContentEditable;
}

type LocalView = { scale: number; ox: number; oy: number };
type Selection = { kind: 'trap' | 'token' | 'npcToken'; id: string } | null;

type DragMode =
  | { kind: 'pan'; lastX: number; lastY: number }
  | {
      kind: 'moveTrap';
      trapId: string;
      startNx: number;
      startNy: number;
      pointerNx: number;
      pointerNy: number;
    }
  | { kind: 'resizeTrap'; trapId: string; startSize: number; startDist: number }
  | {
      kind: 'moveToken';
      tokenId: string;
      startNx: number;
      startNy: number;
      pointerNx: number;
      pointerNy: number;
    }
  | { kind: 'resizeToken'; tokenId: string; startSize: number; startDist: number }
  | {
      kind: 'moveNpcToken';
      tokenId: string;
      startNx: number;
      startNy: number;
      pointerNx: number;
      pointerNy: number;
    }
  | { kind: 'resizeNpcToken'; tokenId: string; startSize: number; startDist: number }
  | {
      kind: 'rotateToken';
      tokenId: string;
      startRotation: number;
      startPointerAngle: number;
      centerClientX: number;
      centerClientY: number;
    }
  | null;

function randomId(prefix: string): string {
  return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
}

function pointerAngleDeg(cx: number, cy: number, x: number, y: number): number {
  return (Math.atan2(y - cy, x - cx) * 180) / Math.PI;
}

function shortestAngleDelta(fromDeg: number, toDeg: number): number {
  let d = toDeg - fromDeg;
  while (d > 180) d -= 360;
  while (d < -180) d += 360;
  return d;
}

const SCENE_NPC_DND_MIME = 'application/x-dnd-scene-npc-id';

function SceneNpcMarker({
  placement,
  npc,
  left,
  top,
  sizePx,
  selected,
  onSelect,
  onContextMenu,
  onMovePointerDown,
  onResizePointerDown,
}: {
  placement: SceneNpcToken;
  npc: ProjectNpc;
  left: number;
  top: number;
  sizePx: number;
  selected: boolean;
  onSelect: () => void;
  onContextMenu: (e: React.MouseEvent) => void;
  onMovePointerDown: (e: React.PointerEvent) => void;
  onResizePointerDown: (e: React.PointerEvent) => void;
}) {
  const imageUrl = useAssetUrl(npc.avatarAssetId);
  const disposition = normalizeNpcDisposition(placement.disposition ?? npc.disposition);
  return (
    <div
      data-testid={`scene-npc-token-${placement.id}`}
      className={[styles.sceneNpcToken, selected ? styles.sceneTokenSelected : ''].filter(Boolean).join(' ')}
      style={{ left, top, width: sizePx, height: sizePx }}
      onContextMenu={(e) => {
        e.preventDefault();
        e.stopPropagation();
        onContextMenu(e);
      }}
      onPointerDown={(e) => {
        if (e.button !== 0) return;
        onSelect();
        onMovePointerDown(e);
      }}
    >
      <PlayerTokenView
        name={npc.name}
        imageUrl={imageUrl}
        ringColor={npcDispositionRingColor(disposition)}
        imageOffset={npc.imageOffset}
        imageScale={npc.imageScale}
        sizePx={sizePx}
      />
      {selected ? <div className={styles.handle} onPointerDown={onResizePointerDown} /> : null}
    </div>
  );
}

function NpcPaletteTile({ npc }: { npc: ProjectNpc }) {
  const imageUrl = useAssetUrl(npc.avatarAssetId);
  const disposition = normalizeNpcDisposition(npc.disposition);
  return (
    <div
      className={styles.npcTile}
      data-testid={`scene-npc-tile-${npc.id}`}
      draggable
      onDragStart={(e) => {
        e.dataTransfer.setData(SCENE_NPC_DND_MIME, npc.id);
        e.dataTransfer.effectAllowed = 'copy';
      }}
      title={npc.name}
    >
      <PlayerTokenView
        name={npc.name}
        imageUrl={imageUrl}
        ringColor={npcDispositionRingColor(disposition)}
        imageOffset={npc.imageOffset}
        imageScale={npc.imageScale}
        sizePx={70}
      />
    </div>
  );
}

export function SceneEditorApp() {
  const api = getDndApi();
  const { t } = useEditorI18n();
  const appTokens = useAppTokens();
  const [session, setSession] = useState<SessionState | null>(null);
  const [trapsOpen, setTrapsOpen] = useState(false);
  const [gridOpen, setGridOpen] = useState(false);
  const [tokensOpen, setTokensOpen] = useState(false);
  const [npcsOpen, setNpcsOpen] = useState(false);
  const [tokenSearch, setTokenSearch] = useState('');
  const [npcSearch, setNpcSearch] = useState('');
  const [tokenModal, setTokenModal] = useState<
    { mode: 'create' } | { mode: 'edit'; tokenId: TokenId } | null
  >(null);
  const [pendingDeleteToken, setPendingDeleteToken] = useState<{ id: TokenId; name: string } | null>(null);
  const [npcCtxMenu, setNpcCtxMenu] = useState<{
    x: number;
    y: number;
    placementId: string;
  } | null>(null);
  const [tokenCtxMenu, setTokenCtxMenu] = useState<{
    x: number;
    y: number;
    placementId: string;
  } | null>(null);
  const [selected, setSelected] = useState<Selection>(null);
  const [view, setView] = useState<LocalView>({ scale: 1, ox: 0.5, oy: 0.5 });
  const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
  const hostRef = useRef<HTMLDivElement | null>(null);
  const dragRef = useRef<DragMode>(null);
  const saveTrapsTimerRef = useRef(0);
  const saveTokensTimerRef = useRef(0);
  const saveNpcTokensTimerRef = useRef(0);
  const saveGridTimerRef = useRef(0);
  const spaceDownRef = useRef(false);

  const project = session?.project ?? null;
  const sceneId = project?.currentSceneId ?? null;
  const scene = sceneId && project ? project.scenes[sceneId] : undefined;
  const url = useAssetUrl(scene?.previewAssetId ?? null);
  const rot = scene?.previewRotationDeg ?? 0;
  const [localTraps, setLocalTraps] = useState<SceneTrap[]>([]);
  const [localTokens, setLocalTokens] = useState<SceneToken[]>([]);
  const [localNpcTokens, setLocalNpcTokens] = useState<SceneNpcToken[]>([]);
  const [localGrid, setLocalGrid] = useState<SceneGrid>({ ...DEFAULT_SCENE_GRID });
  const trapsRef = useRef<SceneTrap[]>([]);
  const tokensRef = useRef<SceneToken[]>([]);
  const npcTokensRef = useRef<SceneNpcToken[]>([]);

  useEffect(() => {
    trapsRef.current = localTraps;
    tokensRef.current = localTokens;
    npcTokensRef.current = localNpcTokens;
  }, [localNpcTokens, localTokens, localTraps]);

  useEffect(() => {
    setLocalTraps(scene?.traps ?? []);
    setLocalTokens((scene?.tokens ?? []).filter((t) => appTokens.some((a) => a.id === t.tokenId)));
    setLocalNpcTokens(
      (scene?.npcTokens ?? []).filter((t) => project?.npcs.some((npc) => npc.id === t.npcId)),
    );
    setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
    setSelected(null);
    setView({ scale: 1, ox: 0.5, oy: 0.5 });
  }, [sceneId, scene?.previewAssetId]);

  useEffect(() => {
    if (dragRef.current) return;
    setLocalTraps(scene?.traps ?? []);
  }, [scene?.traps]);

  useEffect(() => {
    if (dragRef.current) return;
    const known = new Set(appTokens.map((t) => t.id));
    setLocalTokens((scene?.tokens ?? []).filter((t) => known.has(t.tokenId)));
  }, [scene?.tokens, appTokens]);

  useEffect(() => {
    if (dragRef.current) return;
    const known = new Set((project?.npcs ?? []).map((npc) => npc.id));
    setLocalNpcTokens((scene?.npcTokens ?? []).filter((t) => known.has(t.npcId)));
  }, [scene?.npcTokens, project?.npcs]);

  useEffect(() => {
    if (dragRef.current) return;
    setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
  }, [scene?.grid]);

  useEffect(() => {
    void api.invoke(ipcChannels.project.get, {}).then(({ project: p }) => {
      setSession({ project: p, currentSceneId: p?.currentSceneId ?? null });
    });
    return api.on(ipcChannels.session.stateChanged, ({ state }) => {
      setSession(state);
    });
  }, [api]);

  const persistTraps = useCallback(
    (next: SceneTrap[]) => {
      if (!sceneId) return;
      setLocalTraps(next);
      trapsRef.current = next;
      if (saveTrapsTimerRef.current) window.clearTimeout(saveTrapsTimerRef.current);
      saveTrapsTimerRef.current = window.setTimeout(() => {
        void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { traps: next } });
      }, 120);
    },
    [api, sceneId],
  );

  const persistTokens = useCallback(
    (next: SceneToken[]) => {
      if (!sceneId) return;
      setLocalTokens(next);
      tokensRef.current = next;
      if (saveTokensTimerRef.current) window.clearTimeout(saveTokensTimerRef.current);
      saveTokensTimerRef.current = window.setTimeout(() => {
        void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { tokens: next } });
      }, 120);
    },
    [api, sceneId],
  );

  const persistNpcTokens = useCallback(
    (next: SceneNpcToken[]) => {
      if (!sceneId) return;
      setLocalNpcTokens(next);
      npcTokensRef.current = next;
      if (saveNpcTokensTimerRef.current) window.clearTimeout(saveNpcTokensTimerRef.current);
      saveNpcTokensTimerRef.current = window.setTimeout(() => {
        void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { npcTokens: next } });
      }, 120);
    },
    [api, sceneId],
  );

  const persistGrid = useCallback(
    (next: SceneGrid) => {
      if (!sceneId) return;
      setLocalGrid(next);
      if (saveGridTimerRef.current) window.clearTimeout(saveGridTimerRef.current);
      saveGridTimerRef.current = window.setTimeout(() => {
        void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { grid: next } });
      }, 120);
    },
    [api, sceneId],
  );

  useEffect(() => {
    const onKeyDown = (e: KeyboardEvent) => {
      if (isTypingTarget(e.target)) return;
      if (e.code === 'Space') spaceDownRef.current = true;
      if ((e.key === 'Delete' || e.key === 'Backspace') && selected && sceneId) {
        e.preventDefault();
        if (selected.kind === 'trap') {
          persistTraps(trapsRef.current.filter((t) => t.id !== selected.id));
        } else if (selected.kind === 'token') {
          persistTokens(tokensRef.current.filter((t) => t.id !== selected.id));
        } else {
          persistNpcTokens(npcTokensRef.current.filter((t) => t.id !== selected.id));
        }
        setSelected(null);
      }
    };
    const onKeyUp = (e: KeyboardEvent) => {
      if (isTypingTarget(e.target)) return;
      if (e.code === 'Space') spaceDownRef.current = false;
    };
    window.addEventListener('keydown', onKeyDown);
    window.addEventListener('keyup', onKeyUp);
    return () => {
      window.removeEventListener('keydown', onKeyDown);
      window.removeEventListener('keyup', onKeyUp);
    };
  }, [persistNpcTokens, persistTraps, persistTokens, sceneId, selected]);

  const hostToNorm = (clientX: number, clientY: number): { x: number; y: number } | null => {
    const host = hostRef.current;
    const cr = contentRect;
    if (!host || !cr || cr.w < 1 || cr.h < 1) return null;
    const r = host.getBoundingClientRect();
    return {
      x: Math.max(0, Math.min(1, (clientX - (r.left + cr.x)) / cr.w)),
      y: Math.max(0, Math.min(1, (clientY - (r.top + cr.y)) / cr.h)),
    };
  };

  const viewCamera = useMemo(() => view, [view]);

  useEffect(() => {
    const host = hostRef.current;
    if (!host) return;
    const nativeWheel = (e: WheelEvent) => {
      e.preventDefault();
      const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
      const cr = contentRect;
      if (!cr) {
        setView((v) => {
          const nextScale = Math.max(1, Math.min(8, v.scale * factor));
          if (nextScale <= 1.001) return { scale: 1, ox: 0.5, oy: 0.5 };
          return { ...v, scale: nextScale };
        });
        return;
      }
      const r = host.getBoundingClientRect();
      setView((v) => {
        const containW = cr.w / Math.max(1e-6, v.scale);
        const containH = cr.h / Math.max(1e-6, v.scale);
        return sceneViewZoomAt(v, {
          hostW: r.width,
          hostH: r.height,
          containW,
          containH,
          hostX: e.clientX - r.left,
          hostY: e.clientY - r.top,
          factor,
        });
      });
    };
    host.addEventListener('wheel', nativeWheel, { passive: false });
    return () => host.removeEventListener('wheel', nativeWheel);
  }, [contentRect]);

  const addTrapAt = (type: SceneTrapType, nx: number, ny: number) => {
    const trap: SceneTrap = {
      id: asSceneTrapId(randomId('trap')),
      type,
      nx,
      ny,
      sizeN: DEFAULT_SCENE_TRAP_SIZE_N,
    };
    setSelected({ kind: 'trap', id: trap.id });
    persistTraps([...trapsRef.current, trap]);
  };

  const addTokenAt = (tokenId: TokenId, nx: number, ny: number) => {
    const placement: SceneToken = {
      id: asSceneTokenId(randomId('stoken')),
      tokenId,
      nx,
      ny,
      sizeN: DEFAULT_SCENE_TOKEN_SIZE_N,
      rotationDeg: 0,
    };
    setSelected({ kind: 'token', id: placement.id });
    persistTokens([...tokensRef.current, placement]);
  };

  const addNpcTokenAt = (npcId: NpcId, nx: number, ny: number) => {
    const gridCellSize =
      localGrid.enabled && Number.isFinite(localGrid.sizeN)
        ? clampSceneNpcTokenSizeN(localGrid.sizeN)
        : DEFAULT_SCENE_NPC_TOKEN_SIZE_N;
    const npc = project?.npcs.find((item) => item.id === npcId);
    const placement: SceneNpcToken = {
      id: asSceneNpcTokenId(randomId('snpc')),
      npcId,
      nx,
      ny,
      sizeN: gridCellSize,
      disposition: normalizeNpcDisposition(npc?.disposition),
    };
    setSelected({ kind: 'npcToken', id: placement.id });
    persistNpcTokens([...npcTokensRef.current, placement]);
  };

  const onStageDrop = (e: React.DragEvent) => {
    e.preventDefault();
    const p = hostToNorm(e.clientX, e.clientY);
    if (!p) return;
    const tokenId = e.dataTransfer.getData(TOKEN_DND_MIME);
    if (tokenId) {
      addTokenAt(asTokenId(tokenId), p.x, p.y);
      return;
    }
    const npcId = e.dataTransfer.getData(SCENE_NPC_DND_MIME);
    if (USERS_BRANCH_FEATURES_ENABLED && npcId) {
      addNpcTokenAt(npcId as NpcId, p.x, p.y);
      return;
    }
    const type = e.dataTransfer.getData('application/x-dnd-trap-type') as SceneTrapType;
    if (!SCENE_TRAP_TYPES.includes(type)) return;
    addTrapAt(type, p.x, p.y);
  };

  const updateTrap = (id: string, patch: Partial<SceneTrap>) => {
    persistTraps(trapsRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t)));
  };

  const updateToken = (id: string, patch: Partial<SceneToken>) => {
    persistTokens(tokensRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t)));
  };

  const updateNpcToken = (id: string, patch: Partial<SceneNpcToken>) => {
    persistNpcTokens(npcTokensRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t)));
  };

  const filteredTokens = useMemo(() => {
    const q = tokenSearch.trim().toLowerCase();
    if (!q) return appTokens;
    return appTokens.filter((t) => t.name.toLowerCase().includes(q));
  }, [appTokens, tokenSearch]);

  const filteredNpcs = useMemo(() => {
    const q = npcSearch.trim().toLowerCase();
    const npcs = project?.npcs ?? [];
    return q ? npcs.filter((npc) => npc.name.toLowerCase().includes(q)) : npcs;
  }, [npcSearch, project?.npcs]);

  const editingToken =
    tokenModal?.mode === 'edit' ? (appTokens.find((t) => t.id === tokenModal.tokenId) ?? null) : null;
  const isImage = scene?.previewAssetType === 'image' && Boolean(url);
  const isVideo = scene?.previewAssetType === 'video' && Boolean(url);
  const hasMapMedia = isImage || isVideo;

  return (
    <div className={styles.page}>
      <aside className={styles.sidebar}>
        <EllipsisText
          text={scene?.title ?? 'Сцена'}
          className={[styles.sideTitle, ellipsisStyles.root].join(' ')}
        />
        <div className={styles.hint}>
          Колесо — зум. СКМ / Space+ЛКМ — пан. Delete — удалить выбранное.
        </div>

        <div className={styles.accordionScroll}>
        <div className={styles.accordion}>
          <button type="button" className={styles.accordionHead} onClick={() => setGridOpen((v) => !v)}>
            Сетка {gridOpen ? '▾' : '▸'}
          </button>
          {gridOpen ? (
            <div className={styles.gridPanel}>
              <label className={styles.checkRow}>
                <input
                  type="checkbox"
                  checked={localGrid.enabled}
                  onChange={(e) => persistGrid({ ...localGrid, enabled: e.target.checked })}
                />
                <span>Наложить сетку</span>
              </label>
              <label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
                <span className={styles.fieldLabel}>Тип</span>
                <Select
                  disabled={!localGrid.enabled}
                  value={localGrid.type}
                  ariaLabel="Тип сетки"
                  options={[
                    { value: 'square', label: sceneGridTypeLabelRu('square') },
                    { value: 'hex', label: sceneGridTypeLabelRu('hex') },
                  ]}
                  onChange={(next) =>
                    persistGrid({
                      ...localGrid,
                      type: next === 'hex' ? 'hex' : 'square',
                    })
                  }
                />
              </label>
              <label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
                <span className={styles.fieldLabel}>Цвет</span>
                <input
                  type="color"
                  className={styles.colorInput}
                  disabled={!localGrid.enabled}
                  value={localGrid.color}
                  onChange={(e) => persistGrid({ ...localGrid, color: e.target.value })}
                  aria-label="Цвет сетки"
                />
              </label>
              <label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
                <span className={styles.fieldLabel}>
                  Размер <span className={styles.fieldValue}>{Math.round(localGrid.sizeN * 100)}</span>
                </span>
                <input
                  type="range"
                  className={styles.range}
                  disabled={!localGrid.enabled}
                  min={SCENE_GRID_SIZE_MIN}
                  max={SCENE_GRID_SIZE_MAX}
                  step={0.005}
                  value={localGrid.sizeN}
                  onChange={(e) =>
                    persistGrid({
                      ...localGrid,
                      sizeN: clampSceneGridSizeN(Number(e.currentTarget.value)),
                    })
                  }
                />
              </label>
            </div>
          ) : null}
        </div>

        {USERS_BRANCH_FEATURES_ENABLED ? (
        <div className={styles.accordion} data-testid="scene-npc-accordion">
          <button type="button" className={styles.accordionHead} onClick={() => setNpcsOpen((v) => !v)}>
            НПС {npcsOpen ? '▾' : '▸'}
          </button>
          {npcsOpen ? (
            <div className={styles.tokensPanel}>
              <Input
                value={npcSearch}
                onChange={setNpcSearch}
                placeholder="Поиск НПС…"
                onKeyDown={(e) => e.stopPropagation()}
              />
              <div className={styles.tokenGrid}>
                {filteredNpcs.map((npc) => (
                  <NpcPaletteTile key={npc.id} npc={npc} />
                ))}
                {filteredNpcs.length === 0 ? <div className={styles.hint}>Нет НПС</div> : null}
              </div>
            </div>
          ) : null}
        </div>
        ) : null}

        <div className={styles.accordion}>
          <button type="button" className={styles.accordionHead} onClick={() => setTokensOpen((v) => !v)}>
            Неигровые токены {tokensOpen ? '▾' : '▸'}
          </button>
          {tokensOpen ? (
            <div className={styles.tokensPanel}>
              <div className={styles.tokensToolbar}>
                <Button onClick={() => setTokenModal({ mode: 'create' })}>Добавить</Button>
              </div>
              <Input
                value={tokenSearch}
                onChange={setTokenSearch}
                placeholder="Поиск…"
                onKeyDown={(e) => e.stopPropagation()}
              />
              <div className={styles.tokenGrid}>
                {filteredTokens.length === 0 ? (
                  <div className={styles.hint}>Нет токенов</div>
                ) : (
                  filteredTokens.map((token) => (
                    <TokenTile
                      key={token.id}
                      token={token}
                      onEdit={() => setTokenModal({ mode: 'edit', tokenId: token.id })}
                      onDelete={() => setPendingDeleteToken({ id: token.id, name: token.name })}
                    />
                  ))
                )}
              </div>
            </div>
          ) : null}
        </div>

        <div className={styles.accordion}>
          <button type="button" className={styles.accordionHead} onClick={() => setTrapsOpen((v) => !v)}>
            Ловушки {trapsOpen ? '▾' : '▸'}
          </button>
          {trapsOpen ? (
            <div className={styles.palette}>
              {SCENE_TRAP_TYPES.map((type) => (
                <div
                  key={type}
                  className={styles.paletteItem}
                  draggable
                  onDragStart={(e) => {
                    e.dataTransfer.setData('application/x-dnd-trap-type', type);
                    e.dataTransfer.effectAllowed = 'copy';
                  }}
                  title="Перетащите на карту"
                >
                  <TrapGlyph type={type} size={22} />
                  <span className={styles.paletteLabel}>{trapTypeLabelRu(type)}</span>
                </div>
              ))}
            </div>
          ) : null}
        </div>

        <div className={styles.clearSceneBtn}>
          <Button
            data-testid="scene-clear-btn"
            disabled={
              !sceneId || (localTraps.length === 0 && localTokens.length === 0 && localNpcTokens.length === 0)
            }
            onClick={() => {
              persistTraps([]);
              persistTokens([]);
              persistNpcTokens([]);
              setSelected(null);
            }}
          >
            Очистить сцену
          </Button>
        </div>
        </div>
      </aside>

      <div className={styles.stage}>
        {!hasMapMedia ? (
          <div className={styles.empty}>Нужно изображение или видео сцены</div>
        ) : (
          <div
            ref={hostRef}
            className={styles.viewport}
            onDragOver={(e) => e.preventDefault()}
            onDrop={onStageDrop}
            onPointerDown={(e) => {
              if (e.button === 1 || (e.button === 0 && spaceDownRef.current)) {
                e.preventDefault();
                (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
                dragRef.current = { kind: 'pan', lastX: e.clientX, lastY: e.clientY };
              }
            }}
            onPointerMove={(e) => {
              const d = dragRef.current;
              if (!d) return;
              if (d.kind === 'pan') {
                const cr = contentRect;
                if (!cr) return;
                const dx = e.clientX - d.lastX;
                const dy = e.clientY - d.lastY;
                d.lastX = e.clientX;
                d.lastY = e.clientY;
                setView((v) => {
                  const containW = cr.w / Math.max(1e-6, v.scale);
                  const containH = cr.h / Math.max(1e-6, v.scale);
                  return sceneViewPanBy(v, { containW, containH, dx, dy });
                });
                return;
              }
              if (d.kind === 'moveTrap') {
                const p = hostToNorm(e.clientX, e.clientY);
                if (!p) return;
                updateTrap(d.trapId, {
                  nx: Math.max(0, Math.min(1, d.startNx + (p.x - d.pointerNx))),
                  ny: Math.max(0, Math.min(1, d.startNy + (p.y - d.pointerNy))),
                });
                return;
              }
              if (d.kind === 'resizeTrap') {
                const p = hostToNorm(e.clientX, e.clientY);
                const trap = trapsRef.current.find((t) => t.id === d.trapId);
                if (!p || !trap) return;
                const dist = Math.hypot(p.x - trap.nx, p.y - trap.ny);
                const ratio = d.startDist > 1e-6 ? dist / d.startDist : 1;
                updateTrap(d.trapId, {
                  sizeN: Math.max(0.02, Math.min(0.45, d.startSize * ratio)),
                });
                return;
              }
              if (d.kind === 'moveToken') {
                const p = hostToNorm(e.clientX, e.clientY);
                if (!p) return;
                updateToken(d.tokenId, {
                  nx: Math.max(0, Math.min(1, d.startNx + (p.x - d.pointerNx))),
                  ny: Math.max(0, Math.min(1, d.startNy + (p.y - d.pointerNy))),
                });
                return;
              }
              if (d.kind === 'resizeToken') {
                const p = hostToNorm(e.clientX, e.clientY);
                const tok = tokensRef.current.find((t) => t.id === d.tokenId);
                if (!p || !tok) return;
                const dist = Math.hypot(p.x - tok.nx, p.y - tok.ny);
                const ratio = d.startDist > 1e-6 ? dist / d.startDist : 1;
                updateToken(d.tokenId, {
                  sizeN: clampSceneTokenSizeN(d.startSize * ratio),
                });
                return;
              }
              if (d.kind === 'moveNpcToken') {
                const p = hostToNorm(e.clientX, e.clientY);
                if (!p) return;
                updateNpcToken(d.tokenId, {
                  nx: Math.max(0, Math.min(1, d.startNx + (p.x - d.pointerNx))),
                  ny: Math.max(0, Math.min(1, d.startNy + (p.y - d.pointerNy))),
                });
                return;
              }
              if (d.kind === 'resizeNpcToken') {
                const p = hostToNorm(e.clientX, e.clientY);
                const tok = npcTokensRef.current.find((t) => t.id === d.tokenId);
                if (!p || !tok) return;
                const dist = Math.hypot(p.x - tok.nx, p.y - tok.ny);
                const ratio = d.startDist > 1e-6 ? dist / d.startDist : 1;
                updateNpcToken(d.tokenId, {
                  sizeN: clampSceneNpcTokenSizeN(d.startSize * ratio),
                });
                return;
              }
              if (d.kind === 'rotateToken') {
                const ang = pointerAngleDeg(d.centerClientX, d.centerClientY, e.clientX, e.clientY);
                const delta = shortestAngleDelta(d.startPointerAngle, ang);
                updateToken(d.tokenId, { rotationDeg: d.startRotation + delta });
              }
            }}
            onPointerUp={() => {
              dragRef.current = null;
            }}
            onPointerCancel={() => {
              dragRef.current = null;
            }}
          >
            {isImage ? (
              <RotatedImage
                url={url!}
                rotationDeg={rot}
                mode="contain"
                viewCamera={viewCamera}
                onContentRectChange={setContentRect}
              />
            ) : (
              <ContainedVideo
                url={url!}
                rotationDeg={rot}
                muted
                playsInline
                loop
                preload="metadata"
                viewCamera={viewCamera}
                onContentRectChange={setContentRect}
              />
            )}
            <SceneGridOverlay grid={localGrid} viewport={contentRect} />
            <TokenPathsOverlay
              tokens={localTokens}
              npcTokens={USERS_BRANCH_FEATURES_ENABLED ? localNpcTokens : []}
              viewport={contentRect}
              mode="always"
            />
            {contentRect
              ? localTokens.map((tok) => {
                  const minDim = Math.min(contentRect.w, contentRect.h);
                  const sizePx = Math.max(16, tok.sizeN * minDim);
                  const left = contentRect.x + tok.nx * contentRect.w;
                  const top = contentRect.y + tok.ny * contentRect.h;
                  return (
                    <SceneTokenMarker
                      key={tok.id}
                      token={tok}
                      left={left}
                      top={top}
                      sizePx={sizePx}
                      selected={selected?.kind === 'token' && selected.id === tok.id}
                      onSelect={() => setSelected({ kind: 'token', id: tok.id })}
                      onContextMenu={(e) => {
                        e.preventDefault();
                        e.stopPropagation();
                        setSelected({ kind: 'token', id: tok.id });
                        setTokenCtxMenu({ x: e.clientX, y: e.clientY, placementId: tok.id });
                      }}
                      onMovePointerDown={(e) => {
                        if (spaceDownRef.current) return;
                        e.stopPropagation();
                        const p = hostToNorm(e.clientX, e.clientY);
                        if (!p) return;
                        (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
                        dragRef.current = {
                          kind: 'moveToken',
                          tokenId: tok.id,
                          startNx: tok.nx,
                          startNy: tok.ny,
                          pointerNx: p.x,
                          pointerNy: p.y,
                        };
                      }}
                      onResizePointerDown={(e) => {
                        e.stopPropagation();
                        e.preventDefault();
                        const p = hostToNorm(e.clientX, e.clientY);
                        if (!p) return;
                        (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
                        dragRef.current = {
                          kind: 'resizeToken',
                          tokenId: tok.id,
                          startSize: tok.sizeN,
                          startDist: Math.max(1e-4, Math.hypot(p.x - tok.nx, p.y - tok.ny)),
                        };
                      }}
                      onRotatePointerDown={(e) => {
                        e.stopPropagation();
                        e.preventDefault();
                        const host = hostRef.current;
                        if (!host || !contentRect) return;
                        const r = host.getBoundingClientRect();
                        const cx = r.left + contentRect.x + tok.nx * contentRect.w;
                        const cy = r.top + contentRect.y + tok.ny * contentRect.h;
                        (e.currentTarget as HTMLButtonElement).setPointerCapture(e.pointerId);
                        dragRef.current = {
                          kind: 'rotateToken',
                          tokenId: tok.id,
                          startRotation: tok.rotationDeg,
                          startPointerAngle: pointerAngleDeg(cx, cy, e.clientX, e.clientY),
                          centerClientX: cx,
                          centerClientY: cy,
                        };
                      }}
                    />
                  );
                })
              : null}
            {USERS_BRANCH_FEATURES_ENABLED && contentRect
              ? localNpcTokens.map((tok) => {
                  const npc = project?.npcs.find((item) => item.id === tok.npcId);
                  if (!npc) return null;
                  const minDim = Math.min(contentRect.w, contentRect.h);
                  const sizePx = Math.max(16, tok.sizeN * sceneGridTokenFitFactor(localGrid) * minDim);
                  const left = contentRect.x + tok.nx * contentRect.w;
                  const top = contentRect.y + tok.ny * contentRect.h;
                  return (
                    <SceneNpcMarker
                      key={tok.id}
                      placement={tok}
                      npc={npc}
                      left={left}
                      top={top}
                      sizePx={sizePx}
                      selected={selected?.kind === 'npcToken' && selected.id === tok.id}
                      onSelect={() => setSelected({ kind: 'npcToken', id: tok.id })}
                      onContextMenu={(e) => {
                        setSelected({ kind: 'npcToken', id: tok.id });
                        setNpcCtxMenu({ x: e.clientX, y: e.clientY, placementId: tok.id });
                      }}
                      onMovePointerDown={(e) => {
                        if (spaceDownRef.current) return;
                        e.stopPropagation();
                        const p = hostToNorm(e.clientX, e.clientY);
                        if (!p) return;
                        (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
                        dragRef.current = {
                          kind: 'moveNpcToken',
                          tokenId: tok.id,
                          startNx: tok.nx,
                          startNy: tok.ny,
                          pointerNx: p.x,
                          pointerNy: p.y,
                        };
                      }}
                      onResizePointerDown={(e) => {
                        e.stopPropagation();
                        e.preventDefault();
                        const p = hostToNorm(e.clientX, e.clientY);
                        if (!p) return;
                        (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
                        dragRef.current = {
                          kind: 'resizeNpcToken',
                          tokenId: tok.id,
                          startSize: tok.sizeN,
                          startDist: Math.max(1e-4, Math.hypot(p.x - tok.nx, p.y - tok.ny)),
                        };
                      }}
                    />
                  );
                })
              : null}
            {contentRect
              ? localTraps.map((trap) => {
                  const minDim = Math.min(contentRect.w, contentRect.h);
                  const sizePx = Math.max(16, trap.sizeN * minDim);
                  const left = contentRect.x + trap.nx * contentRect.w;
                  const top = contentRect.y + trap.ny * contentRect.h;
                  const isSelected = selected?.kind === 'trap' && selected.id === trap.id;
                  return (
                    <div
                      key={trap.id}
                      className={[styles.trap, isSelected ? styles.trapSelected : '']
                        .filter(Boolean)
                        .join(' ')}
                      style={{ left, top, width: sizePx, height: sizePx }}
                      onPointerDown={(e) => {
                        if (e.button !== 0 || spaceDownRef.current) return;
                        e.stopPropagation();
                        setSelected({ kind: 'trap', id: trap.id });
                        const p = hostToNorm(e.clientX, e.clientY);
                        if (!p) return;
                        (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
                        dragRef.current = {
                          kind: 'moveTrap',
                          trapId: trap.id,
                          startNx: trap.nx,
                          startNy: trap.ny,
                          pointerNx: p.x,
                          pointerNy: p.y,
                        };
                      }}
                    >
                      <TrapGlyph type={trap.type} size={Math.max(14, sizePx * 0.55)} />
                      {trap.label && trap.label.trim().toLowerCase() !== 'свободная' ? (
                        <div className={styles.trapLabel}>{trap.label}</div>
                      ) : null}
                      {isSelected ? (
                        <div
                          className={styles.handle}
                          onPointerDown={(e) => {
                            e.stopPropagation();
                            e.preventDefault();
                            const p = hostToNorm(e.clientX, e.clientY);
                            if (!p) return;
                            (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
                            dragRef.current = {
                              kind: 'resizeTrap',
                              trapId: trap.id,
                              startSize: trap.sizeN,
                              startDist: Math.max(1e-4, Math.hypot(p.x - trap.nx, p.y - trap.ny)),
                            };
                          }}
                        />
                      ) : null}
                    </div>
                  );
                })
              : null}
          </div>
        )}
      </div>

      <TokenEditModal
        open={tokenModal !== null}
        initial={editingToken}
        existingNames={appTokens.map((t) => t.name)}
        onClose={() => setTokenModal(null)}
        onSaved={() => setTokenModal(null)}
      />

      {pendingDeleteToken
        ? createPortal(
            <>
              <button
                type="button"
                aria-label="Закрыть"
                className={editorStyles.modalBackdrop}
                onClick={() => setPendingDeleteToken(null)}
              />
              <div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
                <div className={editorStyles.modalHeader}>
                  <div className={editorStyles.modalTitle}>Удалить токен</div>
                  <button
                    type="button"
                    className={editorStyles.modalClose}
                    onClick={() => setPendingDeleteToken(null)}
                  >
                    ×
                  </button>
                </div>
                <div>
                  Удалить токен «{pendingDeleteToken.name}» из пула? Он также будет убран с текущей сцены.
                </div>
                <div className={editorStyles.modalFooter}>
                  <Button onClick={() => setPendingDeleteToken(null)}>Отмена</Button>
                  <Button
                    variant="primary"
                    onClick={() => {
                      const id = pendingDeleteToken.id;
                      setPendingDeleteToken(null);
                      void api.invoke(ipcChannels.tokens.delete, { id });
                      persistTokens(tokensRef.current.filter((t) => t.tokenId !== id));
                    }}
                  >
                    Удалить
                  </Button>
                </div>
              </div>
            </>,
            document.body,
          )
        : null}

      {tokenCtxMenu
        ? createPortal(
            <>
              <button
                type="button"
                className={styles.ctxMenuBackdrop}
                aria-label={t('common.close')}
                onClick={() => setTokenCtxMenu(null)}
              />
              <div
                className={styles.ctxMenu}
                style={{ left: tokenCtxMenu.x, top: tokenCtxMenu.y }}
                role="menu"
              >
                <button
                  type="button"
                  className={styles.ctxItem}
                  role="menuitem"
                  onClick={() => {
                    const id = tokenCtxMenu.placementId;
                    setTokenCtxMenu(null);
                    void api
                      .invoke(ipcChannels.windows.openTokenPathEditor, {
                        kind: 'token',
                        placementId: id,
                      })
                      .catch((err) => console.error('[sceneEditor] openTokenPathEditor', err));
                  }}
                >
                  Указать движение
                </button>
                {(() => {
                  const tok = localTokens.find((item) => item.id === tokenCtxMenu.placementId);
                  if (!tok?.path || tok.path.points.length < 2) return null;
                  return (
                    <button
                      type="button"
                      className={styles.ctxItem}
                      role="menuitem"
                      onClick={() => {
                        const sample = sampleTokenPathAtDistance(tok.path!, 0);
                        if (sample) {
                          updateToken(tok.id, {
                            nx: sample.nx,
                            ny: sample.ny,
                            ...(tok.path?.facingMode === 'fixed'
                              ? { rotationDeg: tok.path.fixedRotationDeg }
                              : { rotationDeg: sample.rotationDeg }),
                          });
                        }
                        setTokenCtxMenu(null);
                      }}
                    >
                      Сбросить на старт пути
                    </button>
                  );
                })()}
                <button
                  type="button"
                  className={styles.ctxItemDanger}
                  role="menuitem"
                  onClick={() => {
                    const id = tokenCtxMenu.placementId;
                    setTokenCtxMenu(null);
                    persistTokens(tokensRef.current.filter((item) => item.id !== id));
                    setSelected((current) =>
                      current?.kind === 'token' && current.id === id ? null : current,
                    );
                  }}
                >
                  {t('common.delete')}
                </button>
              </div>
            </>,
            document.body,
          )
        : null}

      {USERS_BRANCH_FEATURES_ENABLED && npcCtxMenu
        ? createPortal(
            <>
              <button
                type="button"
                className={styles.ctxMenuBackdrop}
                aria-label={t('common.close')}
                onClick={() => setNpcCtxMenu(null)}
              />
              <div
                className={styles.ctxMenu}
                style={{ left: npcCtxMenu.x, top: npcCtxMenu.y }}
                role="menu"
              >
                <button
                  type="button"
                  className={styles.ctxItem}
                  role="menuitem"
                  onClick={() => {
                    const id = npcCtxMenu.placementId;
                    setNpcCtxMenu(null);
                    void api
                      .invoke(ipcChannels.windows.openTokenPathEditor, {
                        kind: 'npcToken',
                        placementId: id,
                      })
                      .catch((err) => console.error('[sceneEditor] openTokenPathEditor', err));
                  }}
                >
                  Указать движение
                </button>
                {(() => {
                  const tok = localNpcTokens.find((item) => item.id === npcCtxMenu.placementId);
                  if (!tok?.path || tok.path.points.length < 2) return null;
                  return (
                    <button
                      type="button"
                      className={styles.ctxItem}
                      role="menuitem"
                      onClick={() => {
                        const sample = sampleTokenPathAtDistance(tok.path!, 0);
                        if (sample) {
                          updateNpcToken(tok.id, { nx: sample.nx, ny: sample.ny });
                        }
                        setNpcCtxMenu(null);
                      }}
                    >
                      Сбросить на старт пути
                    </button>
                  );
                })()}
                <button
                  type="button"
                  className={styles.ctxItemDanger}
                  role="menuitem"
                  onClick={() => {
                    const id = npcCtxMenu.placementId;
                    setNpcCtxMenu(null);
                    persistNpcTokens(npcTokensRef.current.filter((item) => item.id !== id));
                    setSelected((current) => (current?.id === id ? null : current));
                  }}
                >
                  {t('common.delete')}
                </button>
                {(() => {
                  const tok = localNpcTokens.find((item) => item.id === npcCtxMenu.placementId);
                  const npc = tok ? project?.npcs.find((item) => item.id === tok.npcId) : undefined;
                  if (!tok || !npc) return null;
                  const current = normalizeNpcDisposition(tok.disposition ?? npc.disposition);
                  return otherNpcDispositions(current).map((d) => (
                    <button
                      key={d}
                      type="button"
                      className={styles.ctxItem}
                      role="menuitem"
                      onClick={() => {
                        updateNpcToken(tok.id, { disposition: d });
                        setNpcCtxMenu(null);
                      }}
                    >
                      {t(dispositionMakeKey(d))}
                    </button>
                  ));
                })()}
              </div>
            </>,
            document.body,
          )
        : null}
    </div>
  );
}
