import { useMemo, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type { Wall, WallOpening, ElectricalItem, FurnitureItem, DoorOpenDirection, FloorType, WallFinish, Annotation, PositionAnchor, HorizontalAnchor, VerticalAnchor, WallLightStyle, FurnitureTexture, OutletDirection, AcUnitStyle } from '@house-plan-maker/shared';
import { TextPromptModal } from '../ui/TextPromptModal';
import { DOOR_OPEN_DIRECTIONS, FLOOR_TYPES, WALL_FINISHES, HORIZONTAL_ANCHORS, VERTICAL_ANCHORS, WALL_LIGHT_STYLES, OUTLET_DIRECTIONS, AC_UNIT_STYLES, FURNITURE_TEXTURES, TEXTURABLE_FURNITURE } from '@house-plan-maker/shared';
import { useEditor } from './context/EditorContext';
import { useUndoRedo } from './context/UndoRedoContext';
import { wallLength } from './utils/wallUtils';
import { polygonArea, polygonPerimeter, generateLocalId } from './utils/geometry';
import { normalizeAngleDegrees } from './utils/angle';
import {
  getElectricalVariant,
  getWallLightStyle,
  getWallLightCordLength,
  getCeilingLampSize,
  ELECTRICAL_SYMBOL_DEFS,
  getOutletInvertCoordX,
  getOutletInvertCoordY,
  getOutletDirection,
} from './symbols/electrical';
import {
  getCurtainLeftOpen,
  getCurtainRightOpen,
  getCurtainFabricColor,
} from './utils/curtainMetadata';
import { getFurnitureTexture, getAcUnitStyle } from './utils/furnitureTextureMetadata';
import type { EditorCommand } from './types';
import styles from './properties-panel.module.css';

const PROPERTIES_COLLAPSED_KEY = 'editor.propertiesPanel.collapsed';

/**
 * Resolve a translation key with a hard-coded English fallback used when
 * the key is missing from the active locale. Keeps the call-site terse
 * compared with the inline `i18n.exists(k) ? t(k) : 'fallback'` pattern.
 */
function tf(
  i18n: { exists: (k: string) => boolean },
  t: (k: string) => string,
  key: string,
  fallback: string,
): string {
  return i18n.exists(key) ? t(key) : fallback;
}

function readCollapsed(): boolean {
  try {
    return localStorage.getItem(PROPERTIES_COLLAPSED_KEY) === 'true';
  } catch {
    return false;
  }
}

function writeCollapsed(value: boolean): void {
  try {
    localStorage.setItem(PROPERTIES_COLLAPSED_KEY, String(value));
  } catch {
    /* ignore quota / disabled storage */
  }
}

export function PropertiesPanel() {
  const { t } = useTranslation();
  const { state, dispatch, updateOpening, updateElectrical, updateFurniture, updateWall } = useEditor();
  const { execute } = useUndoRedo();
  const { selectedIds, walls, openings, electricalItems, furnitureItems, room } = state;

  const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
  const toggleCollapsed = useCallback(() => {
    setCollapsed((prev) => {
      const next = !prev;
      writeCollapsed(next);
      return next;
    });
  }, []);

  const header = (
    <div className={styles.header}>
      <span>{t('properties.title')}</span>
      <button
        type="button"
        className={styles.collapseBtn}
        onClick={toggleCollapsed}
        title={t('properties.collapse')}
        aria-label={t('properties.collapse')}
      >
        {'\u25B6'}
      </button>
    </div>
  );

  const roomArea = useMemo(
    () => room.shape.length >= 3 ? polygonArea(room.shape) : 0,
    [room.shape],
  );
  const roomPerimeter = useMemo(
    () => room.shape.length >= 2 ? polygonPerimeter(room.shape) : 0,
    [room.shape],
  );

  // Find selected elements
  const selected = useMemo(() => {
    const items: {
      type: 'wall' | 'opening' | 'electrical' | 'furniture';
      data: Wall | WallOpening | ElectricalItem | FurnitureItem;
    }[] = [];

    for (const id of selectedIds) {
      const wall = walls.find((w) => w.id === id);
      if (wall) {
        items.push({ type: 'wall', data: wall });
        continue;
      }
      const opening = openings.find((o) => o.id === id);
      if (opening) {
        items.push({ type: 'opening', data: opening });
        continue;
      }
      const elec = electricalItems.find((e) => e.id === id);
      if (elec) {
        items.push({ type: 'electrical', data: elec });
        continue;
      }
      const furn = furnitureItems.find((f) => f.id === id);
      if (furn) {
        items.push({ type: 'furniture', data: furn });
      }
    }

    return items;
  }, [selectedIds, walls, openings, electricalItems, furnitureItems]);

  if (collapsed) {
    return (
      <div className={styles.panelCollapsed}>
        <button
          type="button"
          className={styles.collapseBtn}
          onClick={toggleCollapsed}
          title={t('properties.expand')}
          aria-label={t('properties.expand')}
        >
          {'\u25C0'}
        </button>
      </div>
    );
  }

  if (selected.length === 0) {
    return (
      <div className={styles.panel}>
        {header}
        <div className={styles.empty}>
          <p className={styles.emptyText}>{t('properties.noSelection')}</p>
          <p className={styles.emptyHint}>{t('properties.selectHint')}</p>
        </div>
        <div className={styles.section}>
          <div className={styles.sectionTitle}>{t('properties.roomInfo')}</div>
          <PropertyRow label={t('properties.name')} value={room.name} />
          {roomArea > 0 && (
            <PropertyRow label={t('properties.area')} value={`${(Math.round(roomArea * 100) / 100).toFixed(2)} m\u00B2`} />
          )}
          {roomPerimeter > 0 && (
            <PropertyRow label={t('properties.perimeter')} value={`${(Math.round(roomPerimeter * 100) / 100).toFixed(2)} m`} />
          )}
          <PropertyRow label={t('properties.wallHeight')} value={`${room.wallHeight}m`} />
          <PropertyRow label={t('properties.plinthHeight')} value={`${Math.round(room.plinthHeight * 1000) / 10}cm`} />
          <PropertyRow
            label={t('properties.outletCountStat')}
            value={String(electricalItems.filter((e) => e.type === 'OUTLET').reduce((sum, e) => sum + Math.max(1, e.count), 0))}
          />
          <PropertyRow
            label={t('properties.switchCountStat')}
            value={String(electricalItems.filter((e) => e.type === 'SWITCH').length)}
          />
          {/* Stretch ceiling drop (натяжной потолок). Stored in meters,
              edited in cm for ergonomics. 0 = disabled. */}
          <EditablePropertyRow
            label={t('properties.stretchCeilingOffset')}
            value={String(Math.round((room.stretchCeilingOffset ?? 0) * 1000) / 10)}
            unit="cm"
            onCommit={(v) => {
              const cm = parseFloat(v);
              if (!isNaN(cm) && cm >= 0 && cm <= 200) {
                dispatch({
                  type: 'UPDATE_ROOM_PROPS',
                  props: { stretchCeilingOffset: cm / 100 },
                });
              }
            }}
          />
          <SelectPropertyRow<FloorType>
            label={t('properties.floorType')}
            value={room.floorType}
            options={FLOOR_TYPES.map((ft) => ({
              value: ft,
              label: t(`floor.${ft}`),
            }))}
            onChange={(v) => dispatch({ type: 'UPDATE_ROOM_PROPS', props: { floorType: v } })}
          />
          <SelectPropertyRow<WallFinish>
            label={t('properties.wallFinish')}
            value={room.wallFinish ?? 'PAINT'}
            options={WALL_FINISHES.map((wf) => ({
              value: wf,
              label: t(`wallFinish.${wf}`),
            }))}
            onChange={(v) => dispatch({ type: 'UPDATE_ROOM_PROPS', props: { wallFinish: v } })}
          />
          <div className={styles.row}>
            <span className={styles.rowLabel}>{t('properties.wallColor')}</span>
            <input
              type="color"
              value={room.wallColor ?? '#f5f0eb'}
              onChange={(e) => dispatch({ type: 'UPDATE_ROOM_PROPS', props: { wallColor: e.target.value } })}
              style={{ width: 32, height: 24, border: '1px solid var(--color-border)', borderRadius: 4, cursor: 'pointer', padding: 0 }}
              // Wall color only renders on the PAINT finish — when a textured
              // finish is selected the value is still editable so the user
              // can pre-pick a colour for when they switch back. Tooltip
              // explains when it won't be visible.
              title={(room.wallFinish ?? 'PAINT') !== 'PAINT' ? t('properties.wallColorPaintOnly') : t('properties.wallColor')}
            />
          </div>
          <PropertyRow label={t('properties.walls')} value={String(walls.length)} />
          <PropertyRow label={t('properties.openings')} value={String(openings.length)} />
          {/* Room-level outlet dimensions — used to draw outlet boundaries in
              all views (2D/3D/projection). Stored in meters; edited in cm. */}
          <EditablePropertyRow
            label={t('properties.outletWidth')}
            value={String(Math.round(room.outletWidth * 1000) / 10)}
            unit="cm"
            onCommit={(v) => {
              const cm = parseFloat(v);
              if (!isNaN(cm) && cm > 0 && cm <= 100) {
                dispatch({ type: 'UPDATE_ROOM_PROPS', props: { outletWidth: cm / 100 } });
              }
            }}
          />
          <EditablePropertyRow
            label={t('properties.outletHeight')}
            value={String(Math.round(room.outletHeight * 1000) / 10)}
            unit="cm"
            onCommit={(v) => {
              const cm = parseFloat(v);
              if (!isNaN(cm) && cm > 0 && cm <= 100) {
                dispatch({ type: 'UPDATE_ROOM_PROPS', props: { outletHeight: cm / 100 } });
              }
            }}
          />
        </div>
      </div>
    );
  }

  if (selected.length > 1) {
    return (
      <div className={styles.panel}>
        {header}
        <div className={styles.empty}>
          <p className={styles.emptyText}>{t('properties.multipleSelected', { count: selected.length })}</p>
        </div>
      </div>
    );
  }

  const item = selected[0];

  return (
    <div className={styles.panel}>
      {header}
      {item.type === 'wall' && (
        <WallProperties
          wall={item.data as Wall}
          onUpdate={(updated) => {
            const original = item.data as Wall;
            const cmd: EditorCommand = {
              description: 'Update wall thickness',
              execute: () => updateWall(updated),
              undo: () => updateWall(original),
            };
            execute(cmd);
          }}
        />
      )}
      {item.type === 'opening' && (
        <OpeningProperties
          opening={item.data as WallOpening}
          walls={walls}
          onUpdate={(updated) => {
            const original = item.data as WallOpening;
            const cmd: EditorCommand = {
              description: `Update ${updated.type.toLowerCase()}`,
              execute: () => updateOpening(updated),
              undo: () => updateOpening(original),
            };
            execute(cmd);
          }}
        />
      )}
      {item.type === 'electrical' && (
        <ElectricalProperties
          item={item.data as ElectricalItem}
          onUpdate={(updated) => {
            const original = item.data as ElectricalItem;
            const cmd: EditorCommand = {
              description: 'Update electrical item',
              execute: () => updateElectrical(updated),
              undo: () => updateElectrical(original),
            };
            execute(cmd);
          }}
        />
      )}
      {item.type === 'furniture' && (
        <FurnitureProperties
          item={item.data as FurnitureItem}
          onUpdate={(updated) => {
            const original = item.data as FurnitureItem;
            const cmd: EditorCommand = {
              description: 'Update furniture item',
              execute: () => updateFurniture(updated),
              undo: () => updateFurniture(original),
            };
            execute(cmd);
          }}
        />
      )}
      {/* Add note / edit attached annotations for any item */}
      {(item.type === 'electrical' || item.type === 'furniture') && (
        <ItemAnnotationManager itemId={item.data.id} roomId={room.id} />
      )}
    </div>
  );
}

// ── Attached annotation manager ──

interface ItemAnnotationManagerProps {
  readonly itemId: string;
  readonly roomId: string;
}

function ItemAnnotationManager({ itemId, roomId }: ItemAnnotationManagerProps) {
  const { t } = useTranslation();
  const { state, addAnnotation, updateAnnotation, removeAnnotation } = useEditor();
  const attached = useMemo(
    () => state.annotations.filter((a) => a.attachedToId === itemId),
    [state.annotations, itemId],
  );

  const [editing, setEditing] = useState<{ kind: 'add' } | { kind: 'edit'; annotation: Annotation } | null>(null);

  return (
    <div style={{ padding: '4px 8px', display: 'flex', flexDirection: 'column', gap: 4 }}>
      {attached.map((ann) => (
        <div
          key={ann.id}
          style={{
            display: 'flex',
            alignItems: 'center',
            gap: 4,
            fontSize: 12,
            padding: '2px 6px',
            border: '1px solid var(--color-border)',
            borderRadius: 4,
            background: 'var(--color-bg)',
          }}
        >
          <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
            {ann.text}
          </span>
          <button
            type="button"
            style={{ fontSize: 11, padding: '0 4px' }}
            onClick={() => setEditing({ kind: 'edit', annotation: ann })}
            aria-label={t('annotation.edit') ?? 'Edit'}
          >
            ✎
          </button>
          <button
            type="button"
            style={{ fontSize: 11, padding: '0 4px' }}
            onClick={() => removeAnnotation(ann.id)}
            aria-label={t('annotation.delete') ?? 'Delete'}
          >
            ✕
          </button>
        </div>
      ))}
      <button
        type="button"
        style={{
          width: '100%',
          padding: '4px 8px',
          fontSize: 12,
          border: '1px solid var(--color-border)',
          borderRadius: 4,
          background: 'var(--color-bg)',
          color: 'var(--color-text-secondary)',
          cursor: 'pointer',
        }}
        onClick={() => setEditing({ kind: 'add' })}
      >
        {t('properties.addNote')}
      </button>
      <TextPromptModal
        open={editing != null}
        title={t('annotation.editPrompt')}
        initialValue={editing?.kind === 'edit' ? editing.annotation.text : ''}
        onConfirm={(value) => {
          const trimmed = value.trim();
          if (!editing) return;
          if (editing.kind === 'add') {
            if (trimmed) {
              addAnnotation({
                id: generateLocalId(),
                roomId,
                x: 0.3,
                y: -0.2,
                text: trimmed,
                fontSize: 12,
                attachedToId: itemId,
              });
            }
          } else if (trimmed && trimmed !== editing.annotation.text) {
            updateAnnotation({ ...editing.annotation, text: trimmed });
          }
          setEditing(null);
        }}
        onCancel={() => setEditing(null)}
      />
    </div>
  );
}

// ── Wall Properties ──

interface WallPropertiesProps {
  readonly wall: Wall;
  readonly onUpdate: (wall: Wall) => void;
}

function WallProperties({ wall, onUpdate }: WallPropertiesProps) {
  const { t } = useTranslation();
  const len = wallLength(wall);

  const handleThicknessChange = useCallback(
    (value: string) => {
      const cm = parseFloat(value);
      if (!isNaN(cm) && cm > 0 && cm <= 100) {
        onUpdate({ ...wall, thickness: cm / 100 });
      }
    },
    [wall, onUpdate],
  );

  return (
    <div className={styles.section}>
      <div className={styles.sectionTitle}>{t('properties.wall')}</div>
      <PropertyRow label={t('properties.length')} value={formatM(len)} />
      <EditablePropertyRow
        label={t('properties.thickness')}
        value={String(Math.round(wall.thickness * 100))}
        unit="cm"
        onCommit={handleThicknessChange}
      />
      <PropertyRow label={t('properties.startX')} value={formatM(wall.startX)} />
      <PropertyRow label={t('properties.startY')} value={formatM(wall.startY)} />
      <PropertyRow label={t('properties.endX')} value={formatM(wall.endX)} />
      <PropertyRow label={t('properties.endY')} value={formatM(wall.endY)} />
      <PropertyRow label={t('properties.direction')} value={wall.direction} />
    </div>
  );
}

// ── Opening Properties ──

interface OpeningPropertiesProps {
  readonly opening: WallOpening;
  readonly walls: readonly Wall[];
  readonly onUpdate: (opening: WallOpening) => void;
}

function OpeningProperties({ opening, walls, onUpdate }: OpeningPropertiesProps) {
  const { t, i18n } = useTranslation();
  const wall = walls.find((w) => w.id === opening.wallId);
  const wLen = wall ? wallLength(wall) : 0;
  const gridColsLabel = i18n.exists('properties.windowGridCols')
    ? t('properties.windowGridCols')
    : 'Grid columns';
  const gridRowsLabel = i18n.exists('properties.windowGridRows')
    ? t('properties.windowGridRows')
    : 'Grid rows';
  const slopeDepthLabel = i18n.exists('properties.windowSlopeDepth')
    ? t('properties.windowSlopeDepth')
    : 'Reveal depth (slope)';
  const frameThicknessLabel = i18n.exists('properties.openingFrameThickness')
    ? t('properties.openingFrameThickness')
    : 'Frame thickness';
  // Slope projects outward from the window into and through the wall
  // toward the exterior, so it isn't bounded by wall thickness — only by
  // the schema-level cap (2 m), which is plenty for any realistic reveal.
  const maxSlopeDepth = 2;
  // Frame thickness is bounded by the schema cap (0.5 m); deeper than that
  // would dwarf even oversize doors and is almost certainly a typo.
  const maxFrameThickness = 0.5;

  // Openings always store canonical (positionAlongWall=center, elevationFromFloor=bottom).
  // The anchor on an opening is a view-only preference: it controls how the
  // numbers are displayed and edited in this panel, but does not move the
  // physical opening. Toggling anchor only changes which edge of the opening
  // the displayed values refer to.
  const anchor = opening.positionAnchor;

  const handleWidthChange = useCallback(
    (value: string) => {
      const num = parseFloat(value);
      if (!isNaN(num) && num > 0 && num < wLen) {
        onUpdate({ ...opening, width: num });
      }
    },
    [opening, onUpdate, wLen],
  );

  const handleHeightChange = useCallback(
    (value: string) => {
      const num = parseFloat(value);
      if (!isNaN(num) && num > 0) {
        onUpdate({ ...opening, height: num });
      }
    },
    [opening, onUpdate],
  );

  // Convert canonical (center along wall, bottom from floor) into the value
  // displayed in the panel based on the user's anchor preference.
  const displayPosition = Math.round((() => {
    if (anchor.horizontal === 'left') return opening.positionAlongWall - opening.width / 2;
    if (anchor.horizontal === 'right') return opening.positionAlongWall + opening.width / 2;
    return opening.positionAlongWall;
  })() * 1000) / 1000;

  const displayElevation = Math.round((() => {
    if (anchor.vertical === 'top') return opening.elevationFromFloor + opening.height;
    if (anchor.vertical === 'middle') return opening.elevationFromFloor + opening.height / 2;
    return opening.elevationFromFloor;
  })() * 1000) / 1000;

  const handlePositionChange = useCallback(
    (value: string) => {
      const num = parseFloat(value);
      if (!isNaN(num)) {
        // Convert anchored value back to canonical center position.
        let centerPos = num;
        if (anchor.horizontal === 'left') centerPos = num + opening.width / 2;
        else if (anchor.horizontal === 'right') centerPos = num - opening.width / 2;
        if (centerPos >= 0 && centerPos <= wLen) {
          onUpdate({ ...opening, positionAlongWall: centerPos });
        }
      }
    },
    [opening, onUpdate, wLen, anchor.horizontal],
  );

  const handleElevationChange = useCallback(
    (value: string) => {
      const num = parseFloat(value);
      if (!isNaN(num)) {
        // Convert anchored vertical value back to canonical bottom-edge.
        let bottom = num;
        if (anchor.vertical === 'top') bottom = num - opening.height;
        else if (anchor.vertical === 'middle') bottom = num - opening.height / 2;
        if (bottom >= 0) {
          onUpdate({ ...opening, elevationFromFloor: bottom });
        }
      }
    },
    [opening, onUpdate, anchor.vertical],
  );

  const handleOpenDirectionChange = useCallback(
    (direction: DoorOpenDirection) => {
      onUpdate({ ...opening, openDirection: direction });
    },
    [opening, onUpdate],
  );

  return (
    <div className={styles.section}>
      <div className={styles.sectionTitle}>
        {opening.type === 'DOOR' ? t('properties.door') : t('properties.window')}
      </div>
      <EditablePropertyRow
        label={t('properties.width')}
        value={String(opening.width)}
        unit="m"
        onCommit={handleWidthChange}
      />
      <EditablePropertyRow
        label={t('properties.height')}
        value={String(opening.height)}
        unit="m"
        onCommit={handleHeightChange}
      />
      <EditablePropertyRow
        label={t('properties.position')}
        value={String(displayPosition)}
        unit="m"
        onCommit={handlePositionChange}
      />
      <PositionAnchorEditor
        anchor={anchor}
        onChange={(positionAnchor) => onUpdate({ ...opening, positionAnchor })}
      />
      {/* Frame member thickness — applies to both doors and windows. Clamped
          to the schema cap so a typo can't produce a wall-sized frame. */}
      <EditablePropertyRow
        label={frameThicknessLabel}
        value={String(opening.frameThickness)}
        unit="m"
        onCommit={(v) => {
          const n = parseFloat(v);
          if (!isNaN(n) && n >= 0) {
            onUpdate({ ...opening, frameThickness: Math.min(n, maxFrameThickness) });
          }
        }}
      />
      {opening.type === 'DOOR' && (
        <SelectPropertyRow
          label={t('properties.openDirection')}
          value={opening.openDirection}
          options={DOOR_OPEN_DIRECTIONS.map((dir) => ({
            value: dir,
            label: t(`properties.openDir.${dir}`),
          }))}
          onChange={handleOpenDirectionChange}
        />
      )}
      {opening.type === 'WINDOW' && (
        <>
          <EditablePropertyRow
            label={t('properties.elevation')}
            value={String(displayElevation)}
            unit="m"
            onCommit={handleElevationChange}
          />
          {/* Grid subdivision: N columns × M rows of panes. The 3D and
              projection views render (cols-1) vertical mullions and
              (rows-1) horizontal mullions. Clamp to [1, 10] so a user
              typo can't produce a 1000-mullion window. */}
          <EditablePropertyRow
            label={gridColsLabel}
            value={String(opening.gridCols)}
            onCommit={(v) => {
              const n = parseInt(v, 10);
              if (!isNaN(n) && n >= 1 && n <= 10) {
                onUpdate({ ...opening, gridCols: n });
              }
            }}
          />
          <EditablePropertyRow
            label={gridRowsLabel}
            value={String(opening.gridRows)}
            onCommit={(v) => {
              const n = parseInt(v, 10);
              if (!isNaN(n) && n >= 1 && n <= 10) {
                onUpdate({ ...opening, gridRows: n });
              }
            }}
          />
          {/* Reveal (откос) depth — how far the angled jamb panels protrude
              from the window frame into the room. 0 = flush, no slope. The
              renderer clamps the value so it cannot exceed half the wall
              thickness; we mirror that clamp in the input handler so a
              typo can't push the window out the back of the wall. */}
          <EditablePropertyRow
            label={slopeDepthLabel}
            value={String(opening.slopeDepth)}
            unit="m"
            onCommit={(v) => {
              const n = parseFloat(v);
              if (!isNaN(n) && n >= 0) {
                onUpdate({ ...opening, slopeDepth: Math.min(n, maxSlopeDepth) });
              }
            }}
          />
        </>
      )}
      {wall && (
        <PropertyRow label={t('properties.wallLength')} value={formatM(wLen)} />
      )}
    </div>
  );
}

// ── Property Row Components ──

interface PropertyRowProps {
  readonly label: string;
  readonly value: string;
}

function PropertyRow({ label, value }: PropertyRowProps) {
  return (
    <div className={styles.row}>
      <span className={styles.rowLabel}>{label}</span>
      <span className={styles.rowValue}>{value}</span>
    </div>
  );
}

interface CheckboxPropertyRowProps {
  readonly label: string;
  readonly checked: boolean;
  readonly onChange: (checked: boolean) => void;
}

function CheckboxPropertyRow({ label, checked, onChange }: CheckboxPropertyRowProps) {
  return (
    <label className={styles.row} style={{ cursor: 'pointer' }}>
      <span className={styles.rowLabel}>{label}</span>
      <input
        type="checkbox"
        checked={checked}
        onChange={(e) => onChange(e.target.checked)}
      />
    </label>
  );
}

interface EditablePropertyRowProps {
  readonly label: string;
  readonly value: string;
  readonly unit?: string;
  readonly onCommit: (value: string) => void;
}

function EditablePropertyRow({ label, value, unit, onCommit }: EditablePropertyRowProps) {
  const [editing, setEditing] = useState(false);
  const [draft, setDraft] = useState(value);

  const handleBlur = useCallback(() => {
    setEditing(false);
    if (draft !== value) {
      onCommit(draft);
    }
  }, [draft, value, onCommit]);

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      if (e.key === 'Enter') {
        handleBlur();
      } else if (e.key === 'Escape') {
        setDraft(value);
        setEditing(false);
      }
    },
    [handleBlur, value],
  );

  if (editing) {
    return (
      <div className={styles.row}>
        <span className={styles.rowLabel}>{label}</span>
        <div className={styles.editRow}>
          <input
            className={styles.editInput}
            type="text"
            value={draft}
            onChange={(e) => setDraft(e.target.value)}
            onBlur={handleBlur}
            onKeyDown={handleKeyDown}
            autoFocus
          />
          {unit && <span className={styles.editUnit}>{unit}</span>}
        </div>
      </div>
    );
  }

  return (
    <div className={styles.row}>
      <span className={styles.rowLabel}>{label}</span>
      <button
        className={styles.editableValue}
        onClick={() => {
          setDraft(value);
          setEditing(true);
        }}
      >
        {value}{unit ? ` ${unit}` : ''}
      </button>
    </div>
  );
}

// ── Position Anchor Editor ──
//
// Renders two side-by-side select boxes that edit `positionAnchor`. Used for
// every placeable item (electrical, furniture, openings).

interface PositionAnchorEditorProps {
  readonly anchor: PositionAnchor;
  readonly onChange: (anchor: PositionAnchor) => void;
}

function PositionAnchorEditor({ anchor, onChange }: PositionAnchorEditorProps) {
  const { t, i18n } = useTranslation();
  const label = i18n.exists('properties.anchor') ? t('properties.anchor') : 'Anchor';
  const labelFor = (v: HorizontalAnchor | VerticalAnchor): string => {
    const key = `anchor.${v}`;
    return i18n.exists(key) ? t(key) : v;
  };
  return (
    <div className={styles.row}>
      <span className={styles.rowLabel}>{label}</span>
      <span style={{ display: 'flex', gap: 4, flex: 1, justifyContent: 'flex-end' }}>
        <select
          className={styles.selectInput}
          value={anchor.horizontal}
          onChange={(e) => onChange({ ...anchor, horizontal: e.target.value as HorizontalAnchor })}
          style={{ flex: 1, minWidth: 0 }}
        >
          {HORIZONTAL_ANCHORS.map((h) => (
            <option key={h} value={h}>{labelFor(h)}</option>
          ))}
        </select>
        <select
          className={styles.selectInput}
          value={anchor.vertical}
          onChange={(e) => onChange({ ...anchor, vertical: e.target.value as VerticalAnchor })}
          style={{ flex: 1, minWidth: 0 }}
        >
          {VERTICAL_ANCHORS.map((v) => (
            <option key={v} value={v}>{labelFor(v)}</option>
          ))}
        </select>
      </span>
    </div>
  );
}

// ── Select Property Row ──

interface SelectPropertyRowProps<T extends string> {
  readonly label: string;
  readonly value: T;
  readonly options: readonly { readonly value: T; readonly label: string }[];
  readonly onChange: (value: T) => void;
}

// ── Label override row ──
//
// Editable text input that overrides the default symbol/furniture label.
// Empty string clears the override (stored as `null`); the placeholder
// shows the default the item would fall back to.

interface LabelOverrideRowProps {
  readonly value: string | null;
  readonly placeholder: string;
  readonly onChange: (value: string | null) => void;
}

function LabelOverrideRow({ value, placeholder, onChange }: LabelOverrideRowProps) {
  const { t, i18n } = useTranslation();
  const [draft, setDraft] = useState(value ?? '');
  const [editing, setEditing] = useState(false);

  // Sync external changes (selecting a different item) into the draft when
  // the input is not currently being edited.
  if (!editing && draft !== (value ?? '')) {
    setDraft(value ?? '');
  }

  const commit = useCallback(() => {
    setEditing(false);
    const trimmed = draft.trim();
    const next = trimmed.length > 0 ? trimmed : null;
    if (next !== value) {
      onChange(next);
    }
  }, [draft, value, onChange]);

  const label = i18n.exists('properties.customLabel') ? t('properties.customLabel') : 'Title';

  return (
    <div className={styles.row}>
      <span className={styles.rowLabel}>{label}</span>
      <input
        type="text"
        className={styles.editInput}
        value={draft}
        placeholder={placeholder}
        onFocus={() => setEditing(true)}
        onChange={(e) => setDraft(e.target.value)}
        onBlur={commit}
        onKeyDown={(e) => {
          if (e.key === 'Enter') {
            (e.target as HTMLInputElement).blur();
          } else if (e.key === 'Escape') {
            setDraft(value ?? '');
            setEditing(false);
          }
        }}
        style={{ flex: 1, minWidth: 0 }}
      />
    </div>
  );
}

function SelectPropertyRow<T extends string>({ label, value, options, onChange }: SelectPropertyRowProps<T>) {
  return (
    <div className={styles.row}>
      <span className={styles.rowLabel}>{label}</span>
      <select
        className={styles.selectInput}
        value={value}
        onChange={(e) => onChange(e.target.value as T)}
      >
        {options.map((opt) => (
          <option key={opt.value} value={opt.value}>
            {opt.label}
          </option>
        ))}
      </select>
    </div>
  );
}

// ── Electrical Properties ──

interface ElectricalPropertiesProps {
  readonly item: ElectricalItem;
  readonly onUpdate: (item: ElectricalItem) => void;
}

function ElectricalProperties({ item, onUpdate }: ElectricalPropertiesProps) {
  const { t, i18n } = useTranslation();
  const variant = getElectricalVariant(item.metadata);
  const def = ELECTRICAL_SYMBOL_DEFS.find(
    (d) => d.type === item.type && (d.variant ?? 'single') === (item.type === 'OUTLET' ? undefined : variant),
  );

  const isWallMounted = item.wallId !== null;
  const isOutlet = item.type === 'OUTLET';
  const isWallLight = item.type === 'LIGHT_WALL';
  const isCeilingLight = item.type === 'LIGHT_CEILING';
  const wallLightStyle = getWallLightStyle(item.metadata);
  const cordLength = getWallLightCordLength(item.metadata);
  const ceilingLampSize = getCeilingLampSize(item.metadata);
  const countLabel = i18n.exists('properties.outletCount') ? t('properties.outletCount') : 'Count';

  const outletDirection = getOutletDirection(item.metadata);

  const outletDirectionOptions = useMemo(
    () =>
      OUTLET_DIRECTIONS.map((d) => ({
        value: d,
        label: i18n.exists(`properties.outletDirection.${d}`)
          ? t(`properties.outletDirection.${d}`)
          : d === 'horizontal'
            ? 'Horizontal'
            : 'Vertical',
      })),
    [t, i18n],
  );

  const wallLightStyleOptions = useMemo(
    () =>
      WALL_LIGHT_STYLES.map((s) => ({
        value: s,
        label: i18n.exists(`properties.wallLightStyle.${s}`)
          ? t(`properties.wallLightStyle.${s}`)
          : s === 'classic'
            ? 'Classic'
            : s === 'pendant-globe'
              ? 'Pendant Globe'
              : s === 'sconce-up'
                ? 'Sconce Up'
                : 'Sconce Down',
      })),
    [t, i18n],
  );

  const handleXChange = useCallback(
    (value: string) => {
      const num = parseFloat(value);
      if (!isNaN(num)) onUpdate({ ...item, x: num });
    },
    [item, onUpdate],
  );

  const handleYChange = useCallback(
    (value: string) => {
      const num = parseFloat(value);
      if (!isNaN(num)) onUpdate({ ...item, y: num });
    },
    [item, onUpdate],
  );

  const handleRotationChange = useCallback(
    (value: string) => {
      const num = parseFloat(value);
      if (!isNaN(num)) onUpdate({ ...item, rotation: normalizeAngleDegrees(num) });
    },
    [item, onUpdate],
  );

  const handleElevationChange = useCallback(
    (value: string) => {
      const num = parseFloat(value);
      if (!isNaN(num) && num >= 0) {
        onUpdate({ ...item, elevationFromFloor: num });
      }
    },
    [item, onUpdate],
  );

  // Section title shows the user's custom label if set, otherwise the
  // default symbol-def label or raw type code as fallback.
  const displayTitle = item.label ?? def?.label ?? item.type;
  const labelPlaceholder = def?.label ?? item.type;

  return (
    <div className={styles.section}>
      <div className={styles.sectionTitle}>{displayTitle}</div>
      <PropertyRow label={t('properties.type')} value={item.type} />
      {!isOutlet && variant !== 'single' && <PropertyRow label={t('properties.variant')} value={variant} />}
      <LabelOverrideRow
        value={item.label}
        placeholder={labelPlaceholder}
        onChange={(label) => onUpdate({ ...item, label })}
      />
      {isWallLight && (
        <SelectPropertyRow<WallLightStyle>
          label={i18n.exists('properties.wallLightStyleLabel') ? t('properties.wallLightStyleLabel') : 'Style'}
          value={wallLightStyle}
          options={wallLightStyleOptions}
          onChange={(style) =>
            onUpdate({
              ...item,
              metadata: { ...(item.metadata ?? {}), wallLightStyle: style },
            })
          }
        />
      )}
      {isWallLight && wallLightStyle === 'pendant-globe' && (
        <EditablePropertyRow
          label={i18n.exists('properties.cordLength') ? t('properties.cordLength') : 'Cord length'}
          value={String(Math.round(cordLength * 1000) / 1000)}
          unit="m"
          onCommit={(v) => {
            const num = parseFloat(v);
            if (!isNaN(num) && num >= 0.05 && num <= 2.0) {
              onUpdate({
                ...item,
                metadata: { ...(item.metadata ?? {}), cordLength: num },
              });
            }
          }}
        />
      )}
      {(isCeilingLight || isWallLight) && (
        <EditablePropertyRow
          label={i18n.exists('properties.lampSize') ? t('properties.lampSize') : 'Lamp size'}
          value={String(Math.round(ceilingLampSize * 1000) / 1000)}
          unit="m"
          onCommit={(v) => {
            const num = parseFloat(v);
            if (!isNaN(num) && num >= 0.05 && num <= 1.0) {
              onUpdate({
                ...item,
                metadata: { ...(item.metadata ?? {}), lampSize: num },
              });
            }
          }}
        />
      )}
      {isOutlet && (
        <EditablePropertyRow
          label={countLabel}
          value={String(item.count)}
          onCommit={(v) => {
            const n = parseInt(v, 10);
            if (!isNaN(n) && n >= 1 && n <= 20) onUpdate({ ...item, count: n });
          }}
        />
      )}
      {isOutlet && (
        <SelectPropertyRow<OutletDirection>
          label={i18n.exists('properties.outletDirectionLabel') ? t('properties.outletDirectionLabel') : 'Direction'}
          value={outletDirection}
          options={outletDirectionOptions}
          onChange={(dir) =>
            onUpdate({
              ...item,
              metadata: { ...(item.metadata ?? {}), outletDirection: dir },
            })
          }
        />
      )}
      {isOutlet && (
        <>
          <CheckboxPropertyRow
            label={t('properties.invertCoordX')}
            checked={getOutletInvertCoordX(item.metadata)}
            onChange={(checked) =>
              onUpdate({
                ...item,
                metadata: { ...(item.metadata ?? {}), invertCoordX: checked },
              })
            }
          />
          <CheckboxPropertyRow
            label={t('properties.invertCoordY')}
            checked={getOutletInvertCoordY(item.metadata)}
            onChange={(checked) =>
              onUpdate({
                ...item,
                metadata: { ...(item.metadata ?? {}), invertCoordY: checked },
              })
            }
          />
        </>
      )}
      <EditablePropertyRow label={t('properties.x')} value={String(Math.round(item.x * 1000) / 1000)} unit="m" onCommit={handleXChange} />
      <EditablePropertyRow label={t('properties.y')} value={String(Math.round(item.y * 1000) / 1000)} unit="m" onCommit={handleYChange} />
      <EditablePropertyRow label={t('properties.rotation')} value={String(Math.round(item.rotation))} unit={"\u00b0"} onCommit={handleRotationChange} />
      <PositionAnchorEditor
        anchor={item.positionAnchor}
        onChange={(positionAnchor) => onUpdate({ ...item, positionAnchor })}
      />
      {isWallMounted && (
        <>
          <PropertyRow label={t('properties.wallMounted')} value={t('properties.yes')} />
          <EditablePropertyRow
            label={t('properties.elevation')}
            value={String(Math.round((item.elevationFromFloor ?? 0) * 1000) / 1000)}
            unit="m"
            onCommit={handleElevationChange}
          />
        </>
      )}
    </div>
  );
}

// ── Furniture Properties ──

interface FurniturePropertiesProps {
  readonly item: FurnitureItem;
  readonly onUpdate: (item: FurnitureItem) => void;
}

/**
 * Strip the legacy `[no-stand]` marker from a furniture label so the input
 * field shows only the user-visible name. The marker is a single-purpose
 * boolean stored in the label string for TVs to disable the stand mesh.
 */
function stripFurnitureMarkers(label: string | null): string | null {
  if (!label) return null;
  const cleaned = label.replace('[no-stand]', '').trim();
  return cleaned.length > 0 ? cleaned : null;
}

/**
 * Re-attach any markers that the original label carried after the user
 * edited the visible portion. Currently only `[no-stand]` is preserved.
 */
function preserveFurnitureMarkers(originalLabel: string | null, newDisplay: string | null): string | null {
  const hadNoStand = originalLabel?.includes('[no-stand]') ?? false;
  if (!hadNoStand) return newDisplay;
  const trimmed = (newDisplay ?? '').trim();
  return trimmed.length > 0 ? `${trimmed} [no-stand]` : '[no-stand]';
}

function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) {
  const { t, i18n } = useTranslation();
  const displayLabel = stripFurnitureMarkers(item.label);
  const isTexturable = TEXTURABLE_FURNITURE.includes(item.type);
  const currentTexture = getFurnitureTexture(item.metadata);

  const textureOptions = useMemo(
    () =>
      FURNITURE_TEXTURES.map((tex) => ({
        value: tex,
        label: i18n.exists(`furnitureTexture.${tex}`)
          ? t(`furnitureTexture.${tex}`)
          : tex === 'NONE'
            ? 'None (solid color)'
            : tex.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
      })),
    [t, i18n],
  );
  // Furniture's "default" label for the placeholder is the type code; we
  // don't have access to the original FurnitureDef from a placed item.
  const labelPlaceholder = item.type;

  const handleXChange = useCallback(
    (value: string) => {
      const num = parseFloat(value);
      if (!isNaN(num)) onUpdate({ ...item, x: num });
    },
    [item, onUpdate],
  );

  const handleYChange = useCallback(
    (value: string) => {
      const num = parseFloat(value);
      if (!isNaN(num)) onUpdate({ ...item, y: num });
    },
    [item, onUpdate],
  );

  const handleWidthChange = useCallback(
    (value: string) => {
      const num = parseFloat(value);
      if (!isNaN(num) && num > 0) onUpdate({ ...item, width: num });
    },
    [item, onUpdate],
  );

  const handleDepthChange = useCallback(
    (value: string) => {
      const num = parseFloat(value);
      if (!isNaN(num) && num > 0) onUpdate({ ...item, depth: num });
    },
    [item, onUpdate],
  );

  const handleHeightChange = useCallback(
    (value: string) => {
      const num = parseFloat(value);
      if (!isNaN(num) && num > 0) onUpdate({ ...item, height: num });
    },
    [item, onUpdate],
  );

  const handleElevationChange = useCallback(
    (value: string) => {
      const num = parseFloat(value);
      if (!isNaN(num) && num >= 0) onUpdate({ ...item, elevationFromFloor: num });
    },
    [item, onUpdate],
  );

  const handleRotationChange = useCallback(
    (value: string) => {
      const num = parseFloat(value);
      if (!isNaN(num)) onUpdate({ ...item, rotation: normalizeAngleDegrees(num) });
    },
    [item, onUpdate],
  );

  return (
    <div className={styles.section}>
      <div className={styles.sectionTitle}>
        {displayLabel ?? item.type}
      </div>
      <PropertyRow label={t('properties.type')} value={item.type} />
      <LabelOverrideRow
        value={displayLabel}
        placeholder={labelPlaceholder}
        onChange={(newDisplay) =>
          onUpdate({ ...item, label: preserveFurnitureMarkers(item.label, newDisplay) })
        }
      />
      {isTexturable && (
        <SelectPropertyRow<FurnitureTexture>
          label={i18n.exists('properties.surfaceTexture') ? t('properties.surfaceTexture') : 'Surface'}
          value={currentTexture}
          options={textureOptions}
          onChange={(tex) =>
            onUpdate({
              ...item,
              metadata: { ...(item.metadata ?? {}), surfaceTexture: tex },
            })
          }
        />
      )}
      <EditablePropertyRow label={t('properties.x')} value={String(Math.round(item.x * 1000) / 1000)} unit="m" onCommit={handleXChange} />
      <EditablePropertyRow label={t('properties.y')} value={String(Math.round(item.y * 1000) / 1000)} unit="m" onCommit={handleYChange} />
      <EditablePropertyRow label={t('properties.width')} value={String(item.width)} unit="m" onCommit={handleWidthChange} />
      <EditablePropertyRow label={t('properties.depth')} value={String(item.depth)} unit="m" onCommit={handleDepthChange} />
      <EditablePropertyRow label={t('properties.height')} value={String(item.height)} unit="m" onCommit={handleHeightChange} />
      <EditablePropertyRow label={t('properties.elevation')} value={String(Math.round(item.elevationFromFloor * 1000) / 1000)} unit="m" onCommit={handleElevationChange} />
      <EditablePropertyRow label={t('properties.rotation')} value={String(Math.round(item.rotation))} unit={"\u00b0"} onCommit={handleRotationChange} />
      <PositionAnchorEditor
        anchor={item.positionAnchor}
        onChange={(positionAnchor) => onUpdate({ ...item, positionAnchor })}
      />
      <FurnitureOpacitySlider item={item} />
      <FurnitureProjectionToggle item={item} />
      {item.type === 'NIGHTSTAND' && (
        <>
          <div className={styles.row}>
            <span className={styles.rowLabel}>{tf(i18n, t, 'properties.legs', 'Legs')}</span>
            <label className={styles.checkboxLabel}>
              <input
                type="checkbox"
                checked={(item.metadata?.['hasLegs'] as boolean | undefined) ?? false}
                onChange={(e) => {
                  onUpdate({
                    ...item,
                    metadata: { ...(item.metadata ?? {}), hasLegs: e.target.checked },
                  });
                }}
              />
              {t('properties.yes')}
            </label>
          </div>
          {(item.metadata?.['hasLegs'] as boolean | undefined) && (
            <EditablePropertyRow
              label={tf(i18n, t, 'properties.legHeight', 'Leg height')}
              value={String(Math.round(((item.metadata?.['legHeight'] as number | undefined) ?? 0.12) * 1000) / 1000)}
              unit="m"
              onCommit={(value) => {
                const num = parseFloat(value);
                // Cap legs at 60% of total height so the body stays a
                // recognisable cabinet rather than collapsing to a plank.
                const maxLegHeight = item.height * 0.6;
                if (!isNaN(num) && num > 0 && num <= maxLegHeight) {
                  onUpdate({
                    ...item,
                    metadata: { ...(item.metadata ?? {}), legHeight: num },
                  });
                }
              }}
            />
          )}
        </>
      )}
      {item.type === 'CURTAIN' && <CurtainControls item={item} onUpdate={onUpdate} />}
      {item.type === 'BOOKCASE' && <BookcaseControls item={item} onUpdate={onUpdate} />}
      {item.type === 'AC_UNIT' && (
        <SelectPropertyRow<AcUnitStyle>
          label={tf(i18n, t, 'properties.acUnitStyleLabel', 'Style')}
          value={getAcUnitStyle(item.metadata)}
          options={AC_UNIT_STYLES.map((s) => ({
            value: s,
            label: tf(i18n, t, `properties.acUnitStyle.${s}`, s === 'generic' ? 'Generic' : 'LG'),
          }))}
          onChange={(style) =>
            onUpdate({
              ...item,
              metadata: { ...(item.metadata ?? {}), acUnitStyle: style },
            })
          }
        />
      )}
      {item.type === 'TV' && (
        <div className={styles.row}>
          <span className={styles.rowLabel}>{t('properties.stand')}</span>
          <label style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer' }}>
            <input
              type="checkbox"
              checked={!item.label?.includes('[no-stand]')}
              onChange={(e) => {
                const hasStand = e.target.checked;
                const cleanLabel = (item.label ?? '').replace('[no-stand]', '').trim();
                onUpdate({
                  ...item,
                  label: hasStand ? (cleanLabel || null) : `${cleanLabel} [no-stand]`.trim(),
                });
              }}
            />
            {t('properties.yes')}
          </label>
        </div>
      )}
      {item.type === 'DIGITAL_PIANO' && (
        <div className={styles.row}>
          <span className={styles.rowLabel}>{t('properties.stand')}</span>
          <label style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer' }}>
            <input
              type="checkbox"
              // Missing value means "default on" — matches the preset default.
              checked={(item.metadata?.['hasStand'] as boolean | undefined) ?? true}
              onChange={(e) => {
                const next = { ...(item.metadata ?? {}), hasStand: e.target.checked };
                onUpdate({ ...item, metadata: next });
              }}
            />
            {t('properties.yes')}
          </label>
        </div>
      )}
    </div>
  );
}

function FurnitureOpacitySlider({ item }: { readonly item: FurnitureItem }) {
  const { t, i18n } = useTranslation();
  const { updateFurniture } = useEditor();
  const value = item.opacity ?? 1;
  const label = i18n.exists('properties.opacity') ? t('properties.opacity') : 'Opacity';
  return (
    <div className={styles.row}>
      <span className={styles.rowLabel}>{label}</span>
      <span style={{ display: 'flex', alignItems: 'center', gap: 6, flex: 1, justifyContent: 'flex-end' }}>
        <input
          type="range"
          min={0}
          max={1}
          step={0.05}
          value={value}
          onChange={(e) => {
            const next = parseFloat(e.target.value);
            updateFurniture({ ...item, opacity: Number.isFinite(next) ? next : 1 });
          }}
          style={{ width: 100 }}
        />
        <span style={{ minWidth: 32, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
          {Math.round(value * 100)}%
        </span>
      </span>
    </div>
  );
}

function FurnitureProjectionToggle({ item }: { readonly item: FurnitureItem }) {
  const { t, i18n } = useTranslation();
  const { updateFurniture } = useEditor();
  const enabled = item.showProjection ?? false;
  const label = i18n.exists('properties.showProjection')
    ? t('properties.showProjection')
    : 'Show on wall projection';
  return (
    <div className={styles.row}>
      <span className={styles.rowLabel}>{label}</span>
      <label style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
        <input
          type="checkbox"
          checked={enabled}
          onChange={() => updateFurniture({ ...item, showProjection: !enabled })}
        />
      </label>
    </div>
  );
}

// ── Curtain-specific controls ──
//
// Curtains store their state in the item's metadata bag:
//   - `leftOpen`, `rightOpen` (0..1)  — per-side retraction
//   - `openAmount` (legacy symmetric)  — still honoured by the helpers as a
//     fallback for either side when a per-side key is missing
//   - `fabricColor` (hex)
//
// Editing either side writes the explicit per-side key. We also clear the
// legacy `openAmount` on first edit so the new per-side values don't get
// shadowed by a stale symmetric value on subsequent reads.

interface CurtainControlsProps {
  readonly item: FurnitureItem;
  readonly onUpdate: (item: FurnitureItem) => void;
}

function CurtainControls({ item, onUpdate }: CurtainControlsProps) {
  const { t, i18n } = useTranslation();
  const leftOpen = getCurtainLeftOpen(item.metadata);
  const rightOpen = getCurtainRightOpen(item.metadata);
  const fabricColor = getCurtainFabricColor(item.metadata);

  const updateMetadata = useCallback(
    (patch: Record<string, unknown>) => {
      // When a per-side key is being written, drop the legacy symmetric
      // `openAmount` so it doesn't keep overriding the new per-side values
      // on subsequent reads via the fallback in `curtainMetadata.ts`.
      const base = { ...(item.metadata ?? {}) };
      if ('leftOpen' in patch || 'rightOpen' in patch) {
        delete base['openAmount'];
      }
      const next = { ...base, ...patch };
      onUpdate({ ...item, metadata: next });
    },
    [item, onUpdate],
  );

  const leftLabel = i18n.exists('properties.curtainLeftOpen')
    ? t('properties.curtainLeftOpen')
    : 'Left open';
  const rightLabel = i18n.exists('properties.curtainRightOpen')
    ? t('properties.curtainRightOpen')
    : 'Right open';
  const colorLabel = i18n.exists('properties.curtainFabricColor')
    ? t('properties.curtainFabricColor')
    : 'Fabric color';

  return (
    <>
      <CurtainOpenSlider
        label={leftLabel}
        value={leftOpen}
        onChange={(v) => updateMetadata({ leftOpen: v })}
      />
      <CurtainOpenSlider
        label={rightLabel}
        value={rightOpen}
        onChange={(v) => updateMetadata({ rightOpen: v })}
      />
      <div className={styles.row}>
        <span className={styles.rowLabel}>{colorLabel}</span>
        <input
          type="color"
          value={fabricColor}
          onChange={(e) => updateMetadata({ fabricColor: e.target.value })}
          style={{ width: 32, height: 24, border: '1px solid var(--color-border)', borderRadius: 4, cursor: 'pointer', padding: 0 }}
        />
      </div>
    </>
  );
}

// ── Bookcase controls ──
//
// A bookcase has two editable per-item properties stored in its
// metadata bag:
//   - `shelfRows`: number of storage compartments (integer, 1–12).
//     The 3D mesh draws one more horizontal board than this number
//     (top + bottom + internal dividers).
//   - `hasBackPanel`: whether the unit has a solid back panel. An
//     "open bookshelf" that can double as a room divider omits it.
// Missing values fall back to the legacy behaviour: auto-derive the
// row count from the item's height and always draw the back panel.

interface BookcaseControlsProps {
  readonly item: FurnitureItem;
  readonly onUpdate: (item: FurnitureItem) => void;
}

function BookcaseControls({ item, onUpdate }: BookcaseControlsProps) {
  const { t, i18n } = useTranslation();
  const rowsLabel = tf(i18n, t, 'properties.shelfRows', 'Shelf rows');
  const columnsLabel = tf(i18n, t, 'properties.shelfColumns', 'Shelf columns');
  const backPanelLabel = tf(i18n, t, 'properties.hasBackPanel', 'Back panel');
  const drawersLabel = tf(i18n, t, 'properties.drawerCells', 'Drawers');

  const metadataRows = item.metadata?.['shelfRows'];
  const currentRows =
    typeof metadataRows === 'number' && metadataRows >= 1
      ? Math.round(metadataRows)
      : Math.max(2, Math.round(item.height / 0.35));

  const metadataColumns = item.metadata?.['shelfColumns'];
  const currentColumns =
    typeof metadataColumns === 'number' && metadataColumns >= 1
      ? Math.round(metadataColumns)
      : 1;

  const metadataHasBack = item.metadata?.['hasBackPanel'];
  const hasBack = typeof metadataHasBack === 'boolean' ? metadataHasBack : true;

  const metadataDrawers = item.metadata?.['drawerCells'];
  const drawerCells: Record<string, boolean> =
    metadataDrawers && typeof metadataDrawers === 'object' && !Array.isArray(metadataDrawers)
      ? (metadataDrawers as Record<string, boolean>)
      : {};

  const updateMetadata = useCallback(
    (patch: Record<string, unknown>) => {
      const next = { ...(item.metadata ?? {}), ...patch };
      onUpdate({ ...item, metadata: next });
    },
    [item, onUpdate],
  );

  /**
   * When the grid shrinks, drop any `drawerCells` keys that no longer
   * refer to a visible cell so a subsequent grow doesn't bring back
   * ghost drawers the user doesn't see in the editor.
   */
  const applyGridChange = useCallback(
    (patch: { shelfRows?: number; shelfColumns?: number }) => {
      const nextRows = patch.shelfRows ?? currentRows;
      const nextCols = patch.shelfColumns ?? currentColumns;
      const pruned: Record<string, boolean> = {};
      for (const [key, value] of Object.entries(drawerCells)) {
        if (value !== true) continue;
        const [rStr, cStr] = key.split('_');
        const r = parseInt(rStr ?? '', 10);
        const c = parseInt(cStr ?? '', 10);
        if (Number.isFinite(r) && Number.isFinite(c) && r < nextRows && c < nextCols) {
          pruned[key] = true;
        }
      }
      updateMetadata({ ...patch, drawerCells: pruned });
    },
    [currentRows, currentColumns, drawerCells, updateMetadata],
  );

  return (
    <>
      <EditablePropertyRow
        label={rowsLabel}
        value={String(currentRows)}
        onCommit={(v) => {
          const n = parseInt(v, 10);
          if (!isNaN(n) && n >= 1 && n <= 12) {
            applyGridChange({ shelfRows: n });
          }
        }}
      />
      <EditablePropertyRow
        label={columnsLabel}
        value={String(currentColumns)}
        onCommit={(v) => {
          const n = parseInt(v, 10);
          if (!isNaN(n) && n >= 1 && n <= 12) {
            applyGridChange({ shelfColumns: n });
          }
        }}
      />
      <div className={styles.row}>
        <span className={styles.rowLabel}>{backPanelLabel}</span>
        <label className={styles.checkboxLabel}>
          <input
            type="checkbox"
            checked={hasBack}
            onChange={(e) => updateMetadata({ hasBackPanel: e.target.checked })}
          />
          {t('properties.yes')}
        </label>
      </div>
      <div className={styles.row} style={{ alignItems: 'flex-start' }}>
        <span className={styles.rowLabel}>{drawersLabel}</span>
        <div
          style={{
            display: 'grid',
            gridTemplateColumns: `repeat(${currentColumns}, 18px)`,
            gap: 2,
          }}
        >
          {Array.from({ length: currentRows })
            .flatMap((_, rFromTop) => {
              // Display top row first, but store with row 0 = bottom to
              // match the 3D mesh's y-axis indexing.
              const r = currentRows - 1 - rFromTop;
              return Array.from({ length: currentColumns }).map((__, c) => {
                const key = `${r}_${c}`;
                const checked = drawerCells[key] === true;
                return (
                  <input
                    key={key}
                    type="checkbox"
                    checked={checked}
                    title={`row ${rFromTop + 1} / col ${c + 1}`}
                    onChange={(e) => {
                      const next = { ...drawerCells };
                      if (e.target.checked) {
                        next[key] = true;
                      } else {
                        delete next[key];
                      }
                      updateMetadata({ drawerCells: next });
                    }}
                    style={{ width: 18, height: 18, margin: 0, cursor: 'pointer' }}
                  />
                );
              });
            })}
        </div>
      </div>
    </>
  );
}

interface CurtainOpenSliderProps {
  readonly label: string;
  readonly value: number;
  readonly onChange: (value: number) => void;
}

function CurtainOpenSlider({ label, value, onChange }: CurtainOpenSliderProps) {
  return (
    <div className={styles.row}>
      <span className={styles.rowLabel}>{label}</span>
      <span style={{ display: 'flex', alignItems: 'center', gap: 6, flex: 1, justifyContent: 'flex-end' }}>
        <input
          type="range"
          min={0}
          max={1}
          step={0.05}
          value={value}
          onChange={(e) => {
            const next = parseFloat(e.target.value);
            if (Number.isFinite(next)) onChange(next);
          }}
          style={{ width: 100 }}
        />
        <span style={{ minWidth: 32, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
          {Math.round(value * 100)}%
        </span>
      </span>
    </div>
  );
}

function formatM(meters: number): string {
  return `${Math.round(meters * 1000) / 1000}m`;
}

function formatCm(meters: number): string {
  return `${Math.round(meters * 100)}cm`;
}
