// Tests for textGeometry.js — the bbox-preserving and initial-placement
// helpers for text elements.
//
// What this guards
// ────────────────
// These helpers keep typing into a text element from making the text
// drift across the canvas (`withTextCenterPreservation`), keep font
// swaps from producing wildly different rendered sizes
// (`fontSizeToPreserveWidth`), and place new text neatly centered on
// creation (`placeTextCentered`). Subtle bugs in any of them produce
// the kind of "ugh, why did it move" UX moment that's hard to bisect
// after the fact.
//
// Why we mock textMetrics
// ───────────────────────
// All three functions ultimately call `measureTextWidth`/`measureText
// Metrics`, which in production uses canvas measureText. In jsdom the
// canvas measurement is either approximate (canvas package, font-
// dependent) or geometric-fallback (no canvas, `fontSize * length * 0.55`
// for every font). Neither lets us assert exact behavior cleanly:
//
//   • Approximate: machine-dependent based on installed fonts.
//   • Fallback: all fonts measure identical → can't test font-swap math.
//
// Mocking lets us assert the EXACT math we care about. The mock
// returns `text.length * fontSize * familyFactor`, where familyFactor
// distinguishes wide / narrow / default fonts. This is a faithful
// model of how text width actually scales (it's linear in both length
// and fontSize, and font choice acts as a multiplicative factor), so
// the math being tested is the same math the production code runs.
//
// `vi` is a Vitest global (config has `globals: true`), so no import
// needed.

// ────────────────────────────────────────────────────────────────────
// Mock setup. Must be declared BEFORE the import of the module under
// test, otherwise Vitest will hoist the import and the real
// textMetrics will be loaded first.
//
// The family-factor model:
//   'WideFont'   → factor 0.80  (broad serif-ish)
//   'NarrowFont' → factor 0.30  (condensed display face)
//   anything else → factor 0.50 (a reasonable middle)
//
// So a 10-char string at fontSize 20 measures:
//   WideFont:   10 * 20 * 0.80 = 160
//   NarrowFont: 10 * 20 * 0.30 = 60
//   default:    10 * 20 * 0.50 = 100
// ────────────────────────────────────────────────────────────────────

vi.mock('./textMetrics', () => {
  const factorFor = (family) => {
    if (family === 'WideFont') return 0.80;
    if (family === 'NarrowFont') return 0.30;
    return 0.50;
  };
  const measureTextWidth = (text, fontSize, fontFamily) => {
    if (!text) return 0;
    return text.length * fontSize * factorFor(fontFamily);
  };
  const measureTextMetrics = (text, fontSize, fontFamily) => ({
    width: measureTextWidth(text, fontSize, fontFamily),
    ascent: fontSize * 0.7,
    descent: fontSize * 0.2,
    fontAscent: fontSize * 0.85,
    fontDescent: fontSize * 0.25,
  });
  return { measureTextWidth, measureTextMetrics };
});

import {
  withTextCenterPreservation,
  fontSizeToPreserveWidth,
  placeTextCentered,
} from './textGeometry';
import { measureTextWidth } from './textMetrics';
import {
  DEFAULT_DISPLAY_FONT,
  DEFAULT_FONT_SIZE,
  LINE_HEIGHT_RATIO,
  MIN_FONT_SIZE,
  MAX_FONT_SIZE,
} from '../constants/textMetrics';

// Helper: compute the visual center of a text element given its
// current state, using the SAME mocked measureTextWidth the function
// under test uses. Lets us verify the center-preservation invariant
// without recomputing the formula at the assertion site.
function visualCenter(element) {
  const w = measureTextWidth(
    element.text ?? '',
    element.fontSize ?? DEFAULT_FONT_SIZE,
    element.fontFamily ?? DEFAULT_DISPLAY_FONT
  );
  const h = (element.fontSize ?? DEFAULT_FONT_SIZE) * LINE_HEIGHT_RATIO;
  return {
    x: (element.x ?? 0) + w / 2,
    y: (element.y ?? 0) + h / 2,
  };
}

// ────────────────────────────────────────────────────────────────────
// withTextCenterPreservation — passes attrs through when nothing's
// changed, augments them with x/y when text/font/size changed.
// ────────────────────────────────────────────────────────────────────

describe('withTextCenterPreservation — passthrough cases', () => {
  it('returns attrs unchanged for non-text elements', () => {
    // Image elements have their own center-preservation logic in
    // ElementToolbar's handleSizeChange. This helper is text-only.
    const el = { type: 'image', x: 0, y: 0, width: 100, height: 100 };
    const attrs = { width: 150 };
    expect(withTextCenterPreservation(el, attrs)).toBe(attrs);
  });

  it('returns attrs unchanged when caller supplies x (caller wins)', () => {
    // The function's contract: an explicit x/y from the caller takes
    // precedence over the center-preservation calculation. Used when
    // the user drags a text element — DesignCanvas commits the new
    // position via updateAndCommit({ x, y, ... }), and we don't want
    // the center-preservation to fight the drag's commit.
    const el = { type: 'text', text: 'hello', fontSize: 32, x: 50, y: 50 };
    const attrs = { text: 'world', x: 100 };
    expect(withTextCenterPreservation(el, attrs)).toBe(attrs);
  });

  it('returns attrs unchanged when caller supplies y', () => {
    const el = { type: 'text', text: 'hello', fontSize: 32, x: 50, y: 50 };
    const attrs = { fontSize: 48, y: 100 };
    expect(withTextCenterPreservation(el, attrs)).toBe(attrs);
  });

  it('returns attrs unchanged when no bbox-affecting attrs are present', () => {
    // Changing fill/stroke/rotation doesn't affect bbox — no
    // center-preservation needed.
    const el = { type: 'text', text: 'hello', fontSize: 32, x: 50, y: 50 };
    const attrs = { fill: '#ff0000', rotation: 45 };
    expect(withTextCenterPreservation(el, attrs)).toBe(attrs);
  });
});

describe('withTextCenterPreservation — bbox-affecting changes', () => {
  // Each test asserts the CENTER-PRESERVATION INVARIANT: after the
  // function returns, the visual center computed from the new
  // element matches the visual center of the original. This is the
  // contract; specific x/y deltas are derived.

  it('preserves the center when text gets longer', () => {
    const el = { type: 'text', text: 'hi', fontSize: 20, x: 100, y: 100, fontFamily: 'default' };
    const before = visualCenter(el);
    const attrs = withTextCenterPreservation(el, { text: 'hello world' });
    const after = visualCenter({ ...el, ...attrs });
    expect(after.x).toBeCloseTo(before.x, 6);
    expect(after.y).toBeCloseTo(before.y, 6);
    // x should have decreased to move the element leftward as the
    // text grew rightward.
    expect(attrs.x).toBeLessThan(el.x);
  });

  it('preserves the center when text gets shorter', () => {
    const el = { type: 'text', text: 'hello world', fontSize: 20, x: 100, y: 100, fontFamily: 'default' };
    const before = visualCenter(el);
    const attrs = withTextCenterPreservation(el, { text: 'hi' });
    const after = visualCenter({ ...el, ...attrs });
    expect(after.x).toBeCloseTo(before.x, 6);
    expect(after.y).toBeCloseTo(before.y, 6);
    expect(attrs.x).toBeGreaterThan(el.x);
  });

  it('preserves the center when fontSize increases', () => {
    const el = { type: 'text', text: 'hello', fontSize: 20, x: 100, y: 100, fontFamily: 'default' };
    const before = visualCenter(el);
    const attrs = withTextCenterPreservation(el, { fontSize: 40 });
    const after = visualCenter({ ...el, ...attrs });
    expect(after.x).toBeCloseTo(before.x, 6);
    expect(after.y).toBeCloseTo(before.y, 6);
    // Both axes adjust — text grows in both width and height.
    expect(attrs.x).toBeLessThan(el.x);
    expect(attrs.y).toBeLessThan(el.y);
  });

  it('preserves the center when fontSize decreases', () => {
    const el = { type: 'text', text: 'hello', fontSize: 40, x: 100, y: 100, fontFamily: 'default' };
    const before = visualCenter(el);
    const attrs = withTextCenterPreservation(el, { fontSize: 20 });
    const after = visualCenter({ ...el, ...attrs });
    expect(after.x).toBeCloseTo(before.x, 6);
    expect(after.y).toBeCloseTo(before.y, 6);
  });

  it('preserves the center when switching to a wider font', () => {
    const el = { type: 'text', text: 'hello', fontSize: 20, x: 100, y: 100, fontFamily: 'NarrowFont' };
    const before = visualCenter(el);
    const attrs = withTextCenterPreservation(el, { fontFamily: 'WideFont' });
    const after = visualCenter({ ...el, ...attrs });
    expect(after.x).toBeCloseTo(before.x, 6);
    expect(after.y).toBeCloseTo(before.y, 6);
    // Wider font → element shifts leftward.
    expect(attrs.x).toBeLessThan(el.x);
  });

  it('preserves the center when switching to a narrower font', () => {
    const el = { type: 'text', text: 'hello', fontSize: 20, x: 100, y: 100, fontFamily: 'WideFont' };
    const before = visualCenter(el);
    const attrs = withTextCenterPreservation(el, { fontFamily: 'NarrowFont' });
    const after = visualCenter({ ...el, ...attrs });
    expect(after.x).toBeCloseTo(before.x, 6);
    expect(after.y).toBeCloseTo(before.y, 6);
    expect(attrs.x).toBeGreaterThan(el.x);
  });

  it('preserves the center when text AND fontSize AND fontFamily all change', () => {
    // Stacked bbox changes — the math should compose. Specifically
    // the function reads the new text + new size + new family in one
    // pass, computes the new width once, and centers around the
    // original center. Verifying with a multi-axis change pins that
    // composition.
    const el = { type: 'text', text: 'hi', fontSize: 20, x: 100, y: 100, fontFamily: 'NarrowFont' };
    const before = visualCenter(el);
    const attrs = withTextCenterPreservation(el, {
      text: 'hello world',
      fontSize: 40,
      fontFamily: 'WideFont',
    });
    const after = visualCenter({ ...el, ...attrs });
    expect(after.x).toBeCloseTo(before.x, 6);
    expect(after.y).toBeCloseTo(before.y, 6);
  });

  it('uses defaults when element fields are missing', () => {
    // Edge case: element has no x/y/text/fontSize/fontFamily. The
    // function should treat missing fields as the documented
    // defaults rather than throwing.
    const el = { type: 'text' };
    const attrs = withTextCenterPreservation(el, { text: 'hello' });
    expect(attrs.x).toBeDefined();
    expect(attrs.y).toBeDefined();
    expect(Number.isFinite(attrs.x)).toBe(true);
    expect(Number.isFinite(attrs.y)).toBe(true);
  });
});

// ────────────────────────────────────────────────────────────────────
// fontSizeToPreserveWidth — bidirectional clamped width-preserving
// fontSize calculator.
// ────────────────────────────────────────────────────────────────────

describe('fontSizeToPreserveWidth', () => {
  it('returns oldFontSize unchanged when text is empty', () => {
    // No text → no width to preserve → no-op. The defensive bail
    // also prevents the division-by-zero that an empty-string
    // measurement would produce.
    expect(fontSizeToPreserveWidth('', 'WideFont', 32, 'NarrowFont')).toBe(32);
  });

  it('returns oldFontSize unchanged when fonts are the same width', () => {
    // Two different family names with the same factor in our mock
    // would also return identical widths; the "same font" case is a
    // subset of this where ratio = 1 cleanly.
    expect(fontSizeToPreserveWidth('hello', 'default', 32, 'default')).toBe(32);
  });

  it('shrinks fontSize when swapping to a wider font', () => {
    // 'hello' at fontSize 32 in NarrowFont (factor 0.3) → width 48.
    // The SAME width at fontSize 32 in WideFont (factor 0.8) → 128.
    // ratio = 48 / 128 = 0.375.
    // targetSize = 32 * 0.375 = 12, clamped to MIN_FONT_SIZE.
    const result = fontSizeToPreserveWidth('hello', 'NarrowFont', 32, 'WideFont');
    expect(result).toBeLessThan(32);
  });

  it('grows fontSize when swapping to a narrower font', () => {
    // 'hello' at 32 in WideFont → 128 width.
    // SAME width at 32 in NarrowFont → 48.
    // ratio = 128 / 48 ≈ 2.667.
    // targetSize = 32 * 2.667 ≈ 85.3 → 85 after round.
    const result = fontSizeToPreserveWidth('hello', 'WideFont', 32, 'NarrowFont');
    expect(result).toBeGreaterThan(32);
    expect(result).toBeLessThanOrEqual(MAX_FONT_SIZE);
  });

  it('clamps result to MAX_FONT_SIZE when ratio would exceed it', () => {
    // Push the math past MAX_FONT_SIZE: start at a large fontSize in
    // WideFont, swap to NarrowFont. The unclamped target would be
    // 120 * (128/48) ≈ 320 — well past the 200 cap.
    const result = fontSizeToPreserveWidth('hello', 'WideFont', 120, 'NarrowFont');
    expect(result).toBe(MAX_FONT_SIZE);
  });

  it('clamps result to MIN_FONT_SIZE when ratio would fall below it', () => {
    // Tiny fontSize in NarrowFont swapping to WideFont. Mock factors
    // mean swap shrinks by ~2.67x. From fontSize 20 the unclamped
    // target is 7.5 — below MIN_FONT_SIZE (12).
    const result = fontSizeToPreserveWidth('hello', 'NarrowFont', 20, 'WideFont');
    expect(result).toBe(MIN_FONT_SIZE);
  });

  it('rounds to integer fontSize', () => {
    // The function calls Math.round on the target. CSS happily
    // accepts fractional font sizes but a non-integer in storage
    // makes JSON snapshots awkward and produces noisy diffs.
    const result = fontSizeToPreserveWidth('hello', 'NarrowFont', 32, 'WideFont');
    expect(Number.isInteger(result)).toBe(true);
  });

  it('is approximately symmetric: shrink-then-grow returns near original', () => {
    // Numerical sanity check. Two opposite swaps should land close to
    // the starting fontSize, modulo rounding.
    const start = 48;
    const shrunk = fontSizeToPreserveWidth('hello', 'NarrowFont', start, 'WideFont');
    const grown = fontSizeToPreserveWidth('hello', 'WideFont', shrunk, 'NarrowFont');
    // Within 1 of original (Math.round at each step can accumulate
    // up to half a unit per step → 1 max).
    expect(Math.abs(grown - start)).toBeLessThanOrEqual(1);
  });
});

// ────────────────────────────────────────────────────────────────────
// placeTextCentered — initial placement for new text elements.
// ────────────────────────────────────────────────────────────────────

describe('placeTextCentered', () => {
  const CANVAS = 300;

  it('centers the text within the canvas', () => {
    // 'hi' at fontSize 20 in default font → width = 2 * 20 * 0.5 = 20.
    // x = round((300 - 20) / 2) = 140.
    // h = 20 * LINE_HEIGHT_RATIO (default 1.2 in textGeometry).
    // y = round((300 - h) / 2).
    const result = placeTextCentered(
      { text: 'hi', fontSize: 20, fontFamily: 'default' },
      CANVAS
    );
    expect(result.x).toBe(140);
    // y is centered on the line-height box, not asserted exactly
    // because LINE_HEIGHT_RATIO is in constants/ — but it should be
    // a reasonable positive number.
    expect(result.y).toBeGreaterThan(0);
    expect(result.y).toBeLessThan(CANVAS);
    expect(result.fontSize).toBe(20);
  });

  it('auto-shrinks fontSize when text would overflow the margin', () => {
    // 'hello world' (11 chars) at fontSize 100 in WideFont (factor 0.8)
    // → width = 880. Margin = 0.9 * 300 = 270.
    // ratio = 270/880 ≈ 0.307.
    // new fontSize = floor(100 * 0.307) = 30.
    const result = placeTextCentered(
      { text: 'hello world', fontSize: 100, fontFamily: 'WideFont' },
      CANVAS
    );
    expect(result.fontSize).toBeLessThan(100);
    expect(result.fontSize).toBeGreaterThanOrEqual(MIN_FONT_SIZE);
  });

  it('respects a custom margin ratio', () => {
    // Tighter margin = less room = more shrink. Verify that a smaller
    // ratio produces smaller fontSize (or smaller x offset for
    // already-fitting text).
    const tight = placeTextCentered(
      { text: 'hello world', fontSize: 100, fontFamily: 'WideFont' },
      CANVAS,
      0.5
    );
    const loose = placeTextCentered(
      { text: 'hello world', fontSize: 100, fontFamily: 'WideFont' },
      CANVAS,
      0.9
    );
    expect(tight.fontSize).toBeLessThanOrEqual(loose.fontSize);
  });

  it('uses defaults for missing fields', () => {
    // Empty draft should still produce a sensible placement using the
    // default font and DEFAULT_FONT_SIZE.
    const result = placeTextCentered({}, CANVAS);
    expect(result.fontSize).toBe(DEFAULT_FONT_SIZE);
    expect(Number.isFinite(result.x)).toBe(true);
    expect(Number.isFinite(result.y)).toBe(true);
  });

  it('returns integer x and y (no fractional placement)', () => {
    // Stored as integers via Math.round in the function. Avoids
    // half-pixel placements in stored data.
    const result = placeTextCentered(
      { text: 'hello', fontSize: 20, fontFamily: 'default' },
      CANVAS
    );
    expect(Number.isInteger(result.x)).toBe(true);
    expect(Number.isInteger(result.y)).toBe(true);
  });

  it('clamps x and y to non-negative values', () => {
    // Pathological case: text wider than canvas even after
    // auto-shrink. The shrink should usually make this impossible,
    // but defensively Math.max(0, ...) protects against negative
    // x/y in stored state.
    const result = placeTextCentered(
      { text: 'a'.repeat(100), fontSize: 80, fontFamily: 'WideFont' },
      CANVAS
    );
    expect(result.x).toBeGreaterThanOrEqual(0);
    expect(result.y).toBeGreaterThanOrEqual(0);
  });

  it('shrinks fontSize but not below MIN_FONT_SIZE', () => {
    // Massively-overflowing text at a tiny canvas. Even after the
    // shrink ratio, the fontSize floor is MIN_FONT_SIZE.
    const result = placeTextCentered(
      { text: 'a'.repeat(500), fontSize: 200, fontFamily: 'WideFont' },
      CANVAS
    );
    expect(result.fontSize).toBeGreaterThanOrEqual(MIN_FONT_SIZE);
  });
});
