import { useState, useCallback, useEffect, useRef, lazy, Suspense } from 'react';
import { useBlocker } from 'react-router';
import { useTranslation } from 'react-i18next';
import type Konva from 'konva';
import { useEditor } from './context/EditorContext';
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
import { boundingBox } from './utils/geometry';
import { normalizeAngleDegrees } from './utils/angle';
import { EditorCanvas } from './EditorCanvas';
import { EditorToolbar } from './EditorToolbar';
import { PropertiesPanel } from './PropertiesPanel';
import { ElectricalPalette } from './panels/ElectricalPalette';
import { FurniturePalette } from './panels/FurniturePalette';
import { CableLengthStatus } from './panels/CableLengthStatus';
import { ProjectionPanel } from './projection/ProjectionPanel';
import { WallProjectionView } from './projection/WallProjectionView';
import { wallLength as computeWallLength } from './utils/wallUtils';
import { ExportDialog } from './export/ExportDialog';
import { importRoomFromJson } from './export/roomFormat';
import type { CameraPreset } from './three/CameraControls';

// Pixel density and padding used by the hidden projection renderers that
// populate the PDF export. Sized so each wall's stage matches its native
// aspect ratio (no vertical empty space inside the captured PNG). The
// extra ``OVERFLOW`` budget keeps annotations that end up just outside the
// wall rectangle (typical for labels next to outlets near a corner) inside
// the captured stage — WallProjectionView's projectionToPixel places
// content at (padding, padding), so the overflow budget sits on the right
// and bottom edges and is picked up by stage.toDataURL.
const EXPORT_PX_PER_M = 400;
const EXPORT_PROJ_PADDING = 80;
const EXPORT_PROJ_OVERFLOW_RIGHT_PX = 600; // ~1.5 m of label headroom
const EXPORT_PROJ_OVERFLOW_BOTTOM_PX = 240; // ~0.6 m of label headroom

const Room3DView = lazy(() =>
  import('./three/Room3DView').then((m) => ({ default: m.Room3DView })),
);
import {
  getRoomFull,
  bulkUpdateWalls,
  batchSyncOpenings,
  batchSyncElectrical,
  batchSyncFurniture,
  batchSyncAnnotations,
  updateRoom,
} from '../../api/client';
import type {
  CreateWallOpeningDto,
  UpdateWallOpeningDto,
  CreateElectricalItemDto,
  UpdateElectricalItemDto,
  CreateFurnitureItemDto,
  UpdateFurnitureItemDto,
  CreateAnnotationDto,
  UpdateAnnotationDto,
} from '@house-plan-maker/shared';
import styles from './room-editor-layout.module.css';

const AUTO_SAVE_DELAY_MS = 5000;

interface RoomEditorLayoutProps {
  readonly roomId: string;
}

export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
  const { t } = useTranslation();
  const { state, dispatch } = useEditor();
  const [isSaving, setIsSaving] = useState(false);
  const [isAutoSaving, setIsAutoSaving] = useState(false);
  const [saveError, setSaveError] = useState<string | null>(null);
  type ViewMode = '2d' | '3d' | 'projections';
  const [viewMode, setViewMode] = useState<ViewMode>('2d');
  const [showExport, setShowExport] = useState(false);
  const canvasContainerRef = useRef<HTMLDivElement>(null);
  // Start as null so the initial render doesn't use a seed 800×600 size —
  // the Stage (and the auto-fit effect) only kicks in after the container
  // has been measured, avoiding the multi-frame resize flicker on open.
  const [canvasSize, setCanvasSize] = useState<{ width: number; height: number } | null>(null);

  // ── Dirty tracking ──
  const [isDirty, setIsDirty] = useState(false);
  const lastSavedRef = useRef({
    walls: state.walls,
    openings: state.openings,
    electricalItems: state.electricalItems,
    furnitureItems: state.furnitureItems,
    room: state.room,
  });

  // Mark dirty when state diverges from last saved snapshot
  useEffect(() => {
    const saved = lastSavedRef.current;
    const dirty =
      state.walls !== saved.walls ||
      state.openings !== saved.openings ||
      state.electricalItems !== saved.electricalItems ||
      state.furnitureItems !== saved.furnitureItems ||
      state.room.floorType !== saved.room.floorType ||
      state.room.wallColor !== saved.room.wallColor ||
      state.room.wallFinish !== saved.room.wallFinish ||
      state.room.wallHeight !== saved.room.wallHeight ||
      state.room.plinthHeight !== saved.room.plinthHeight ||
      state.room.plinthThickness !== saved.room.plinthThickness ||
      state.room.stretchCeilingOffset !== saved.room.stretchCeilingOffset ||
      state.room.outletWidth !== saved.room.outletWidth ||
      state.room.outletHeight !== saved.room.outletHeight ||
      state.room.name !== saved.room.name;
    setIsDirty(dirty);
  }, [
    state.walls,
    state.openings,
    state.electricalItems,
    state.furnitureItems,
    state.room.floorType,
    state.room.wallColor,
    state.room.wallFinish,
    state.room.wallHeight,
    state.room.plinthHeight,
    state.room.plinthThickness,
    state.room.stretchCeilingOffset,
    state.room.outletWidth,
    state.room.outletHeight,
    state.room.name,
    state.room,
  ]);

  // Warn on browser close / refresh
  useEffect(() => {
    if (!isDirty) return;

    const handleBeforeUnload = (e: BeforeUnloadEvent) => {
      e.preventDefault();
    };

    window.addEventListener('beforeunload', handleBeforeUnload);
    return () => window.removeEventListener('beforeunload', handleBeforeUnload);
  }, [isDirty]);

  // Block in-app navigation via react-router
  const blocker = useBlocker(isDirty);

  useEffect(() => {
    if (blocker.state === 'blocked') {
      const leave = window.confirm(
        t('editor.unsavedChanges'),
      );
      if (leave) {
        blocker.proceed();
      } else {
        blocker.reset();
      }
    }
  }, [blocker]);

  // ── Export refs ──
  const mainStageRef = useRef<Konva.Stage | null>(null);
  const projectionStageMapRef = useRef<Map<string, Konva.Stage>>(new Map());
  const threeCanvasRef = useRef<HTMLCanvasElement | null>(null);
  const preset3DRef = useRef<((preset: CameraPreset) => void) | null>(null);

  // Once the 3D view has been requested (either by entering 3D mode or opening
  // the export dialog) keep it mounted off-screen so exports can always
  // capture a 3D image without a visible mode switch.
  const [mount3D, setMount3D] = useState(false);
  useEffect(() => {
    if (viewMode === '3d' || showExport) setMount3D(true);
  }, [viewMode, showExport]);

  const handleMainStageRef = useCallback((stage: Konva.Stage | null) => {
    mainStageRef.current = stage;
  }, []);

  const handleProjectionStageRef = useCallback((wallId: string, stage: Konva.Stage | null) => {
    if (stage) {
      projectionStageMapRef.current.set(wallId, stage);
    } else {
      projectionStageMapRef.current.delete(wallId);
    }
  }, []);

  // ── Export-only projection stages ──
  // The visible projection panel only mounts a single wall stage in 'tabs'
  // mode, which means PDF/"all-room-views" exports would lose the other walls.
  // We mount a hidden, per-wall copy of WallProjectionView while the export
  // dialog is open so the export code can always reach every wall.
  const exportProjectionStageMapRef = useRef<Map<string, Konva.Stage>>(new Map());
  const handleExportProjectionStageRef = useCallback(
    (wallId: string, stage: Konva.Stage | null) => {
      if (stage) {
        exportProjectionStageMapRef.current.set(wallId, stage);
      } else {
        exportProjectionStageMapRef.current.delete(wallId);
      }
    },
    [],
  );

  // ── Resize observer for canvas ──
  useEffect(() => {
    const container = canvasContainerRef.current;
    if (!container) return;

    const commitSize = (w: number, h: number): void => {
      const width = Math.floor(w);
      const height = Math.floor(h);
      if (width <= 0 || height <= 0) return;
      // Skip no-op updates so the auto-fit effect doesn't re-run on every
      // ResizeObserver tick that doesn't actually change the pixel size.
      setCanvasSize((prev) => {
        if (prev && prev.width === width && prev.height === height) return prev;
        return { width, height };
      });
    };

    const observer = new ResizeObserver((entries) => {
      for (const entry of entries) {
        commitSize(entry.contentRect.width, entry.contentRect.height);
      }
    });

    observer.observe(container);

    // Initial size
    const rect = container.getBoundingClientRect();
    commitSize(rect.width, rect.height);

    return () => observer.disconnect();
  }, []);

  // ── Auto-fit the room into the 2D canvas ──
  // Fires once the container has been measured and the room shape is
  // available, and again whenever either changes. Skips no-op reruns where
  // the canvas and room already match the last fit signature so we don't
  // flicker through multiple frames on open.
  const hasUserAdjustedViewRef = useRef(false);
  const lastFitSignatureRef = useRef<string>('');
  const lastDispatchedViewRef = useRef<{ zoom: number; panX: number; panY: number } | null>(null);

  useEffect(() => {
    if (viewMode !== '2d') return;
    if (!canvasSize) return;
    if (canvasSize.width <= 100 || canvasSize.height <= 100) return;
    if (state.room.shape.length === 0) return;

    const bbox = boundingBox(state.room.shape);
    const roomW = bbox.maxX - bbox.minX;
    const roomH = bbox.maxY - bbox.minY;
    if (roomW <= 0 || roomH <= 0) return;

    const signature = `${canvasSize.width}x${canvasSize.height}|${bbox.minX},${bbox.minY},${bbox.maxX},${bbox.maxY}`;

    // Already fit at this signature? Nothing to do.
    if (lastFitSignatureRef.current === signature) return;
    // User moved the camera → don't clobber their view until the room or
    // canvas actually changes dimensions (which gives a new signature).
    if (hasUserAdjustedViewRef.current && lastFitSignatureRef.current !== '') return;

    const padding = 80;
    const scaleX = (canvasSize.width - padding * 2) / roomW;
    const scaleY = (canvasSize.height - padding * 2) / roomH;
    const fitZoom = Math.min(scaleX, scaleY, 300);

    const centerX = (bbox.minX + bbox.maxX) / 2;
    const centerY = (bbox.minY + bbox.maxY) / 2;
    const panX = canvasSize.width / 2 - centerX * fitZoom;
    const panY = canvasSize.height / 2 - centerY * fitZoom;

    lastDispatchedViewRef.current = { zoom: fitZoom, panX, panY };
    // Single atomic reducer pass — produces one new state, not two, so the
    // ZoomPanContext can't emit an intermediate (newZoom, oldPan) frame.
    dispatch({ type: 'SET_VIEW', zoom: fitZoom, offset: { x: panX, y: panY } });
    lastFitSignatureRef.current = signature;
  }, [viewMode, canvasSize, state.room.shape, dispatch]);

  // Detect *manual* zoom/pan. Comparing against the values we just
  // dispatched prevents the auto-fit itself from flipping the flag.
  useEffect(() => {
    const last = lastDispatchedViewRef.current;
    if (!last) return;
    const EPS = 0.5;
    const cameFromAutoFit =
      Math.abs(state.zoom - last.zoom) < EPS &&
      Math.abs(state.panOffset.x - last.panX) < EPS &&
      Math.abs(state.panOffset.y - last.panY) < EPS;
    if (!cameFromAutoFit) {
      hasUserAdjustedViewRef.current = true;
    }
  }, [state.zoom, state.panOffset]);

  // ── Re-measure canvas when switching back to 2D view ──
  useEffect(() => {
    if (viewMode !== '2d') return;
    // Use requestAnimationFrame to measure after the style change is applied
    const rafId = requestAnimationFrame(() => {
      const container = canvasContainerRef.current;
      if (!container) return;
      const rect = container.getBoundingClientRect();
      if (rect.width > 0 && rect.height > 0) {
        setCanvasSize({ width: Math.floor(rect.width), height: Math.floor(rect.height) });
      }
    });
    return () => cancelAnimationFrame(rafId);
  }, [viewMode]);

  // ── Save handler (batch-optimized) ──
  const isSavingRef = useRef(false);

  const handleSave = useCallback(async () => {
    if (isSavingRef.current) return;
    isSavingRef.current = true;
    setIsSaving(true);
    setSaveError(null);

    try {
      // 0. Save room-level properties (floor, wall color, heights, name)
      await updateRoom(roomId, {
        name: state.room.name,
        floorType: state.room.floorType,
        wallColor: state.room.wallColor,
        wallFinish: state.room.wallFinish,
        wallHeight: state.room.wallHeight,
        plinthHeight: state.room.plinthHeight,
        plinthThickness: state.room.plinthThickness,
        stretchCeilingOffset: state.room.stretchCeilingOffset,
        outletWidth: state.room.outletWidth,
        outletHeight: state.room.outletHeight,
      });

      // 1. Save walls first (bulk replace) to get server-assigned wall IDs
      const wallDtos = state.walls.map((w) => ({
        startX: w.startX,
        startY: w.startY,
        endX: w.endX,
        endY: w.endY,
        thickness: w.thickness,
        direction: w.direction,
      }));
      const serverWalls = await bulkUpdateWalls(roomId, wallDtos);

      // Build a map from local wall to server wall by matching coordinates
      const wallIdMap = new Map<string, string>();
      for (const localWall of state.walls) {
        const match = serverWalls.find(
          (sw) =>
            Math.abs(sw.startX - localWall.startX) < 0.001 &&
            Math.abs(sw.startY - localWall.startY) < 0.001 &&
            Math.abs(sw.endX - localWall.endX) < 0.001 &&
            Math.abs(sw.endY - localWall.endY) < 0.001,
        );
        if (match) {
          wallIdMap.set(localWall.id, match.id);
        }
      }

      // 2. Fetch current server state once for diff computation
      const freshRoom = await getRoomFull(roomId);

      // 3. Compute openings — since bulkUpdateWalls CASCADE-deletes all openings,
      // we must re-create ALL openings with the new server wall IDs
      const openingsCreate: CreateWallOpeningDto[] = [];
      const openingsUpdate: { id: string; data: UpdateWallOpeningDto }[] = [];
      const openingsDelete: string[] = [];

      for (const opening of state.openings) {
        const serverWallId = wallIdMap.get(opening.wallId) ?? opening.wallId;
        openingsCreate.push({
          wallId: serverWallId,
          type: opening.type,
          positionAlongWall: opening.positionAlongWall,
          width: opening.width,
          height: opening.height,
          elevationFromFloor: opening.elevationFromFloor,
          openDirection: opening.openDirection,
          positionAnchor: opening.positionAnchor,
          gridCols: opening.gridCols,
          gridRows: opening.gridRows,
          slopeDepth: opening.slopeDepth,
          frameThickness: opening.frameThickness,
        });
      }
      // No updates or deletes needed — cascade already removed all server openings

      // 4. Electrical items — since wall IDs changed after bulk replace,
      // delete all existing and re-create with correct wall IDs
      const elecCreate: CreateElectricalItemDto[] = [];
      const elecUpdate: { id: string; data: UpdateElectricalItemDto }[] = [];
      const elecDelete: string[] = freshRoom.electricalItems.map((e) => e.id);

      for (const elec of state.electricalItems) {
        const serverWallId = elec.wallId ? (wallIdMap.get(elec.wallId) ?? elec.wallId) : null;
        elecCreate.push({
          type: elec.type,
          x: elec.x,
          y: elec.y,
          wallId: serverWallId,
          elevationFromFloor: elec.elevationFromFloor,
          rotation: normalizeAngleDegrees(elec.rotation ?? 0),
          count: elec.count,
          positionAnchor: elec.positionAnchor,
          label: elec.label,
          metadata: elec.metadata,
        });
      }

      // 5. Compute diffs for furniture
      const serverFurnIds = new Set(freshRoom.furnitureItems.map((f) => f.id));
      const localFurnIds = new Set(state.furnitureItems.map((f) => f.id));

      const furnCreate: CreateFurnitureItemDto[] = [];
      const furnUpdate: { id: string; data: UpdateFurnitureItemDto }[] = [];
      const furnDelete: string[] = [];

      for (const furn of state.furnitureItems) {
        if (furn.id.startsWith('local-')) {
          furnCreate.push({
            type: furn.type,
            x: furn.x,
            y: furn.y,
            width: furn.width,
            depth: furn.depth,
            height: furn.height,
            rotation: furn.rotation,
            elevationFromFloor: furn.elevationFromFloor,
            label: furn.label,
            showProjection: furn.showProjection ?? false,
            opacity: furn.opacity ?? 1,
            positionAnchor: furn.positionAnchor,
            metadata: furn.metadata ?? null,
          });
        } else if (serverFurnIds.has(furn.id)) {
          const serverFurn = freshRoom.furnitureItems.find((f) => f.id === furn.id);
          if (serverFurn) {
            const anchorChanged =
              serverFurn.positionAnchor.horizontal !== furn.positionAnchor.horizontal ||
              serverFurn.positionAnchor.vertical !== furn.positionAnchor.vertical;
            const metadataChanged =
              JSON.stringify(serverFurn.metadata ?? null) !== JSON.stringify(furn.metadata ?? null);
            const hasChanges =
              serverFurn.x !== furn.x ||
              serverFurn.y !== furn.y ||
              serverFurn.width !== furn.width ||
              serverFurn.depth !== furn.depth ||
              serverFurn.height !== furn.height ||
              serverFurn.rotation !== furn.rotation ||
              serverFurn.elevationFromFloor !== furn.elevationFromFloor ||
              serverFurn.label !== furn.label ||
              (serverFurn.showProjection ?? false) !== (furn.showProjection ?? false) ||
              (serverFurn.opacity ?? 1) !== (furn.opacity ?? 1) ||
              anchorChanged ||
              metadataChanged;
            if (hasChanges) {
              furnUpdate.push({
                id: furn.id,
                data: {
                  type: furn.type,
                  x: furn.x,
                  y: furn.y,
                  width: furn.width,
                  depth: furn.depth,
                  height: furn.height,
                  rotation: normalizeAngleDegrees(furn.rotation ?? 0),
                  elevationFromFloor: furn.elevationFromFloor,
                  label: furn.label,
                  showProjection: furn.showProjection ?? false,
                  opacity: furn.opacity ?? 1,
                  positionAnchor: furn.positionAnchor,
                  metadata: furn.metadata ?? null,
                },
              });
            }
          }
        }
      }

      for (const serverFurn of freshRoom.furnitureItems) {
        if (!localFurnIds.has(serverFurn.id)) {
          furnDelete.push(serverFurn.id);
        }
      }

      // 6. Execute the 3 element batch calls in parallel — responses contain
      //    final server state. Annotations need to wait until after this so we
      //    can remap their attachedToId through the new server-side ids.
      const [syncedOpenings, syncedElectrical, syncedFurniture] = await Promise.all([
        batchSyncOpenings(roomId, {
          create: openingsCreate,
          update: openingsUpdate,
          delete: openingsDelete,
        }),
        batchSyncElectrical(roomId, {
          create: elecCreate,
          update: elecUpdate,
          delete: elecDelete,
        }),
        batchSyncFurniture(roomId, {
          create: furnCreate,
          update: furnUpdate,
          delete: furnDelete,
        }),
      ]);

      // 7. Build an id map (old local id → new server id) so the reducer can
      // preserve the user's selection across the bulk-replace save flow.
      // The server batch endpoints return items in non-deterministic order, so
      // we match by content, then consume each match exactly once.
      const idMap = new Map<string, string>();

      for (const [localId, serverId] of wallIdMap) {
        idMap.set(localId, serverId);
      }

      const consumedOpenings = new Set<string>();
      for (const local of state.openings) {
        const localServerWallId = wallIdMap.get(local.wallId) ?? local.wallId;
        const match = syncedOpenings.find(
          (o) =>
            !consumedOpenings.has(o.id) &&
            o.wallId === localServerWallId &&
            o.type === local.type &&
            Math.abs(o.positionAlongWall - local.positionAlongWall) < 0.001 &&
            Math.abs(o.width - local.width) < 0.001,
        );
        if (match) {
          consumedOpenings.add(match.id);
          idMap.set(local.id, match.id);
        }
      }

      const consumedElectrical = new Set<string>();
      for (const local of state.electricalItems) {
        const localServerWallId = local.wallId
          ? (wallIdMap.get(local.wallId) ?? local.wallId)
          : null;
        const match = syncedElectrical.find(
          (e) =>
            !consumedElectrical.has(e.id) &&
            e.type === local.type &&
            (e.wallId ?? null) === localServerWallId &&
            Math.abs(e.x - local.x) < 0.001 &&
            Math.abs(e.y - local.y) < 0.001,
        );
        if (match) {
          consumedElectrical.add(match.id);
          idMap.set(local.id, match.id);
        }
      }

      const consumedFurniture = new Set<string>();
      for (const local of state.furnitureItems) {
        if (!local.id.startsWith('local-') && syncedFurniture.some((f) => f.id === local.id)) {
          idMap.set(local.id, local.id);
          consumedFurniture.add(local.id);
          continue;
        }
        const match = syncedFurniture.find(
          (f) =>
            !consumedFurniture.has(f.id) &&
            f.type === local.type &&
            Math.abs(f.x - local.x) < 0.001 &&
            Math.abs(f.y - local.y) < 0.001 &&
            Math.abs(f.width - local.width) < 0.001 &&
            Math.abs(f.depth - local.depth) < 0.001,
        );
        if (match) {
          consumedFurniture.add(match.id);
          idMap.set(local.id, match.id);
        }
      }

      // 7b. Now that the id map is built, save annotations with attachedToId
      //     remapped to the new server-side item ids.
      const serverAnnIds = new Set((freshRoom.annotations ?? []).map((a) => a.id));
      const localAnnIds = new Set(state.annotations.map((a) => a.id));
      const annCreate: CreateAnnotationDto[] = [];
      const annUpdate: { id: string; data: UpdateAnnotationDto }[] = [];
      const annDelete: string[] = [];

      for (const ann of state.annotations) {
        const remappedAttachedTo = ann.attachedToId
          ? (idMap.get(ann.attachedToId) ?? ann.attachedToId)
          : null;
        if (ann.id.startsWith('local-') || !serverAnnIds.has(ann.id)) {
          annCreate.push({
            x: ann.x,
            y: ann.y,
            text: ann.text,
            fontSize: ann.fontSize,
            color: ann.color,
            attachedToId: remappedAttachedTo,
            projectionOffsetX: ann.projectionOffsetX,
            projectionOffsetY: ann.projectionOffsetY,
          });
        } else {
          annUpdate.push({
            id: ann.id,
            data: {
              x: ann.x,
              y: ann.y,
              text: ann.text,
              fontSize: ann.fontSize,
              color: ann.color,
              attachedToId: remappedAttachedTo,
              projectionOffsetX: ann.projectionOffsetX,
              projectionOffsetY: ann.projectionOffsetY,
            },
          });
        }
      }
      for (const id of serverAnnIds) {
        if (!localAnnIds.has(id)) annDelete.push(id);
      }

      const syncedAnnotations = await batchSyncAnnotations(roomId, {
        create: annCreate,
        update: annUpdate,
        delete: annDelete,
      });

      // 8. Sync state with server-assigned IDs (single dispatch, no flicker)
      dispatch({
        type: 'SYNC_SAVE',
        walls: serverWalls,
        openings: syncedOpenings,
        electricalItems: syncedElectrical,
        furnitureItems: syncedFurniture,
        annotations: syncedAnnotations,
        idMap,
      });

      // Mark state as clean after successful save
      lastSavedRef.current = {
        walls: serverWalls,
        openings: syncedOpenings,
        electricalItems: syncedElectrical,
        furnitureItems: syncedFurniture,
        room: state.room,
      };
    } catch (err: unknown) {
      const message = err instanceof Error ? err.message : t('editor.error.load');
      setSaveError(message);
    } finally {
      setIsSaving(false);
      isSavingRef.current = false;
    }
  }, [roomId, state.walls, state.openings, state.electricalItems, state.furnitureItems, state.annotations, state.room, dispatch]);

  // ── Auto-save with ref-based debounce ──
  const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  useEffect(() => {
    // Clear any existing timer when dirty state or saving state changes
    if (autoSaveTimerRef.current !== null) {
      clearTimeout(autoSaveTimerRef.current);
      autoSaveTimerRef.current = null;
    }

    // Only start the timer if dirty and not currently saving
    if (!isDirty || isSavingRef.current) return;

    autoSaveTimerRef.current = setTimeout(() => {
      autoSaveTimerRef.current = null;
      // Guard: don't auto-save if a manual save is in progress
      if (isSavingRef.current) return;
      setIsAutoSaving(true);
      handleSave().finally(() => setIsAutoSaving(false));
    }, AUTO_SAVE_DELAY_MS);

    return () => {
      if (autoSaveTimerRef.current !== null) {
        clearTimeout(autoSaveTimerRef.current);
        autoSaveTimerRef.current = null;
      }
    };
  }, [isDirty, handleSave]);

  // ── Keyboard shortcuts ──
  useKeyboardShortcuts({ onSave: handleSave });

  // ── Import handler ──
  const [importError, setImportError] = useState<string | null>(null);
  const fileInputRef = useRef<HTMLInputElement>(null);

  const handleImportClick = useCallback(() => {
    setImportError(null);
    fileInputRef.current?.click();
  }, []);

  const handleImportFile = useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) => {
      const file = event.target.files?.[0];
      if (!file) return;

      // Reset the input so the same file can be re-selected
      event.target.value = '';

      const reader = new FileReader();
      reader.onload = () => {
        try {
          const text = reader.result as string;
          const imported = importRoomFromJson(text);

          const confirmed = window.confirm(
            `Import room "${imported.room.name}"? This will replace all current walls, openings, electrical items, and furniture.`,
          );
          if (!confirmed) return;

          dispatch({
            type: 'IMPORT_ROOM',
            room: imported.room,
            walls: imported.walls,
            openings: imported.openings,
            electricalItems: imported.electricalItems,
            furnitureItems: imported.furnitureItems,
          });
          setImportError(null);
        } catch (err: unknown) {
          const message = err instanceof Error ? err.message : 'Failed to import file';
          setImportError(message);
        }
      };
      reader.onerror = () => {
        setImportError('Failed to read the selected file.');
      };
      reader.readAsText(file);
    },
    [dispatch],
  );

  return (
    <div className={styles.layout}>
      {/* Toolbar */}
      {/* Hidden file input for JSON import */}
      <input
        ref={fileInputRef}
        type="file"
        accept=".json"
        style={{ display: 'none' }}
        onChange={handleImportFile}
      />

      <EditorToolbar
        onSave={handleSave}
        isSaving={isSaving}
        isDirty={isDirty}
        isAutoSaving={isAutoSaving}
        onExport={() => setShowExport(true)}
        onImport={handleImportClick}
      />

      {saveError && (
        <div className={styles.saveError}>
          {t('editor.saveFailed', { error: saveError })}
        </div>
      )}

      {importError && (
        <div className={styles.saveError}>
          {t('editor.importFailed', { error: importError })}
        </div>
      )}

      {/* Main area: palette + canvas + properties panel */}
      <div className={styles.main}>
        <div className={styles.canvasArea}>
          {/* Overlay palettes (float over canvas, don't affect layout) */}
          {state.activeTool === 'electrical' && (
            <ElectricalPalette
              selectedIndex={state.selectedElectricalIndex}
              onSelect={(index) =>
                dispatch({ type: 'SET_ELECTRICAL_INDEX', index })
              }
            />
          )}
          {state.activeTool === 'furniture' && (
            <FurniturePalette
              selectedIndex={state.selectedFurnitureIndex}
              onSelect={(index) =>
                dispatch({ type: 'SET_FURNITURE_INDEX', index })
              }
            />
          )}
          {/* View mode toggle: 2D / 3D / Projections */}
          <div className={styles.viewToggle}>
            <button
              className={[styles.viewToggleBtn, viewMode === '2d' ? styles.viewToggleBtnActive : ''].join(' ')}
              onClick={() => setViewMode('2d')}
            >
              {t('toolbar.view2D')}
            </button>
            <button
              className={[styles.viewToggleBtn, viewMode === '3d' ? styles.viewToggleBtnActive : ''].join(' ')}
              onClick={() => setViewMode('3d')}
            >
              {t('toolbar.view3D')}
            </button>
            <button
              className={[styles.viewToggleBtn, viewMode === 'projections' ? styles.viewToggleBtnActive : ''].join(' ')}
              onClick={() => setViewMode('projections')}
            >
              {t('toolbar.viewProjections')}
            </button>
          </div>

          {mount3D && (
            <div
              className={styles.canvasContainer}
              data-export-3d-host
              style={
                viewMode !== '3d'
                  ? {
                      position: 'absolute',
                      width: '1200px',
                      height: '900px',
                      overflow: 'hidden',
                      pointerEvents: 'none',
                      opacity: 0,
                      zIndex: -1,
                    }
                  : undefined
              }
            >
              <Suspense fallback={<div className={styles.loading3D}>{t('editor.loading3D')}</div>}>
                <Room3DView presetTriggerRef={preset3DRef} canvasRef={threeCanvasRef} />
              </Suspense>
            </div>
          )}

          <div
            ref={canvasContainerRef}
            className={styles.canvasContainer}
            style={viewMode !== '2d' ? { position: 'absolute', width: 0, height: 0, overflow: 'hidden', pointerEvents: 'none' } : undefined}
          >
            {/* Only mount the Konva stage once the container has been
                measured — rendering at a seed 800×600 and then re-rendering
                at the real size causes a visible flicker on open. */}
            {canvasSize && (
              <EditorCanvas
                width={canvasSize.width}
                height={canvasSize.height}
                onStageRef={handleMainStageRef}
              />
            )}
          </div>
          {viewMode === '2d' && (
            <CableLengthStatus electricalItems={state.electricalItems} />
          )}

          {/* ProjectionPanel always mounted for export, hidden when not active */}
          <div style={viewMode !== 'projections' ? { position: 'absolute', width: '800px', height: '400px', overflow: 'hidden', pointerEvents: 'none', opacity: 0, zIndex: -1 } : { display: 'contents' }}>
            <ProjectionPanel
              fullView={viewMode === 'projections'}
              onStageRef={handleProjectionStageRef}
            />
          </div>

          {/* Hidden per-wall projection renderer used by the export dialog.
              Each stage is sized to match its wall's native aspect ratio so the
              captured PNG has minimal empty space. Mounted only while the
              export dialog is open to avoid paying the render cost otherwise. */}
          {showExport && (
            <div
              aria-hidden
              style={{
                position: 'absolute',
                left: '-99999px',
                top: 0,
                pointerEvents: 'none',
                opacity: 0,
                zIndex: -1,
              }}
            >
              {state.walls.map((wall) => {
                const len = computeWallLength(wall);
                if (len <= 0) return null;
                const w = Math.round(
                  len * EXPORT_PX_PER_M + EXPORT_PROJ_PADDING + EXPORT_PROJ_OVERFLOW_RIGHT_PX,
                );
                const h = Math.round(
                  state.room.wallHeight * EXPORT_PX_PER_M +
                    EXPORT_PROJ_PADDING +
                    EXPORT_PROJ_OVERFLOW_BOTTOM_PX,
                );
                return (
                  <div key={`export-proj-${wall.id}`} style={{ width: `${w}px`, height: `${h}px` }}>
                    <WallProjectionView
                      wall={wall}
                      walls={state.walls}
                      openings={state.openings}
                      electricalItems={state.layerVisibility.electrical ? state.electricalItems : []}
                      furnitureItems={state.layerVisibility.furniture ? state.furnitureItems : []}
                      annotations={state.layerVisibility.annotations ? state.annotations : []}
                      globalFurnitureOpacity={state.globalFurnitureOpacity}
                      wallHeight={state.room.wallHeight}
                      plinthHeight={state.room.plinthHeight}
                      stretchCeilingOffset={
                        state.layerVisibility.stretchCeiling ? state.room.stretchCeilingOffset : 0
                      }
                      outletWidth={state.room.outletWidth}
                      outletHeight={state.room.outletHeight}
                      selectedIds={state.selectedIds}
                      isHighlighted={false}
                      onSelectElement={() => {}}
                      onStageRef={handleExportProjectionStageRef}
                      width={w}
                      height={h}
                      showMeasurements={state.layerVisibility.measurements}
                      fontScale={2}
                    />
                  </div>
                );
              })}
            </div>
          )}
        </div>
        <PropertiesPanel />
      </div>

      {/* Export Dialog */}
      <ExportDialog
        open={showExport}
        onClose={() => setShowExport(false)}
        mainStageRef={mainStageRef}
        projectionStageRefs={projectionStageMapRef}
        exportProjectionStageRefs={exportProjectionStageMapRef}
        threeCanvasRef={threeCanvasRef}
        preset3DRef={preset3DRef}
        canvasSize={canvasSize}
        is3DView={viewMode === '3d'}
        viewMode={viewMode}
      />
    </div>
  );
}
