// E2E: crop flow + the May 22 "canvas blanks on Cmd+Z after crop"
// regression.
//
// What this guards
// ────────────────
// The headline bug from the May 22 cleanup arc: cropping an image and
// then pressing Cmd+Z would land on an empty canvas instead of
// restoring the pre-crop state. Root cause was in the initializeHistory
// path — see Refinements_2026-05-22.md for the full story. The unit
// tests cover the math (cropMath) and the history-pointer behaviour
// (useDesignEditor) at the function level; this spec exercises the
// full pipeline end-to-end so a future regression that breaks any
// link in the chain (the Konva stage walk for naturalW/H, the
// updateAndCommit wiring, the keyboard handler firing on undo) gets
// caught.
//
// Why this spec is more complex than the other three
// ──────────────────────────────────────────────────
// Cropping is image-only — ElementToolbar's `allowCrop = isImage &&
// !!onStartCrop` gates the Crop button on type === 'image'. Stickers
// don't get a Crop button (even though App's handleStartCrop accepts
// them). That means this spec can't use a one-click sticker add; it
// needs a real file upload via the Upload Photo tab.
//
// Rather than committing a binary fixture file to the repo (and
// dealing with Git LFS or just bloat), we build the upload payload
// inline as a base64-encoded 1×1 transparent PNG. Playwright's
// `setInputFiles({ name, mimeType, buffer })` accepts an in-memory
// buffer with no file on disk. The PNG is the smallest valid PNG
// the spec can use — 67 bytes — which keeps the upload fast and
// avoids any minimum-dimension validation surprises.
//
// Image-load timing
// ─────────────────
// The Konva Image node loads the src URL asynchronously after the
// element is added to state. handleApplyCrop walks the stage to
// find the image node and read naturalWidth/Height; if those are
// 0 (image not yet decoded), it surfaces the
// "Photo is still loading. Try Apply again in a moment." toast
// and keeps crop mode active. Our helper below clicks Apply, then
// waits for crop mode to exit (Apply button vanishes); if it
// doesn't within a short window, that means the toast fired and
// we should wait + retry. This is the same UX a human user
// follows ("oh, didn't work, let me click again in a second").

import { test, expect } from '@playwright/test';
import {
  gotoFreshEditor,
  getElementsCount,
  pressUndo,
} from './fixtures/editor.js';

// Smallest valid PNG: 1×1 transparent. 67 bytes raw, ~90 base64
// chars. Decoded to a buffer for the upload. The image is too small
// to be visually meaningful but that's fine — the bug we're guarding
// against is history-shaped, not pixel-shaped. Crop math reduces to
// an identity crop on a 1×1 source, which still pushes a real
// history entry that undo must restore from.
const TINY_PNG_BASE64
  = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';

/**
 * Click "Apply crop" and wait for crop mode to exit. If the
 * "Photo is still loading" toast appears (because the Konva
 * Image node hasn't decoded the src yet), wait briefly and try
 * again — up to a small attempt budget.
 *
 * Success signal: the Apply button stops being visible (because
 * the ElementToolbar drops back to its non-crop face).
 *
 * This mirrors the toast-and-retry guidance in messages.js:
 *   'toast.crop-not-ready': 'Photo is still loading. Try Apply
 *    again in a moment.'
 */
async function applyCropWithRetry(page) {
  const applyButton = page.getByRole('button', { name: 'Apply crop' });
  // 8 attempts × ~1.5s budget per attempt + intermediate waits =
  // up to ~20s for the image to decode. In practice the buffer is
  // already in memory so this resolves on the first attempt; the
  // retry budget is purely defensive against slow CI environments.
  for (let attempt = 0; attempt < 8; attempt++) {
    await applyButton.click();
    // If crop mode exits within the timeout, Apply succeeded.
    // We use `waitFor` on the button's hidden state because
    // toBeHidden() polls but doesn't return a boolean.
    const exited = await applyButton
      .waitFor({ state: 'hidden', timeout: 1500 })
      .then(() => true)
      .catch(() => false);
    if (exited) return;
    // Apply button still visible → toast probably fired. Wait
    // for the image to finish loading. The toast itself
    // auto-dismisses; we just need the underlying load to
    // complete before the next click.
    await page.waitForTimeout(500);
  }
  throw new Error('Apply crop did not exit crop mode after 8 attempts');
}

test.beforeEach(async ({ page }) => {
  await gotoFreshEditor(page);
});

test('cropping an uploaded image and then Cmd/Ctrl+Z does not blank the canvas (May 22 regression)', async ({ page }) => {
  // Step 1 — Switch to the Upload Photo tab. In a fresh editor
  // the default tab is Upload, so this is mostly a defensive
  // click in case that default changes; either way it makes the
  // file input findable.
  await page.getByRole('tab', { name: /upload/i }).click();

  // Step 2 — Upload the PNG. The file input is hidden via CSS
  // (`ut__hidden-input`); Playwright's setInputFiles bypasses
  // visibility checks. The upload triggers a POST to /api/upload,
  // which is processed by the Node server (Vite proxies the
  // /api/* prefix to localhost:3001). The server's multer+sharp
  // pipeline writes a preview and original to disk and returns
  // their URLs; UploadTab's placeImage then adds the element.
  //
  // The selector targets `.ut__hidden-input` rather than a bare
  // `input[type="file"]` because the page also contains a slot-
  // upload hidden input (from the template-slot system) with
  // accept="image/*" — a bare query would resolve to two elements
  // and trip Playwright's strict mode. The class selector is
  // unique to UploadTab.
  await page.locator('input.ut__hidden-input').setInputFiles({
    name: 'pixel.png',
    mimeType: 'image/png',
    buffer: Buffer.from(TINY_PNG_BASE64, 'base64'),
  });

  // Step 3 — Wait for the upload to complete and the element
  // to be added. expect.poll handles the upload's async chain
  // (server roundtrip + image dimension probe + addElement
  // commit). The default expect timeout (5s) is plenty for a
  // 67-byte upload; if it ever flakes here, CI is overloaded
  // and the test budget should grow rather than this assertion.
  await expect.poll(() => getElementsCount(page)).toBe(1);

  // Step 4 — Make sure the element is selected so the
  // ElementToolbar renders. The upload flow may or may not
  // auto-select (depending on App's handleAddImage); clicking
  // the layer row to select is defensive and explicit. The
  // row's main button has class `.layers-item-main` and
  // contains the element's display name (typically "Photo"
  // or a filename-derived label for uploads).
  await page.locator('.layers-item-main').first().click();

  // Step 5 — Click the Crop button. It only renders for
  // type==='image' elements (allowCrop guard in
  // ElementToolbar). The button's accessible name comes from
  // its ToolbarButton's `label` prop: "Crop image".
  await page.getByRole('button', { name: /crop image/i }).click();

  // Step 6 — The toolbar swapped to its crop-mode face;
  // confirm by waiting for the Apply button before we drive it.
  await expect(page.getByRole('button', { name: 'Apply crop' })).toBeVisible();

  // Step 7 — Apply the crop. We don't modify the crop rect —
  // this is an IDENTITY crop, which still pushes a real
  // history entry (the element gets a crop attribute set even
  // though sx/sy=0 and sWidth/sHeight match naturalW/H). The
  // headline bug doesn't depend on a meaningful crop; it
  // depends on undo restoring the pre-crop snapshot.
  await applyCropWithRetry(page);

  // Step 8 — Sanity: still one element on canvas (the crop
  // committed without removing the image).
  expect(await getElementsCount(page)).toBe(1);

  // Step 9 — THE BUG CHECK. Cmd/Ctrl+Z must walk history back
  // to the pre-crop snapshot. Pre-fix, this would land on an
  // empty canvas (element count → 0); post-fix, the element
  // is still there.
  await pressUndo(page);
  expect(await getElementsCount(page)).toBe(1);

  // Step 10 — Double check: the LayersPanel row for the
  // image is still visible. A bug variant where the element
  // exists in state but is invisible on canvas would slip
  // past the count check alone; asserting the panel row
  // catches the case where elements is still length 1 but
  // populated with garbage.
  await expect(page.locator('.layers-item-main')).toHaveCount(1);
});

test('cancelling crop leaves the original element untouched', async ({ page }) => {
  // Sanity test for the cancel path. Not a regression check —
  // just confirming the crop UI is reversible without going
  // through history. If a future change accidentally pushes a
  // history entry on Cancel (or worse, applies the crop
  // anyway), this test catches it.
  await page.getByRole('tab', { name: /upload/i }).click();
  await page.locator('input.ut__hidden-input').setInputFiles({
    name: 'pixel.png',
    mimeType: 'image/png',
    buffer: Buffer.from(TINY_PNG_BASE64, 'base64'),
  });
  await expect.poll(() => getElementsCount(page)).toBe(1);

  // History-state baseline. After adding ONE element, history is
  // `[empty, [img]]` at idx=1 — the Undo button is ENABLED
  // ("step back to the empty floor"). We capture this enabled
  // state so we can verify Cancel doesn't ADD another history
  // entry on top.
  //
  // The trip wire isn't "Undo disabled" — it's enabled either
  // way. The trip wire is the count of clickable history steps:
  // a single Undo click should land us at the empty canvas
  // (0 elements). If Cancel had pushed a history entry, Undo
  // would walk back to that entry first (still 1 element, just
  // a different snapshot) and only the SECOND undo would empty
  // the canvas. We test the count-after-one-undo as the
  // definitive check.
  const undoButton = page.getByRole('button', { name: 'Undo' });
  await expect(undoButton).toBeEnabled();

  await page.locator('.layers-item-main').first().click();
  await page.getByRole('button', { name: /crop image/i }).click();
  await expect(page.getByRole('button', { name: 'Apply crop' })).toBeVisible();

  // Cancel rather than Apply. The toolbar should drop back to
  // its non-crop face; the element stays as it was.
  await page.getByRole('button', { name: 'Cancel' }).click();
  await expect(page.getByRole('button', { name: 'Apply crop' })).not.toBeVisible();

  // Element count unchanged.
  expect(await getElementsCount(page)).toBe(1);

  // The definitive "Cancel didn't push history" check. ONE undo
  // step should reach the empty floor. If Cancel had pushed a
  // (no-op) entry, we'd undo to that intermediate state and still
  // have 1 element here.
  await undoButton.click();
  expect(await getElementsCount(page)).toBe(0);
});
