// Tests for elementBounds.js — rotated AABB math, out-of-bounds
// detection, and fit-to-canvas correction.
//
// What this guards
// ────────────────
// elementBounds powers the "your design extends past the print area"
// warning and the Add-to-Cart disable. False negatives let users buy
// shirts with their pet's ears cropped; false positives nag users
// whose designs are actually fine. The rotation math is the most
// likely source of either — and mirrors the constrainTransform math
// in DesignCanvas's boundBoxFunc, so any drift here also drifts the
// runtime drag constraint.
//
// Key invariants tested
// ─────────────────────
//   • Image AABB is straightforward [0, w] × [0, h] rotated around
//     (x, y) — element.x/y is the top-left, not the center, and the
//     rotation pivot for bounds purposes is the top-left (matching
//     Konva's behavior when no offset is set, which is how unflipped
//     elements render their bounds).
//   • BOUNDS_EPSILON (0.5) absorbs sub-pixel rounding so an element
//     snapped exactly to the canvas edge isn't flagged due to a
//     floating-point residual.
//   • fitElementToBounds returns the SAME REFERENCE when nothing
//     changed — important for React render perf (a new object every
//     check would invalidate downstream memos).
//   • fitElementsToBounds prints a console.warn when anything was
//     fitted, since these are usually template authoring bugs.
//
// What this doesn't test deeply
// ─────────────────────────────
// Text AABB delegates to `computeTextVisibleBbox`, which depends on
// canvas measureText. jsdom's canvas measureText (when the `canvas`
// package is loaded) is approximate and font-dependent. We test text
// at the element-shape level (a text element WAY outside the canvas
// is out; a text element near origin is in) rather than asserting
// pixel-exact text AABB values.

import {
  isElementOutOfBounds,
  countOutOfBounds,
  fitElementToBounds,
  fitElementsToBounds,
} from './elementBounds';

const CANVAS_SIZE = 300;

// ────────────────────────────────────────────────────────────────────
// isElementOutOfBounds — axis-aligned cases first.
// ────────────────────────────────────────────────────────────────────

describe('isElementOutOfBounds — images (no rotation)', () => {
  it('returns false for an image fully inside the canvas', () => {
    const el = { type: 'image', x: 50, y: 50, width: 100, height: 100 };
    expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(false);
  });

  it('returns true when minX is past the left edge', () => {
    const el = { type: 'image', x: -10, y: 50, width: 100, height: 100 };
    expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(true);
  });

  it('returns true when maxX is past the right edge', () => {
    // x=250, width=100 → maxX=350 > 300.
    const el = { type: 'image', x: 250, y: 50, width: 100, height: 100 };
    expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(true);
  });

  it('returns true when minY is past the top edge', () => {
    const el = { type: 'image', x: 50, y: -10, width: 100, height: 100 };
    expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(true);
  });

  it('returns true when maxY is past the bottom edge', () => {
    const el = { type: 'image', x: 50, y: 250, width: 100, height: 100 };
    expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(true);
  });

  it('treats sticker type the same as image', () => {
    // The AABB math branches on `image | sticker` together — both
    // are rectangular elements with the same width/height geometry.
    // Pinning this so a future refactor that separated them would
    // surface the divergence here.
    const el = { type: 'sticker', x: -10, y: 0, width: 100, height: 100 };
    expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(true);
  });
});

describe('isElementOutOfBounds — BOUNDS_EPSILON boundary', () => {
  // BOUNDS_EPSILON = 0.5 absorbs sub-pixel rounding. The exact value
  // is encapsulated inside the module, but its EFFECT is visible
  // through these boundary tests: a tiny float residual at the edge
  // doesn't flip the result, but a half-pixel or more does.

  it('counts as inside when an edge is within 0.5 of the boundary', () => {
    // x=-0.4 means minX is -0.4. With epsilon=0.5, the test
    // `aabb.minX < -BOUNDS_EPSILON` becomes `-0.4 < -0.5`, false.
    const el = { type: 'image', x: -0.4, y: 0, width: 100, height: 100 };
    expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(false);
  });

  it('counts as out when an edge exceeds the boundary by more than 0.5', () => {
    // x=-0.6 means minX is -0.6. `-0.6 < -0.5` is true → out.
    const el = { type: 'image', x: -0.6, y: 0, width: 100, height: 100 };
    expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(true);
  });

  it('counts as inside when maxX is exactly at the canvas edge', () => {
    // x=200, width=100 → maxX=300. `300 > 300 + 0.5` is false → in.
    // Common case: a user dragged an element flush to the right edge.
    const el = { type: 'image', x: 200, y: 0, width: 100, height: 100 };
    expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(false);
  });
});

describe('isElementOutOfBounds — rotated images', () => {
  // Rotation is around (x, y) — the unrotated top-left. The function
  // rotates all four corners and takes min/max for the AABB.
  //
  // These tests pre-compute the expected geometry so the assertion
  // values are independent of the AABB internals.

  it('a 90° rotation pivots the rect into a different bounding box', () => {
    // 100×100 image at (150, 150) rotated 90°.
    //   cos=0, sin=1
    //   TL: (150 + 0*0 - 0*1, 150 + 0*1 + 0*0) = (150, 150)
    //   TR: (150 + 100*0 - 0*1, 150 + 100*1 + 0*0) = (150, 250)
    //   BL: (150 + 0*0 - 100*1, 150 + 0*1 + 100*0) = (50, 150)
    //   BR: (150 + 100*0 - 100*1, 150 + 100*1 + 100*0) = (50, 250)
    //   AABB: minX=50, maxX=150, minY=150, maxY=250.
    // Fully inside the 300×300 canvas.
    const el = {
      type: 'image',
      x: 150,
      y: 150,
      width: 100,
      height: 100,
      rotation: 90,
    };
    expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(false);
  });

  it('a 45° rotation around (0,0) extends the bbox into negative coords (out)', () => {
    // 100×100 at (0, 0) rotated 45°.
    //   cos = sin = √2/2 ≈ 0.7071
    //   TL: (0, 0)
    //   TR: (100*c, 100*s) = (70.7, 70.7)
    //   BL: (-100*s, 100*c) = (-70.7, 70.7)
    //   BR: (100*c - 100*s, 100*s + 100*c) = (0, 141.4)
    // minX = -70.7 → past the left edge by way more than epsilon → out.
    const el = {
      type: 'image',
      x: 0,
      y: 0,
      width: 100,
      height: 100,
      rotation: 45,
    };
    expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(true);
  });

  it('a 45° rotation around (150, 150) fits inside if the radius is small enough', () => {
    // 50×50 at (150, 150) rotated 45°.
    //   cos = sin ≈ 0.7071
    //   TL: (150, 150)
    //   TR: (150 + 35.4, 150 + 35.4) ≈ (185.4, 185.4)
    //   BL: (150 - 35.4, 150 + 35.4) ≈ (114.6, 185.4)
    //   BR: (150, 150 + 70.7) = (150, 220.7)
    // AABB: minX≈114.6, maxX≈185.4, minY=150, maxY≈220.7. All inside.
    const el = {
      type: 'image',
      x: 150,
      y: 150,
      width: 50,
      height: 50,
      rotation: 45,
    };
    expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(false);
  });

  it('rotation that pushes a corner outside the canvas flags as out', () => {
    // 100×100 at (250, 250) rotated 30°.
    //   cos ≈ 0.866, sin = 0.5
    //   BR: (250 + 100*c - 100*s, 250 + 100*s + 100*c)
    //        = (250 + 86.6 - 50, 250 + 50 + 86.6)
    //        = (286.6, 386.6)
    // maxY ≈ 386.6 > 300+0.5 → out.
    const el = {
      type: 'image',
      x: 250,
      y: 250,
      width: 100,
      height: 100,
      rotation: 30,
    };
    expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(true);
  });

  it('handles negative rotation angles symmetrically', () => {
    // Rotation -45° should produce the mirror of the 45°-around-(0,0)
    // case — the rect tips the other way. Specifically:
    //   100×100 at (100, 0) rotated -45°.
    //   cos ≈ 0.7071, sin ≈ -0.7071
    //   TL: (100, 0)
    //   TR: (100 + 70.7, -70.7) ≈ (170.7, -70.7)
    // minY ≈ -70.7 → past the top edge → out.
    const el = {
      type: 'image',
      x: 100,
      y: 0,
      width: 100,
      height: 100,
      rotation: -45,
    };
    expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(true);
  });
});

describe('isElementOutOfBounds — degenerate inputs', () => {
  it('returns false for null/undefined elements (no AABB)', () => {
    expect(isElementOutOfBounds(null, CANVAS_SIZE)).toBe(false);
    expect(isElementOutOfBounds(undefined, CANVAS_SIZE)).toBe(false);
  });

  it('returns false for unknown element types', () => {
    // Defensive: a future element type that doesn't have AABB
    // shouldn't false-flag as OOB. Returning null from
    // getElementAABB → the caller treats it as in-bounds.
    const el = { type: 'video', x: -100, y: -100 };
    expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(false);
  });

  it('returns false for zero-dimensioned images', () => {
    // An image element mid-load can briefly have width=0/height=0.
    // We want it treated as "no bbox available" rather than getting
    // false-flagged.
    const el = { type: 'image', x: -100, y: -100, width: 0, height: 0 };
    expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(false);
  });

  it('returns false for negative dimensioned images', () => {
    // Shouldn't happen in practice (the slider clamps to MIN_ELEMENT_SIZE)
    // but defensive — negative dims would produce a degenerate
    // AABB. Treating them as "no AABB" is the safe fallback.
    const el = { type: 'image', x: 0, y: 0, width: -100, height: 100 };
    expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(false);
  });
});

// ────────────────────────────────────────────────────────────────────
// isElementOutOfBounds — text elements (coarse).
// ────────────────────────────────────────────────────────────────────

describe('isElementOutOfBounds — text elements (coarse-grained)', () => {
  // Text AABB depends on font measurement which is canvas-dependent.
  // We don't pin pixel values — only the element-shape behavior:
  // a text element clearly inside is in, one clearly outside is out.

  it('returns false for empty text (null AABB falls through)', () => {
    // computeTextVisibleBbox returns null for empty/zero-width text.
    // The bounds check should treat null AABB as in-bounds.
    const el = { type: 'text', text: '', fontSize: 32, x: 100, y: 100 };
    expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(false);
  });

  it('returns true for text positioned way past the right edge', () => {
    // x=500 with any positive-width text → minX > canvas → out, no
    // matter what the font metrics return for the width.
    const el = {
      type: 'text',
      text: 'Hello',
      fontSize: 32,
      x: 500,
      y: 100,
    };
    expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(true);
  });

  it('returns true for text positioned at a deeply negative coord', () => {
    const el = {
      type: 'text',
      text: 'Hello',
      fontSize: 32,
      x: -500,
      y: 100,
    };
    expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(true);
  });
});

// ────────────────────────────────────────────────────────────────────
// countOutOfBounds — straightforward mapping.
// ────────────────────────────────────────────────────────────────────

describe('countOutOfBounds', () => {
  it('returns 0 for an empty array', () => {
    expect(countOutOfBounds([], CANVAS_SIZE)).toBe(0);
  });

  it('returns 0 for null/undefined', () => {
    // Defensive: callers may pass `elements` before it's been
    // initialised. countOutOfBounds drives the cart-disable boolean,
    // so a crash here would gate the entire purchase flow.
    expect(countOutOfBounds(null, CANVAS_SIZE)).toBe(0);
    expect(countOutOfBounds(undefined, CANVAS_SIZE)).toBe(0);
  });

  it('counts mixed in/out elements correctly', () => {
    const els = [
      { type: 'image', x: 0, y: 0, width: 100, height: 100 },     // in
      { type: 'image', x: -50, y: 0, width: 100, height: 100 },   // out (left)
      { type: 'image', x: 250, y: 250, width: 100, height: 100 }, // out (br)
      { type: 'image', x: 100, y: 100, width: 50, height: 50 },   // in
    ];
    expect(countOutOfBounds(els, CANVAS_SIZE)).toBe(2);
  });

  it('returns elements.length when all are out', () => {
    const els = [
      { type: 'image', x: -100, y: 0, width: 50, height: 50 },
      { type: 'image', x: -200, y: 0, width: 50, height: 50 },
    ];
    expect(countOutOfBounds(els, CANVAS_SIZE)).toBe(2);
  });

  it('does not count unknown element types as out (graceful skip)', () => {
    const els = [
      { type: 'image', x: 0, y: 0, width: 100, height: 100 },
      { type: 'mystery', x: -100, y: -100 },
    ];
    expect(countOutOfBounds(els, CANVAS_SIZE)).toBe(0);
  });
});

// ────────────────────────────────────────────────────────────────────
// fitElementToBounds — single-element correction.
// ────────────────────────────────────────────────────────────────────

describe('fitElementToBounds', () => {
  it('returns the same reference when the element is already inside', () => {
    // No-mutation contract. React downstream consumers memoize on
    // element identity; returning a fresh object when nothing
    // changed would invalidate those memos and force unnecessary
    // re-renders.
    const el = { type: 'image', x: 50, y: 50, width: 100, height: 100 };
    expect(fitElementToBounds(el, CANVAS_SIZE)).toBe(el);
  });

  it('translates the element back when it is positioned past an edge', () => {
    // Element fits dimensionally but is positioned past the left
    // edge. Strategy 2 (translate) applies, but no scale.
    const el = { type: 'image', x: -50, y: 100, width: 100, height: 100 };
    const fitted = fitElementToBounds(el, CANVAS_SIZE);
    expect(fitted).not.toBe(el);
    expect(fitted.width).toBe(100); // unchanged
    expect(fitted.height).toBe(100);
    expect(fitted.x).toBe(0);       // translated to 0
    expect(fitted.y).toBe(100);     // unchanged
  });

  it('translates back when an element extends past the right edge', () => {
    // x=250, width=100 → maxX=350. Shift left by 50 to land maxX=300.
    const el = { type: 'image', x: 250, y: 100, width: 100, height: 100 };
    const fitted = fitElementToBounds(el, CANVAS_SIZE);
    expect(fitted.x).toBe(200);
    expect(fitted.y).toBe(100);
  });

  it('scales an oversized element down to fit (with 5% margin)', () => {
    // 500×500 element on a 300×300 canvas. max usable = 285 (95% of 300).
    // scale = 285/500 = 0.57. New dims = round(500 * 0.57) = 285.
    const el = { type: 'image', x: 0, y: 0, width: 500, height: 500 };
    const fitted = fitElementToBounds(el, CANVAS_SIZE);
    expect(fitted.width).toBe(285);
    expect(fitted.height).toBe(285);
    // After scaling, the AABB still starts at (0, 0) and now ends at
    // (285, 285) — fits in the canvas, no further translation needed.
    expect(fitted.x).toBe(0);
    expect(fitted.y).toBe(0);
  });

  it('preserves aspect ratio when scaling down a non-square element', () => {
    // 600 wide × 300 tall. The 95%-of-canvas limit is 285 in both
    // dimensions. scaleW = 285/600 = 0.475. scaleH = 285/300 = 0.95.
    // Uniform scale picks the smaller: 0.475.
    // New: round(600 * 0.475) = 285, round(300 * 0.475) = 143.
    const el = { type: 'image', x: 0, y: 0, width: 600, height: 300 };
    const fitted = fitElementToBounds(el, CANVAS_SIZE);
    expect(fitted.width).toBe(285);
    expect(fitted.height).toBe(143);
    // Aspect ratio preserved within rounding tolerance.
    expect(fitted.width / fitted.height).toBeCloseTo(2.0, 1);
  });

  it('returns null/undefined unchanged when there is no AABB to fit', () => {
    // No AABB means we can't compute the correction. The function
    // should return the element as-is rather than throwing.
    const unknown = { type: 'mystery', x: -100, y: -100 };
    expect(fitElementToBounds(unknown, CANVAS_SIZE)).toBe(unknown);
  });

  it('scales text fontSize when the rendered text is too wide', () => {
    // We don't know the exact text width because of font measurement
    // variance, but we DO know: a fontSize=200 with a long string at
    // canvas size 300 will overflow → scale should reduce fontSize.
    // Just assert fontSize decreased.
    const el = {
      type: 'text',
      text: 'A very long string of text',
      fontSize: 200,
      x: 0,
      y: 100,
      fontFamily: 'Pacifico',
    };
    const fitted = fitElementToBounds(el, CANVAS_SIZE);
    if (fitted !== el) {
      // If a fit happened, fontSize should be smaller.
      expect(fitted.fontSize).toBeLessThan(200);
    }
    // Else the text measurement happened to fit — also acceptable
    // in jsdom where font metrics are approximate. The test passes
    // either way; the asymmetric assertion documents that scaling
    // CAN happen but is font-dependent.
  });
});

// ────────────────────────────────────────────────────────────────────
// fitElementsToBounds — array-level wrapper.
// ────────────────────────────────────────────────────────────────────

describe('fitElementsToBounds', () => {
  // We spy on console.warn for tests that check whether the warning
  // fired. Restore after each test so other suites' console output
  // is untouched.

  let warnSpy;
  beforeEach(() => {
    warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
  });
  afterEach(() => {
    warnSpy.mockRestore();
  });

  it('returns an empty array for empty/null/undefined input', () => {
    expect(fitElementsToBounds([], CANVAS_SIZE)).toEqual([]);
    expect(fitElementsToBounds(null, CANVAS_SIZE)).toEqual([]);
    expect(fitElementsToBounds(undefined, CANVAS_SIZE)).toEqual([]);
  });

  it('returns elements unchanged when all are already inside', () => {
    const els = [
      { type: 'image', x: 0, y: 0, width: 100, height: 100 },
      { type: 'image', x: 150, y: 150, width: 50, height: 50 },
    ];
    const out = fitElementsToBounds(els, CANVAS_SIZE);
    // Each individual element keeps reference identity.
    expect(out[0]).toBe(els[0]);
    expect(out[1]).toBe(els[1]);
    expect(warnSpy).not.toHaveBeenCalled();
  });

  it('returns a new array with corrections when any element was off', () => {
    const inElement = { type: 'image', x: 0, y: 0, width: 100, height: 100 };
    const offElement = { type: 'image', x: -50, y: 100, width: 100, height: 100 };
    const out = fitElementsToBounds([inElement, offElement], CANVAS_SIZE);
    expect(out[0]).toBe(inElement);          // unchanged → same ref
    expect(out[1]).not.toBe(offElement);      // corrected → new ref
    expect(out[1].x).toBe(0);
  });

  it('logs a console.warn when at least one element was fitted', () => {
    // The warning is intentionally noisy in development — it surfaces
    // template authoring bugs that should be fixed at the source
    // data level, not silently corrected at load time.
    const off = { type: 'image', x: -50, y: 0, width: 100, height: 100 };
    fitElementsToBounds([off], CANVAS_SIZE);
    expect(warnSpy).toHaveBeenCalledTimes(1);
    expect(warnSpy.mock.calls[0][0]).toMatch(/\[elementBounds\]/);
  });

  it('singularizes the warning text for a single-element correction', () => {
    // 1 element → "Auto-fit 1 element to canvas bounds" (no 's').
    // Multi-element → "Auto-fit N elements". The text matters less
    // than the count being right, but pinning the grammar helps the
    // log read naturally.
    const off = { type: 'image', x: -50, y: 0, width: 100, height: 100 };
    fitElementsToBounds([off], CANVAS_SIZE);
    expect(warnSpy.mock.calls[0][0]).toMatch(/1 element /); // trailing space catches the singular
  });

  it('pluralizes the warning text for multiple corrections', () => {
    const off1 = { type: 'image', x: -50, y: 0, width: 100, height: 100 };
    const off2 = { type: 'image', x: 0, y: -50, width: 100, height: 100 };
    fitElementsToBounds([off1, off2], CANVAS_SIZE);
    expect(warnSpy.mock.calls[0][0]).toMatch(/2 elements /);
  });
});
