// Tests for cropMath.js — the pure crop math extracted from
// App.handleApplyCrop on May 23 2026.
//
// What this guards
// ────────────────
// Three bug-prone behaviors that lived inline in App.jsx until the
// extraction:
//
//   1. Multi-crop composition. A second crop's source coordinates
//      must be expressed relative to the FULL natural image, not the
//      current crop window. Pre-extraction this was correct (the
//      `currentSrcX + localCropX * scaleX` formula composes properly)
//      but the math was buried in a 200-line component method; a
//      future refactor accidentally simplifying to
//      `localCropX * scaleX` would silently break multi-crop.
//
//   2. flipX/flipY mirror correction. The crop overlay renders in
//      canvas coords without knowing about the flip state, so the
//      function has to mirror back across the local axis. Getting
//      this wrong meant cropping a horizontally-flipped image to a
//      visual "ear" would produce a crop pointing at the opposite
//      ear. (Hard to notice in screenshots; the cropped result still
//      "looks like a face," just the wrong part of it.)
//
//   3. Tracking-field invariants. preCrop / postCrop / previousCrop
//      power the "Undo crop" button. Each has a specific semantic
//      contract (preCrop = dims BEFORE this apply; postCrop = dims
//      AFTER; previousCrop = the crop record we replaced, null for
//      first-time crops) and a regression that conflated them
//      would break single-step undo without breaking any visual
//      check at the moment of crop.

import { computeCropUpdate } from './cropMath';

// Small helpers so each test reads like "given X, expect Y" without
// long inline object literals.

function image(overrides = {}) {
  return {
    type: 'image',
    x: 0,
    y: 0,
    width: 100,
    height: 100,
    ...overrides,
  };
}

function rect(x, y, width, height) {
  return { x, y, width, height };
}

// ────────────────────────────────────────────────────────────────────
// First crop on an uncropped element.
// ────────────────────────────────────────────────────────────────────

describe('computeCropUpdate — first crop (no existing crop)', () => {
  it('maps cropRect canvas coords into source coords via natural/element scale', () => {
    // Element 100×100 displays a 200×300 source. scaleX = 2, scaleY = 3.
    // Crop the visible region (10, 20) → (60, 80) on canvas, which is
    // local (10, 20) → (60, 80) since the element is at origin. In
    // source coords that becomes (20, 60) → (120, 240) — i.e.
    // sx=20, sy=60, sWidth=100, sHeight=180.
    const el = image({ width: 100, height: 100 });
    const patch = computeCropUpdate({
      element: el,
      cropRect: rect(10, 20, 50, 60),
      naturalW: 200,
      naturalH: 300,
    });
    expect(patch.crop).toEqual({
      sx: 20,
      sy: 60,
      sWidth: 100,
      sHeight: 180,
    });
  });

  it('sets the new element geometry to match the crop rect', () => {
    const el = image({ x: 5, y: 5, width: 100, height: 100 });
    const patch = computeCropUpdate({
      element: el,
      cropRect: rect(10, 20, 50, 60),
      naturalW: 200,
      naturalH: 300,
    });
    expect(patch.x).toBe(10);
    expect(patch.y).toBe(20);
    expect(patch.width).toBe(50);
    expect(patch.height).toBe(60);
  });

  it('records previousCrop as null on a first-time crop', () => {
    // Element has no .crop, so the new crop replaces nothing —
    // previousCrop must be null so a follow-up undo lands in the
    // fully-uncropped branch instead of trying to restore a
    // non-existent prior crop.
    const el = image();
    const patch = computeCropUpdate({
      element: el,
      cropRect: rect(10, 10, 50, 50),
      naturalW: 200,
      naturalH: 200,
    });
    expect(patch.previousCrop).toBeNull();
  });

  it('records preCrop and postCrop matching the before/after element dims', () => {
    const el = image({ width: 100, height: 80 });
    const patch = computeCropUpdate({
      element: el,
      cropRect: rect(0, 0, 40, 30),
      naturalW: 200,
      naturalH: 160,
    });
    expect(patch.preCrop).toEqual({ width: 100, height: 80 });
    expect(patch.postCrop).toEqual({ width: 40, height: 30 });
  });
});

// ────────────────────────────────────────────────────────────────────
// Second (and Nth) crop — composes with previousCrop.
// ────────────────────────────────────────────────────────────────────

describe('computeCropUpdate — second crop (composes with existing crop)', () => {
  it('expresses the new source region relative to the FULL natural image', () => {
    // First crop scenario from earlier ended with element at (10, 20)
    // size 50×60 displaying source region (20, 60) → (120, 240). Now
    // the user further crops to (15, 25) size 20×30 on canvas.
    //
    // Within the element's local box, that's (5, 5) → (25, 35).
    // The element's current crop has sWidth=100 over element.width=50
    // (scaleX=2), and sHeight=180 over element.height=60 (scaleY=3).
    //
    // newCrop.sx = 20 (current sx) + 5 * 2 (local * scale) = 30
    // newCrop.sy = 60 + 5 * 3 = 75
    // newCrop.sWidth = 20 * 2 = 40
    // newCrop.sHeight = 30 * 3 = 90
    //
    // Crucially these are coords into the ORIGINAL 200×300 source,
    // not into the first-crop's sub-region — that's what makes
    // multi-crop compose correctly.
    const el = image({
      x: 10,
      y: 20,
      width: 50,
      height: 60,
      crop: { sx: 20, sy: 60, sWidth: 100, sHeight: 180 },
    });
    const patch = computeCropUpdate({
      element: el,
      cropRect: rect(15, 25, 20, 30),
      naturalW: 200,
      naturalH: 300,
    });
    expect(patch.crop).toEqual({
      sx: 30,
      sy: 75,
      sWidth: 40,
      sHeight: 90,
    });
  });

  it('captures the existing crop as previousCrop for one-step undo', () => {
    // The previous crop must survive into the patch so the
    // "Undo crop" button can restore it. The fields are pulled
    // straight from element.crop with no transformation.
    const existing = { sx: 20, sy: 60, sWidth: 100, sHeight: 180 };
    const el = image({ width: 50, height: 60, crop: existing });
    const patch = computeCropUpdate({
      element: el,
      cropRect: rect(0, 0, 20, 30),
      naturalW: 200,
      naturalH: 300,
    });
    expect(patch.previousCrop).toEqual(existing);
  });

  it('preCrop captures the current element dims, not the original natural dims', () => {
    // preCrop is "the dims immediately before this apply", which on
    // the second crop means the FIRST CROP'S dims, not the
    // original-image dims. This is the contract that lets undo go
    // back ONE step (to the first crop) rather than all the way to
    // uncropped.
    const el = image({
      width: 50,
      height: 60,
      crop: { sx: 20, sy: 60, sWidth: 100, sHeight: 180 },
    });
    const patch = computeCropUpdate({
      element: el,
      cropRect: rect(0, 0, 20, 30),
      naturalW: 200,
      naturalH: 300,
    });
    expect(patch.preCrop).toEqual({ width: 50, height: 60 });
  });

  it('multi-crop sequences do not drift across many steps', () => {
    // Simulates a user cropping repeatedly. Each step should produce
    // a sub-region of the PREVIOUS sub-region (not the original).
    // This test sanity-checks composition over multiple iterations —
    // a bug where the scale was computed from naturalW/element.width
    // instead of currentSrcW/element.width would produce wildly
    // wrong sx values by the third or fourth crop.
    let el = image({ x: 0, y: 0, width: 100, height: 100 });
    const naturalW = 1024;
    const naturalH = 1024;

    // Crop 1: inner half. Local (25, 25) → (75, 75). Source coords
    // (256, 256) → (768, 768).
    const p1 = computeCropUpdate({
      element: el,
      cropRect: rect(25, 25, 50, 50),
      naturalW,
      naturalH,
    });
    el = { ...el, ...p1 };
    expect(p1.crop.sx).toBe(256);
    expect(p1.crop.sWidth).toBe(512);

    // Crop 2: another inner half of the first crop. Element is now
    // at (25, 25) size 50×50, displaying source region (256, 256) →
    // (768, 768). Crop the inner half of that — canvas (37.5, 37.5)
    // → (62.5, 62.5). Local (12.5, 12.5) → (37.5, 37.5). Source
    // scaleX = 512/50 = 10.24, so sx = 256 + 12.5 * 10.24 = 384,
    // sWidth = 25 * 10.24 = 256. Effectively the inner half of the
    // first crop, exactly as expected.
    const p2 = computeCropUpdate({
      element: el,
      cropRect: rect(37.5, 37.5, 25, 25),
      naturalW,
      naturalH,
    });
    el = { ...el, ...p2 };
    expect(p2.crop.sx).toBe(384);
    expect(p2.crop.sWidth).toBe(256);

    // Crop 3: inner half of THAT. Element at (37.5, 37.5) size 25×25
    // displaying source (384, 384) → (640, 640). Inner half → source
    // (448, 448) → (576, 576), sWidth=128.
    const p3 = computeCropUpdate({
      element: el,
      cropRect: rect(43.75, 43.75, 12.5, 12.5),
      naturalW,
      naturalH,
    });
    expect(p3.crop.sx).toBe(448);
    expect(p3.crop.sWidth).toBe(128);
  });
});

// ────────────────────────────────────────────────────────────────────
// Flip correction.
// ────────────────────────────────────────────────────────────────────

describe('computeCropUpdate — flipX correction', () => {
  it('mirrors the X axis when element.flipX is true', () => {
    // Element 100×100 at origin, flipX=true. User visually selects
    // (10, 20) → (40, 60) on canvas. In LOCAL coords that's (10, 20)
    // → (40, 60); in SOURCE coords we need to mirror across the
    // local X axis, so the source-X range becomes
    //   left = element.width - localX - cropRect.width = 100 - 10 - 30 = 60
    //   right = 60 + 30 = 90
    // Multiply by scaleX (naturalW/element.width = 200/100 = 2):
    //   source sx = 60 * 2 = 120
    //   source sWidth = 30 * 2 = 60
    // Y is unaffected: localY = 20, sy = 20 * 2 = 40, sHeight = 40 * 2 = 80.
    const el = image({ flipX: true });
    const patch = computeCropUpdate({
      element: el,
      cropRect: rect(10, 20, 30, 40),
      naturalW: 200,
      naturalH: 200,
    });
    expect(patch.crop).toEqual({
      sx: 120,
      sy: 40,
      sWidth: 60,
      sHeight: 80,
    });
  });

  it('mirrors the Y axis when element.flipY is true', () => {
    // Y mirror analog of the X test. Source Y range:
    //   top = element.height - localY - cropRect.height = 100 - 20 - 40 = 40
    //   sy = 40 * scaleY (naturalH/element.height = 2) = 80
    //   sHeight = 40 * 2 = 80
    // X unaffected: sx = 10 * 2 = 20, sWidth = 30 * 2 = 60.
    const el = image({ flipY: true });
    const patch = computeCropUpdate({
      element: el,
      cropRect: rect(10, 20, 30, 40),
      naturalW: 200,
      naturalH: 200,
    });
    expect(patch.crop).toEqual({
      sx: 20,
      sy: 80,
      sWidth: 60,
      sHeight: 80,
    });
  });

  it('mirrors both axes when both flips are set', () => {
    // Compose both formulas.
    //   localCropX = 100 - 10 - 30 = 60, sx = 120
    //   localCropY = 100 - 20 - 40 = 40, sy = 80
    const el = image({ flipX: true, flipY: true });
    const patch = computeCropUpdate({
      element: el,
      cropRect: rect(10, 20, 30, 40),
      naturalW: 200,
      naturalH: 200,
    });
    expect(patch.crop).toEqual({
      sx: 120,
      sy: 80,
      sWidth: 60,
      sHeight: 80,
    });
  });

  it('the flip+previousCrop combo composes correctly', () => {
    // Edge case: an already-cropped, also-flipped image gets cropped
    // again. The math must apply flip correction in LOCAL coords AND
    // compose with previousCrop in source coords. Pulling this
    // through:
    //   element at (0, 0) size 50×50, flipX=true,
    //     crop = { sx: 50, sy: 50, sWidth: 100, sHeight: 100 }
    //     (source region 50..150 of a 200-wide image, displayed
    //      mirror-image on a 50px-wide element)
    //   user crops canvas (10, 10) → (40, 40), which is
    //     local (10, 10) → (40, 40)
    //   flipX correction: localCropX = 50 - 10 - 30 = 10
    //   scaleX = 100/50 = 2
    //   sx = 50 (currentSrcX) + 10 * 2 = 70
    //   sWidth = 30 * 2 = 60
    // Y is unaffected (no flipY): sy = 50 + 10 * 2 = 70, sHeight = 60.
    const el = image({
      width: 50,
      height: 50,
      flipX: true,
      crop: { sx: 50, sy: 50, sWidth: 100, sHeight: 100 },
    });
    const patch = computeCropUpdate({
      element: el,
      cropRect: rect(10, 10, 30, 30),
      naturalW: 200,
      naturalH: 200,
    });
    expect(patch.crop).toEqual({
      sx: 70,
      sy: 70,
      sWidth: 60,
      sHeight: 60,
    });
  });
});

// ────────────────────────────────────────────────────────────────────
// Null guards.
// ────────────────────────────────────────────────────────────────────

describe('computeCropUpdate — null returns on bad input', () => {
  // The null return matches handleApplyCrop's bail-out paths. Returning
  // null tells the caller "couldn't compute, don't commit." The caller
  // is responsible for surfacing the user-facing toast.

  it('returns null for missing element', () => {
    expect(computeCropUpdate({
      element: null,
      cropRect: rect(0, 0, 10, 10),
      naturalW: 100,
      naturalH: 100,
    })).toBeNull();
  });

  it('returns null for missing cropRect', () => {
    expect(computeCropUpdate({
      element: image(),
      cropRect: null,
      naturalW: 100,
      naturalH: 100,
    })).toBeNull();
  });

  it('returns null when natural dims are missing or zero (image not loaded)', () => {
    // The headline "image still loading" case. handleApplyCrop's
    // Konva stage walk can return 0/0 if the underlying HTMLImage
    // hasn't yet fired its load event. Returning null is what makes
    // the silent-failure mode impossible — the caller can't commit
    // a partial patch from a null.
    expect(computeCropUpdate({
      element: image(),
      cropRect: rect(0, 0, 10, 10),
      naturalW: 0,
      naturalH: 100,
    })).toBeNull();
    expect(computeCropUpdate({
      element: image(),
      cropRect: rect(0, 0, 10, 10),
      naturalW: 100,
      naturalH: 0,
    })).toBeNull();
    expect(computeCropUpdate({
      element: image(),
      cropRect: rect(0, 0, 10, 10),
      naturalW: undefined,
      naturalH: undefined,
    })).toBeNull();
  });

  it('returns null when element width or height is zero', () => {
    // Would cause divide-by-zero in scaleX/Y. Defensive guard.
    expect(computeCropUpdate({
      element: image({ width: 0 }),
      cropRect: rect(0, 0, 10, 10),
      naturalW: 100,
      naturalH: 100,
    })).toBeNull();
    expect(computeCropUpdate({
      element: image({ height: 0 }),
      cropRect: rect(0, 0, 10, 10),
      naturalW: 100,
      naturalH: 100,
    })).toBeNull();
  });

  it('returns null when cropRect width or height is zero', () => {
    // A zero-area crop wouldn't visually represent anything; better
    // to bail than to commit a crop with sWidth=0 that would render
    // as an invisible image.
    expect(computeCropUpdate({
      element: image(),
      cropRect: rect(0, 0, 0, 10),
      naturalW: 100,
      naturalH: 100,
    })).toBeNull();
    expect(computeCropUpdate({
      element: image(),
      cropRect: rect(0, 0, 10, 0),
      naturalW: 100,
      naturalH: 100,
    })).toBeNull();
  });
});

// ────────────────────────────────────────────────────────────────────
// Identity / no-op crop.
// ────────────────────────────────────────────────────────────────────

describe('computeCropUpdate — identity crop (full element bounds)', () => {
  it('produces a crop covering the full source when cropRect equals element bounds', () => {
    // Cropping the entire element is a no-op visually but should
    // still produce a valid crop record. The result equals the
    // natural-dims rectangle: sx=0, sy=0, sWidth=naturalW,
    // sHeight=naturalH.
    const el = image({ x: 5, y: 5, width: 100, height: 100 });
    const patch = computeCropUpdate({
      element: el,
      cropRect: rect(5, 5, 100, 100),
      naturalW: 200,
      naturalH: 300,
    });
    expect(patch.crop).toEqual({
      sx: 0,
      sy: 0,
      sWidth: 200,
      sHeight: 300,
    });
    // Element geometry unchanged (the crop rect is the same as the
    // existing element bounds).
    expect(patch.x).toBe(5);
    expect(patch.y).toBe(5);
    expect(patch.width).toBe(100);
    expect(patch.height).toBe(100);
  });
});
