// Test suite for useDesignEditor's history subsystem.
//
// What this covers
// ────────────────
// The history system is the most bug-bitten area of the codebase. This
// file exists primarily as a regression net for those bugs:
//
//   1. The May 22 "crop + Cmd+Z = blank canvas" bug. Root cause was
//      `initializeHistory` unconditionally wiping history to [empty],
//      destroying snapshots that `replaceElements` had already pushed
//      during session restoration. See the dedicated test block below
//      titled "initializeHistory + replaceElements regression"; the
//      named test there is the smoking-gun reproduction.
//
//   2. The debounce-then-undo race that motivated `updateAndCommit`'s
//      existence. The `updateElement` docblock describes it: a
//      debounced setElements queues a 300 ms timer; if the user clicks
//      undo within that window, the post-update snapshot was never
//      written and undo walks back further than intended. The
//      "updateAndCommit pushes synchronously" test below is the
//      regression test for the fix.
//
//   3. The `didDuplicate`-stale-flag bug in `duplicateElement` (per
//      its docblock). The "duplicateElement selects the copy" test
//      pins the fix.
//
//   4. The `reorderElement` splice-then-re-find: target index shifts
//      after the source splice. The reorder tests cover this.
//
// What this doesn't cover
// ───────────────────────
// Anything that requires Konva or the actual canvas to be mounted —
// transformBoundFunc behavior, snap math, the visible-ink text bbox
// integration with Transformer handles. Those need a different test
// setup (Playwright or a Konva mock) and are out of scope here. This
// file is purely about the pure-state portion of useDesignEditor.

import { renderHook, act } from '@testing-library/react';
import { useDesignEditor } from './useDesignEditor';

// ────────────────────────────────────────────────────────────────────
// Test helpers.
//
// `setupEditor()` wraps renderHook + an element factory so each test
// can read like a flat sequence of editor operations rather than
// boilerplate around the act() invocations. Returns the same object
// renderHook does (so callers can access `result.current.elements`
// directly), plus the helpers we use most often.
// ────────────────────────────────────────────────────────────────────

function setupEditor() {
  const view = renderHook(() => useDesignEditor());
  return {
    ...view,
    get current() {
      return view.result.current;
    },
    /**
     * Wrap any synchronous editor mutation in act() and return the
     * result. Keeps individual assertions noise-free.
     */
    do(fn) {
      let returnValue;
      act(() => {
        returnValue = fn(view.result.current);
      });
      return returnValue;
    },
    /**
     * Advance fake timers inside an act() boundary.
     *
     * Why this exists: when the debounced timer fires, its callback
     * calls saveToHistory which in turn calls syncUndoRedo which
     * setCanUndo/setCanRedo — a React state update. If we advance
     * timers OUTSIDE act(), the state update happens outside React's
     * batched-update window and triggers the "not wrapped in act"
     * warning. Wrapping the advance in act() makes the timer's
     * downstream setState calls visible to React's scheduler.
     *
     * The alternative — wrapping every advanceTimersByTime call site
     * in act() inline — works but adds visual noise to each test
     * body. Centralising on this helper keeps the timer tests
     * scannable.
     */
    advanceTimers(ms) {
      act(() => {
        vi.advanceTimersByTime(ms);
      });
    },
  };
}

// Minimal element factory. We don't care about realistic image dims
// for these tests — we only need objects that flow through the
// elements pipeline and end up in JSON snapshots. Each invocation
// produces a fresh object so callers don't accidentally share
// references across snapshots (which would invalidate the dedupe
// invariant we're checking).
function makeImage(overrides = {}) {
  return {
    type: 'image',
    src: 'data:image/png;base64,_test_',
    x: 0,
    y: 0,
    width: 100,
    height: 100,
    ...overrides,
  };
}

function makeText(overrides = {}) {
  return {
    type: 'text',
    text: 'hello',
    fontSize: 32,
    fontFamily: 'Pacifico',
    x: 0,
    y: 0,
    ...overrides,
  };
}

// ────────────────────────────────────────────────────────────────────
// Initial state.
// ────────────────────────────────────────────────────────────────────

describe('useDesignEditor — initial state', () => {
  it('starts with an empty elements array', () => {
    const editor = setupEditor();
    expect(editor.current.elements).toEqual([]);
  });

  it('starts with no selection', () => {
    const editor = setupEditor();
    expect(editor.current.selectedId).toBeNull();
    expect(editor.current.selectedIds.size).toBe(0);
  });

  it('starts with canUndo and canRedo both false', () => {
    const editor = setupEditor();
    // The history is empty; undo at idx -1 has nowhere to go. canRedo
    // is bounded by history length so it's also false.
    expect(editor.current.canUndo).toBe(false);
    expect(editor.current.canRedo).toBe(false);
  });
});

// ────────────────────────────────────────────────────────────────────
// addElement: the simplest history-push path.
// ────────────────────────────────────────────────────────────────────

describe('useDesignEditor — addElement', () => {
  it('pushes the element into state', () => {
    const editor = setupEditor();
    editor.do((e) => e.addElement(makeImage()));
    expect(editor.current.elements).toHaveLength(1);
    expect(editor.current.elements[0].type).toBe('image');
  });

  it('assigns a unique id (overrides any provided id)', () => {
    // `addElement` always calls makeId() and overwrites any incoming
    // id. This is by design — callers don't get to dictate ids — but
    // worth pinning so a future refactor that wanted to honour
    // incoming ids would have to explicitly opt out of this test.
    const editor = setupEditor();
    editor.do((e) => e.addElement({ ...makeImage(), id: 'caller-supplied' }));
    expect(editor.current.elements[0].id).not.toBe('caller-supplied');
    expect(editor.current.elements[0].id).toMatch(/^element-/);
  });

  it('selects the newly added element', () => {
    const editor = setupEditor();
    const newId = editor.do((e) => e.addElement(makeImage()));
    expect(editor.current.selectedId).toBe(newId);
  });

  it('after one add, canUndo is false (we are at the history floor)', () => {
    // The first push lands at history idx 0. canUndo = (idx > 0)
    // evaluates false. This protects against users accidentally
    // undoing past their first action into an empty-canvas state
    // they never explicitly created.
    const editor = setupEditor();
    editor.do((e) => e.addElement(makeImage()));
    expect(editor.current.canUndo).toBe(false);
  });

  it('after two adds, canUndo is true', () => {
    const editor = setupEditor();
    editor.do((e) => e.addElement(makeImage()));
    editor.do((e) => e.addElement(makeImage({ src: 'data:image/png;base64,_two_' })));
    expect(editor.current.canUndo).toBe(true);
  });
});

// ────────────────────────────────────────────────────────────────────
// undo / redo: the read side of history.
// ────────────────────────────────────────────────────────────────────

describe('useDesignEditor — undo / redo', () => {
  it('undo restores the previous snapshot', () => {
    const editor = setupEditor();
    editor.do((e) => e.addElement(makeImage()));
    editor.do((e) => e.addElement(makeText()));
    expect(editor.current.elements).toHaveLength(2);
    editor.do((e) => e.undo());
    expect(editor.current.elements).toHaveLength(1);
    expect(editor.current.elements[0].type).toBe('image');
  });

  it('undo is a no-op at the history floor', () => {
    // After one push, idx is 0 — the floor. Pressing undo should
    // leave state unchanged rather than walk into idx -1 territory
    // (which previously could have restored an undefined snapshot).
    const editor = setupEditor();
    editor.do((e) => e.addElement(makeImage()));
    const before = editor.current.elements;
    editor.do((e) => e.undo());
    expect(editor.current.elements).toBe(before);
    expect(editor.current.canUndo).toBe(false);
  });

  it('redo walks back up after undo', () => {
    const editor = setupEditor();
    editor.do((e) => e.addElement(makeImage()));
    editor.do((e) => e.addElement(makeText()));
    editor.do((e) => e.undo());
    expect(editor.current.elements).toHaveLength(1);
    expect(editor.current.canRedo).toBe(true);
    editor.do((e) => e.redo());
    expect(editor.current.elements).toHaveLength(2);
    expect(editor.current.canRedo).toBe(false);
  });

  it('redo is a no-op when there are no redo entries', () => {
    const editor = setupEditor();
    editor.do((e) => e.addElement(makeImage()));
    expect(editor.current.canRedo).toBe(false);
    editor.do((e) => e.redo());
    expect(editor.current.elements).toHaveLength(1);
  });

  it('a new action after undo discards redo entries (redo-stack truncation)', () => {
    // Standard editor behavior: if you undo and then make a new edit,
    // the redo stack is gone. The new action becomes the only
    // future from the current idx.
    const editor = setupEditor();
    editor.do((e) => e.addElement(makeImage()));                                    // idx 0
    editor.do((e) => e.addElement(makeText()));                                     // idx 1
    editor.do((e) => e.undo());                                                     // idx 0, redo to text available
    expect(editor.current.canRedo).toBe(true);
    editor.do((e) => e.addElement(makeImage({ src: 'data:image/png;base64,_3_' }))); // idx 1, new branch
    expect(editor.current.canRedo).toBe(false);
    expect(editor.current.elements).toHaveLength(2);
    // The redo target (the text element) is gone — undo from the new
    // branch's tip lands on the single-image state, not the text state.
    editor.do((e) => e.undo());
    expect(editor.current.elements).toHaveLength(1);
    expect(editor.current.elements[0].type).toBe('image');
  });

  it('undo preserves selection when the restored snapshot still contains the selected id', () => {
    const editor = setupEditor();
    const firstId = editor.do((e) => e.addElement(makeImage()));
    editor.do((e) => e.addElement(makeText()));
    editor.do((e) => e.selectElement(firstId));
    editor.do((e) => e.undo());
    expect(editor.current.selectedId).toBe(firstId);
  });

  it('undo drops selection when the restored snapshot no longer contains the selected id', () => {
    const editor = setupEditor();
    editor.do((e) => e.addElement(makeImage()));
    const secondId = editor.do((e) => e.addElement(makeText()));
    expect(editor.current.selectedId).toBe(secondId);
    editor.do((e) => e.undo());
    // The second element doesn't exist in the restored snapshot,
    // so it gets filtered out of the selection set.
    expect(editor.current.selectedIds.has(secondId)).toBe(false);
  });
});

// ────────────────────────────────────────────────────────────────────
// initializeHistory — the May 22 regression.
//
// This is the headline reason the test file exists.
// ────────────────────────────────────────────────────────────────────

describe('useDesignEditor — initializeHistory', () => {
  it('seeds an empty-array entry when history is empty (fresh-session path)', () => {
    // Fresh session: no replaceElements, no template. initializeHistory
    // creates the history floor so the user's first action becomes
    // undoable back to the empty starting canvas.
    const editor = setupEditor();
    editor.do((e) => e.initializeHistory());
    // After init, history = [[]] at idx 0. Adding an element pushes
    // to idx 1; undo walks back to idx 0 (empty); canUndo flips off.
    editor.do((e) => e.addElement(makeImage()));
    expect(editor.current.canUndo).toBe(true);
    editor.do((e) => e.undo());
    expect(editor.current.elements).toEqual([]);
  });

  it('does NOT wipe history when entries already exist (regression for crop+undo blank-canvas bug)', () => {
    // This is THE regression test. Pre-fix, `initializeHistory`
    // unconditionally set history to [JSON.stringify([])], which
    // destroyed the snapshot that `replaceElements` had pushed
    // during session restoration. Sequence reproducing the bug:
    //
    //   1. replaceElements pushes the restored 5-element snapshot.
    //   2. initializeHistory wipes history to [[]] at idx 0.
    //   3. User crops → updateAndCommit pushes the cropped state
    //      at idx 1. History = [[], [cropped]].
    //   4. User presses Cmd+Z → undo walks 1 → 0 → restores [],
    //      blanking the canvas.
    //
    // Post-fix, step 2 is a no-op when history has entries. The
    // assertion below is on step 4's outcome: undo must restore the
    // PRE-CROP state, not the empty array.
    const editor = setupEditor();

    // Step 1: session restoration via replaceElements.
    editor.do((e) =>
      e.replaceElements([
        makeImage({ src: 'data:image/png;base64,_a_' }),
        makeImage({ src: 'data:image/png;base64,_b_' }),
      ])
    );
    expect(editor.current.elements).toHaveLength(2);

    // Step 2: initializeHistory runs (App.jsx's restorationComplete
    // effect). Pre-fix this would wipe; post-fix it's a no-op.
    editor.do((e) => e.initializeHistory());
    expect(editor.current.elements).toHaveLength(2);

    // Step 3: user performs an action. We use updateAndCommit because
    // that's the path the crop button takes, but any committing path
    // would expose the same bug. Capture the post-restore ids so we
    // can target one with the update.
    const firstId = editor.current.elements[0].id;
    editor.do((e) =>
      e.updateAndCommit(firstId, {
        x: 50,
        y: 50,
        width: 80,
        height: 80,
        crop: { sx: 10, sy: 10, sWidth: 50, sHeight: 50 },
      })
    );
    expect(editor.current.elements.find((el) => el.id === firstId).crop).toBeDefined();

    // Step 4: undo. The crucial assertion.
    //
    // Pre-fix: undo walks idx 1 → 0, restores [], elements.length === 0.
    // Post-fix: undo walks idx 1 → 0, restores the replaceElements
    // snapshot, elements.length === 2 with no crop on the first.
    expect(editor.current.canUndo).toBe(true);
    editor.do((e) => e.undo());
    expect(editor.current.elements).toHaveLength(2);
    expect(editor.current.elements.find((el) => el.id === firstId)?.crop).toBeUndefined();
  });

  it('multiple initializeHistory calls after the first push are no-ops', () => {
    // Defends against a future refactor that might call
    // initializeHistory more than once — for instance if React 18
    // StrictMode causes the App-level restorationComplete effect to
    // fire twice. Each subsequent call must remain a no-op so the
    // floor stays correct.
    const editor = setupEditor();
    editor.do((e) => e.replaceElements([makeImage()]));
    editor.do((e) => e.initializeHistory());
    editor.do((e) => e.initializeHistory());
    editor.do((e) => e.initializeHistory());
    editor.do((e) => e.addElement(makeText()));
    expect(editor.current.elements).toHaveLength(2);
    editor.do((e) => e.undo());
    // Restored snapshot is the replaceElements one, not empty.
    expect(editor.current.elements).toHaveLength(1);
  });
});

// ────────────────────────────────────────────────────────────────────
// updateAndCommit — the synchronous-push escape hatch.
// ────────────────────────────────────────────────────────────────────

describe('useDesignEditor — replaceElements id preservation', () => {
  it('preserves input ids when present (regression: StrictMode dual-restoration)', () => {
    // The session-restoration case. Elements come back from localStorage
    // with their original ids; replaceElements must preserve them so a
    // second call with the same input produces the same JSON and gets
    // deduped by saveToHistory.
    const editor = setupEditor();
    const items = [
      { ...makeImage(), id: 'persisted-1' },
      { ...makeText(), id: 'persisted-2' },
    ];
    editor.do((e) => e.replaceElements(items));
    expect(editor.current.elements[0].id).toBe('persisted-1');
    expect(editor.current.elements[1].id).toBe('persisted-2');
  });

  it('generates ids for elements that do not have one (template-loading path)', () => {
    // Template constants ship elements without ids — replaceElements
    // generates them. This test verifies the generation path still works
    // after the id-preservation refactor.
    const editor = setupEditor();
    editor.do((e) => e.replaceElements([makeImage(), makeText()]));
    expect(editor.current.elements[0].id).toBeTruthy();
    expect(editor.current.elements[1].id).toBeTruthy();
    expect(editor.current.elements[0].id).toMatch(/^element-/);
    expect(editor.current.elements[1].id).toMatch(/^element-/);
    expect(editor.current.elements[0].id).not.toBe(editor.current.elements[1].id);
  });

  it('mixed input (some with ids, some without) preserves and generates as needed', () => {
    // Defensive: a future caller might pass a partially-ided array.
    // Per-element decision means elements with ids keep them, elements
    // without get fresh generation.
    const editor = setupEditor();
    editor.do((e) => e.replaceElements([
      { ...makeImage(), id: 'keeper' },
      makeText(), // no id
      { ...makeImage(), id: 'also-keeper' },
    ]));
    expect(editor.current.elements[0].id).toBe('keeper');
    expect(editor.current.elements[1].id).toMatch(/^element-/);
    expect(editor.current.elements[2].id).toBe('also-keeper');
  });

  it('second call with the same id-bearing input dedupes in history (the StrictMode case)', () => {
    // The headline regression test. Before the fix:
    //   call 1 → replaceElements assigns fresh ids → pushes snapshot A at idx 0
    //   call 2 → replaceElements assigns DIFFERENT fresh ids → pushes
    //              snapshot B at idx 1 (dedupe misses because JSON differs)
    //   undo → walks idx 1 → 0, restores snapshot A (different ids than
    //              what's currently on canvas). Visually equivalent but
    //              produces two undos for one logical action.
    //
    // After the fix:
    //   call 1 → preserves input ids → pushes at idx 0
    //   call 2 → preserves the SAME input ids → saveToHistory dedupes →
    //              no idx change
    //   undo → no-op at floor; canUndo stays false
    const editor = setupEditor();
    const items = [
      { ...makeImage(), id: 'restored-1' },
      { ...makeImage({ src: 'data:image/png;base64,_b_' }), id: 'restored-2' },
    ];
    editor.do((e) => e.replaceElements(items));
    editor.do((e) => e.replaceElements(items));
    // After two identical calls, canUndo must still be false — only one
    // entry exists, dedupe caught the second. (canUndo = idx > 0; idx
    // stays at 0.)
    expect(editor.current.canUndo).toBe(false);
  });
});

describe('useDesignEditor — updateAndCommit', () => {
  it('pushes the new state synchronously (no debounce)', () => {
    // The whole point of updateAndCommit existing separately from
    // updateElement is that its history push happens during React's
    // commit, before any subsequent user event. This test verifies
    // that contract: an undo immediately after updateAndCommit
    // restores the PRE-update state. Pre-fix, an immediate undo
    // would walk past the never-written post-update snapshot and
    // restore the snapshot before THAT.
    const editor = setupEditor();
    const id = editor.do((e) => e.addElement(makeImage({ x: 0 })));
    editor.do((e) => e.updateAndCommit(id, { x: 50 }));
    expect(editor.current.elements[0].x).toBe(50);
    editor.do((e) => e.undo());
    expect(editor.current.elements[0].x).toBe(0);
  });

  it('flushes pending debounced changes before its own push', () => {
    // updateElement queues a 300 ms timer. If updateAndCommit fires
    // before the timer, the pending change would be lost without an
    // explicit flush. The fix is the leading flushPendingChanges()
    // inside updateAndCommit. After updateAndCommit completes,
    // both the debounced update AND the commit should be undoable
    // as separate history entries.
    vi.useFakeTimers();
    try {
      const editor = setupEditor();
      const id = editor.do((e) => e.addElement(makeImage({ x: 0 })));
      // Debounced update — timer scheduled but NOT yet fired.
      editor.do((e) => e.updateElement(id, { x: 25 }));
      expect(editor.current.elements[0].x).toBe(25);
      // Immediate commit — should flush the pending x=25 to history,
      // then push x=50 on top.
      editor.do((e) => e.updateAndCommit(id, { x: 50 }));
      expect(editor.current.elements[0].x).toBe(50);
      // Two undos should land at x=25, then x=0 — proving both
      // snapshots made it to history.
      editor.do((e) => e.undo());
      expect(editor.current.elements[0].x).toBe(25);
      editor.do((e) => e.undo());
      expect(editor.current.elements[0].x).toBe(0);
    } finally {
      vi.useRealTimers();
    }
  });
});

// ────────────────────────────────────────────────────────────────────
// updateElement — the debounced path.
// ────────────────────────────────────────────────────────────────────

describe('useDesignEditor — updateElement (debounced)', () => {
  it('updates state immediately but defers the history push', () => {
    vi.useFakeTimers();
    try {
      const editor = setupEditor();
      const id = editor.do((e) => e.addElement(makeImage({ x: 0 })));
      editor.do((e) => e.updateElement(id, { x: 10 }));
      // State is updated synchronously…
      expect(editor.current.elements[0].x).toBe(10);
      // …but the history push hasn't happened. Undoing right now
      // would restore the pre-update state (the addElement snapshot).
      // We confirm by NOT advancing the timer and checking that the
      // state is exposed but the snapshot isn't committed yet.
      // (canUndo is true because the addElement snapshot is still
      // undoable; we can't directly inspect history length, but we
      // can verify the commitHistory behavior by advancing time.)
      editor.advanceTimers(299);
      expect(editor.current.elements[0].x).toBe(10);
      editor.advanceTimers(2); // total 301 ms, timer fired
      expect(editor.current.elements[0].x).toBe(10);
      // After flush, undoing should restore the addElement snapshot.
      editor.do((e) => e.undo());
      expect(editor.current.elements[0].x).toBe(0);
    } finally {
      vi.useRealTimers();
    }
  });

  it('rapid updates collapse into a single history entry', () => {
    // A drag handler that fires updateElement on every mousemove
    // shouldn't fill history with 60 entries per second. The
    // debounce timer collapses a burst of updates into one push.
    vi.useFakeTimers();
    try {
      const editor = setupEditor();
      const id = editor.do((e) => e.addElement(makeImage({ x: 0 })));
      for (let i = 1; i <= 10; i++) {
        editor.do((e) => e.updateElement(id, { x: i * 5 }));
      }
      expect(editor.current.elements[0].x).toBe(50);
      editor.advanceTimers(300);
      // One history entry from the burst. Undo lands at x=0.
      editor.do((e) => e.undo());
      expect(editor.current.elements[0].x).toBe(0);
      // Redo restores the final x=50 (not an intermediate value).
      editor.do((e) => e.redo());
      expect(editor.current.elements[0].x).toBe(50);
    } finally {
      vi.useRealTimers();
    }
  });

  it('commitHistory drains pending changes synchronously', () => {
    // commitHistory is the user-facing flush. Callers use it after
    // a continuous interaction (transform-end, blur on text input)
    // to commit the latest state to history without waiting for
    // the 300 ms timer.
    const editor = setupEditor();
    const id = editor.do((e) => e.addElement(makeImage({ x: 0 })));
    editor.do((e) => e.updateElement(id, { x: 42 }));
    editor.do((e) => e.commitHistory());
    // Without fake timers, the debounced timer hasn't fired —
    // commitHistory should be what got the snapshot in.
    editor.do((e) => e.undo());
    expect(editor.current.elements[0].x).toBe(0);
  });
});

// ────────────────────────────────────────────────────────────────────
// deleteElement — selection cleanup.
// ────────────────────────────────────────────────────────────────────

describe('useDesignEditor — deleteElement', () => {
  it('removes the element from state and history', () => {
    const editor = setupEditor();
    const id = editor.do((e) => e.addElement(makeImage()));
    editor.do((e) => e.deleteElement(id));
    expect(editor.current.elements).toHaveLength(0);
    // Undo restores it.
    editor.do((e) => e.undo());
    expect(editor.current.elements).toHaveLength(1);
  });

  it('clears the deleted id from selection', () => {
    const editor = setupEditor();
    const id = editor.do((e) => e.addElement(makeImage()));
    expect(editor.current.selectedId).toBe(id);
    editor.do((e) => e.deleteElement(id));
    expect(editor.current.selectedId).toBeNull();
  });
});

// ────────────────────────────────────────────────────────────────────
// duplicateElement — regression for the didDuplicate stale-flag bug.
// ────────────────────────────────────────────────────────────────────

describe('useDesignEditor — duplicateElement', () => {
  it('inserts the copy immediately after the source in the array', () => {
    const editor = setupEditor();
    const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
    const b = editor.do((e) => e.addElement(makeImage({ src: 'b' })));
    editor.do((e) => e.duplicateElement(a));
    // Order: a, a-copy, b. The copy lands at index 1.
    const srcs = editor.current.elements.map((el) => el.src);
    expect(srcs[0]).toBe('a');
    expect(srcs[1]).toBe('a');
    expect(srcs[2]).toBe('b');
    expect(editor.current.elements[2].id).toBe(b);
  });

  it('selects the copy synchronously (regression for didDuplicate stale-flag bug)', () => {
    // The pre-fix bug: selection assignment lived inside an async
    // setElements updater, so `didDuplicate` was read before the
    // updater ran and selection stayed on the source. The fix
    // moved the id generation and selection assignment outside the
    // updater. This test pins that behavior.
    const editor = setupEditor();
    const a = editor.do((e) => e.addElement(makeImage()));
    const newId = editor.do((e) => e.duplicateElement(a));
    expect(newId).toBeTruthy();
    expect(newId).not.toBe(a);
    expect(editor.current.selectedId).toBe(newId);
  });

  it('returns null and is a no-op when the source id does not exist', () => {
    const editor = setupEditor();
    const ret = editor.do((e) => e.duplicateElement('nonexistent-id'));
    expect(ret).toBeNull();
    expect(editor.current.elements).toHaveLength(0);
  });

  it('clears the source-element slotId on the copy', () => {
    // Documented behavior: a duplicated element shouldn't claim the
    // same template slot as its source. Without this, a slot would
    // appear filled twice (once by the original, once by the copy).
    const editor = setupEditor();
    const a = editor.do((e) => e.addElement(makeImage({ slotId: 'slot-1' })));
    editor.do((e) => e.duplicateElement(a));
    const copy = editor.current.elements[1];
    expect(copy.slotId).toBeUndefined();
  });
});

// ────────────────────────────────────────────────────────────────────
// reorderElement — splice-then-re-find target.
// ────────────────────────────────────────────────────────────────────

describe('useDesignEditor — reorderElement', () => {
  it('moves source AFTER target', () => {
    const editor = setupEditor();
    const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
    const b = editor.do((e) => e.addElement(makeImage({ src: 'b' })));
    const c = editor.do((e) => e.addElement(makeImage({ src: 'c' })));
    // Move a (currently first) to AFTER c (currently last). Result: b, c, a.
    editor.do((e) => e.reorderElement(a, c, 'after'));
    expect(editor.current.elements.map((el) => el.src)).toEqual(['b', 'c', 'a']);
  });

  it('moves source BEFORE target', () => {
    const editor = setupEditor();
    editor.do((e) => e.addElement(makeImage({ src: 'a' })));
    const b = editor.do((e) => e.addElement(makeImage({ src: 'b' })));
    const c = editor.do((e) => e.addElement(makeImage({ src: 'c' })));
    // Move c to BEFORE b. Result: a, c, b.
    editor.do((e) => e.reorderElement(c, b, 'before'));
    expect(editor.current.elements.map((el) => el.src)).toEqual(['a', 'c', 'b']);
  });

  it('correctly accounts for target-index shift after source splice', () => {
    // Documented bug source: when source is BEFORE target in the
    // array, splicing source out reduces target's index by one.
    // The reorder logic re-finds target by id after the splice
    // instead of using the cached index. This test covers the
    // case where the cached-index approach would have been wrong:
    //   start: [a, b, c, d]
    //   reorder a after d
    //   if we used target's original index (3) the splice would
    //     place a at index 4, beyond the array length
    //   correct behavior: re-find d at index 2 (after a's removal),
    //     insert a at index 3
    //   result: [b, c, d, a]
    const editor = setupEditor();
    const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
    editor.do((e) => e.addElement(makeImage({ src: 'b' })));
    editor.do((e) => e.addElement(makeImage({ src: 'c' })));
    const d = editor.do((e) => e.addElement(makeImage({ src: 'd' })));
    editor.do((e) => e.reorderElement(a, d, 'after'));
    expect(editor.current.elements.map((el) => el.src)).toEqual(['b', 'c', 'd', 'a']);
  });

  it('is a no-op when source === target', () => {
    const editor = setupEditor();
    const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
    const before = editor.current.elements;
    editor.do((e) => e.reorderElement(a, a, 'after'));
    expect(editor.current.elements).toBe(before);
  });
});

// ────────────────────────────────────────────────────────────────────
// Z-order: bringForward / sendBackward.
// ────────────────────────────────────────────────────────────────────

describe('useDesignEditor — z-order', () => {
  it('bringForward swaps with the next element', () => {
    const editor = setupEditor();
    const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
    editor.do((e) => e.addElement(makeImage({ src: 'b' })));
    editor.do((e) => e.bringForward(a));
    expect(editor.current.elements.map((el) => el.src)).toEqual(['b', 'a']);
  });

  it('bringForward is a no-op for the last element', () => {
    const editor = setupEditor();
    editor.do((e) => e.addElement(makeImage({ src: 'a' })));
    const b = editor.do((e) => e.addElement(makeImage({ src: 'b' })));
    const before = editor.current.elements;
    editor.do((e) => e.bringForward(b));
    expect(editor.current.elements).toBe(before);
  });

  it('sendBackward swaps with the previous element', () => {
    const editor = setupEditor();
    editor.do((e) => e.addElement(makeImage({ src: 'a' })));
    const b = editor.do((e) => e.addElement(makeImage({ src: 'b' })));
    editor.do((e) => e.sendBackward(b));
    expect(editor.current.elements.map((el) => el.src)).toEqual(['b', 'a']);
  });

  it('sendBackward is a no-op for the first element', () => {
    const editor = setupEditor();
    const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
    editor.do((e) => e.addElement(makeImage({ src: 'b' })));
    const before = editor.current.elements;
    editor.do((e) => e.sendBackward(a));
    expect(editor.current.elements).toBe(before);
  });
});

// ────────────────────────────────────────────────────────────────────
// Selection setters.
// ────────────────────────────────────────────────────────────────────

describe('useDesignEditor — selection', () => {
  it('selectElement sets a single-element selection', () => {
    const editor = setupEditor();
    const id = editor.do((e) => e.addElement(makeImage()));
    editor.do((e) => e.addElement(makeText()));
    editor.do((e) => e.selectElement(id));
    expect(editor.current.selectedId).toBe(id);
    expect(editor.current.selectedIds.size).toBe(1);
  });

  it('deselectAll clears the selection', () => {
    const editor = setupEditor();
    editor.do((e) => e.addElement(makeImage()));
    expect(editor.current.selectedIds.size).toBe(1);
    editor.do((e) => e.deselectAll());
    expect(editor.current.selectedIds.size).toBe(0);
    expect(editor.current.selectedId).toBeNull();
  });

  it('toggleInSelection adds and removes ids', () => {
    const editor = setupEditor();
    const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
    const b = editor.do((e) => e.addElement(makeImage({ src: 'b' })));
    // After two adds, b is selected (addElement selects the new id).
    expect(editor.current.selectedIds.has(b)).toBe(true);
    editor.do((e) => e.toggleInSelection(a));
    expect(editor.current.selectedIds.has(a)).toBe(true);
    expect(editor.current.selectedIds.has(b)).toBe(true);
    expect(editor.current.selectedIds.size).toBe(2);
    // selectedId is null when more than one element is selected.
    expect(editor.current.selectedId).toBeNull();
    editor.do((e) => e.toggleInSelection(a));
    expect(editor.current.selectedIds.has(a)).toBe(false);
    expect(editor.current.selectedIds.size).toBe(1);
  });

  it('setSelection replaces the selection set with the given ids', () => {
    const editor = setupEditor();
    const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
    const b = editor.do((e) => e.addElement(makeImage({ src: 'b' })));
    const c = editor.do((e) => e.addElement(makeImage({ src: 'c' })));
    editor.do((e) => e.setSelection([a, c]));
    expect(editor.current.selectedIds.size).toBe(2);
    expect(editor.current.selectedIds.has(a)).toBe(true);
    expect(editor.current.selectedIds.has(c)).toBe(true);
    expect(editor.current.selectedIds.has(b)).toBe(false);
  });
});

// ────────────────────────────────────────────────────────────────────
// deleteMany — bulk delete + selection cleanup.
// ────────────────────────────────────────────────────────────────────

describe('useDesignEditor — deleteMany', () => {
  it('removes all listed ids as a single history entry', () => {
    const editor = setupEditor();
    const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
    editor.do((e) => e.addElement(makeImage({ src: 'b' })));
    const c = editor.do((e) => e.addElement(makeImage({ src: 'c' })));
    editor.do((e) => e.deleteMany([a, c]));
    expect(editor.current.elements).toHaveLength(1);
    expect(editor.current.elements[0].src).toBe('b');
    // A single undo restores both. If it took two undos that would
    // mean deleteMany pushed two history entries — wrong, since it's
    // documented as a single-entry operation.
    editor.do((e) => e.undo());
    expect(editor.current.elements).toHaveLength(3);
  });

  it('clears deleted ids from selection', () => {
    const editor = setupEditor();
    const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
    const b = editor.do((e) => e.addElement(makeImage({ src: 'b' })));
    editor.do((e) => e.setSelection([a, b]));
    editor.do((e) => e.deleteMany([a]));
    expect(editor.current.selectedIds.has(a)).toBe(false);
    expect(editor.current.selectedIds.has(b)).toBe(true);
  });

  it('is a no-op for an empty ids array', () => {
    const editor = setupEditor();
    editor.do((e) => e.addElement(makeImage()));
    const before = editor.current.elements;
    editor.do((e) => e.deleteMany([]));
    expect(editor.current.elements).toBe(before);
  });
});
