/**
 * Unit tests for Web Editor V2 Snap Engine
 *
 * Tests cover:
 * - mergeAnchors: Anchor collection merging
 * - computeResizeSnap: Snap computation during resize
 * - computeDistanceLabels: Distance label generation
 *
 * All functions tested here are pure functions with no DOM dependencies,
 * making them ideal for unit testing.
 */

import { describe, expect, it } from 'vitest';

import {
  computeDistanceLabels,
  computeResizeSnap,
  mergeAnchors,
  type ComputeDistanceLabelsParams,
  type ComputeResizeSnapParams,
  type SnapAnchors,
  type SnapLockX,
  type SnapLockY,
} from '@/entrypoints/web-editor/core/snap-engine';
import type { ViewportRect } from '@/entrypoints/web-editor/overlay/canvas-overlay';

// =============================================================================
// Test Utilities
// =============================================================================

/**
 * Creates a ViewportRect from coordinates and dimensions.
 */
function rect(left: number, top: number, width: number, height: number): ViewportRect {
  return { left, top, width, height };
}

/**
 * Default viewport dimensions for tests.
 */
const VIEWPORT = { width: 800, height: 600 };

/**
 * Creates default params for computeResizeSnap tests.
 */
function createSnapParams(overrides: Partial<ComputeResizeSnapParams>): ComputeResizeSnapParams {
  return {
    rect: rect(100, 100, 200, 150),
    resize: { hasWest: false, hasEast: false, hasNorth: false, hasSouth: false },
    anchors: { x: [], y: [] },
    thresholdPx: 6,
    hysteresisPx: 2,
    minSizePx: 10,
    lockX: null,
    lockY: null,
    viewport: VIEWPORT,
    ...overrides,
  };
}

/**
 * Creates default params for computeDistanceLabels tests.
 */
function createLabelParams(
  overrides: Partial<ComputeDistanceLabelsParams>,
): ComputeDistanceLabelsParams {
  return {
    rect: rect(100, 100, 200, 150),
    lockX: null,
    lockY: null,
    viewport: VIEWPORT,
    minGapPx: 1,
    ...overrides,
  };
}

// =============================================================================
// mergeAnchors Tests
// =============================================================================

describe('snap-engine: mergeAnchors', () => {
  it('returns empty anchors when called with no arguments', () => {
    const result = mergeAnchors();
    expect(result).toEqual({ x: [], y: [] });
  });

  it('returns the same anchors when called with single collection', () => {
    const anchors: SnapAnchors = {
      x: [{ type: 'left', value: 0, source: 'viewport' }],
      y: [{ type: 'top', value: 0, source: 'viewport' }],
    };

    const result = mergeAnchors(anchors);

    expect(result.x).toHaveLength(1);
    expect(result.y).toHaveLength(1);
  });

  it('concatenates anchors from multiple collections in order', () => {
    const collection1: SnapAnchors = {
      x: [{ type: 'left', value: 0, source: 'viewport' }],
      y: [{ type: 'top', value: 0, source: 'viewport' }],
    };

    const collection2: SnapAnchors = {
      x: [{ type: 'center', value: 50, source: 'sibling', sourceRect: rect(40, 0, 20, 20) }],
      y: [],
    };

    const collection3: SnapAnchors = {
      x: [{ type: 'right', value: 100, source: 'sibling', sourceRect: rect(80, 0, 20, 20) }],
      y: [{ type: 'bottom', value: 100, source: 'viewport' }],
    };

    const result = mergeAnchors(collection1, collection2, collection3);

    expect(result.x).toHaveLength(3);
    expect(result.y).toHaveLength(2);

    // Verify order is preserved
    expect(result.x[0]).toMatchObject({ type: 'left', value: 0 });
    expect(result.x[1]).toMatchObject({ type: 'center', value: 50 });
    expect(result.x[2]).toMatchObject({ type: 'right', value: 100 });
  });

  it('handles empty collections gracefully', () => {
    const empty: SnapAnchors = { x: [], y: [] };
    const nonEmpty: SnapAnchors = {
      x: [{ type: 'left', value: 10, source: 'viewport' }],
      y: [],
    };

    const result = mergeAnchors(empty, nonEmpty, empty);

    expect(result.x).toHaveLength(1);
    expect(result.y).toHaveLength(0);
  });
});

// =============================================================================
// computeResizeSnap Tests
// =============================================================================

describe('snap-engine: computeResizeSnap', () => {
  describe('basic snapping', () => {
    it('snaps west edge within threshold and emits vertical guide line', () => {
      const params = createSnapParams({
        rect: rect(103, 100, 197, 150), // left edge at 103
        resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false },
        anchors: {
          x: [{ type: 'left', value: 100, source: 'viewport' }], // anchor at 100
          y: [],
        },
      });

      const result = computeResizeSnap(params);

      // Should snap left edge from 103 to 100 (distance 3 < threshold 6)
      expect(result.snappedRect.left).toBe(100);
      expect(result.snappedRect.width).toBe(200); // width adjusted
      expect(result.lockX).toMatchObject({ type: 'left', value: 100, source: 'viewport' });
      expect(result.lockY).toBeNull();
      expect(result.guideLines).toHaveLength(1);
      expect(result.guideLines[0]).toEqual({ x1: 100, y1: 0, x2: 100, y2: VIEWPORT.height });
    });

    it('snaps east edge within threshold', () => {
      const params = createSnapParams({
        rect: rect(100, 100, 197, 150), // right edge at 297
        resize: { hasWest: false, hasEast: true, hasNorth: false, hasSouth: false },
        anchors: {
          x: [{ type: 'right', value: 300, source: 'viewport' }], // anchor at 300
          y: [],
        },
      });

      const result = computeResizeSnap(params);

      // Should snap right edge from 297 to 300
      expect(result.snappedRect.left).toBe(100); // left unchanged
      expect(result.snappedRect.width).toBe(200);
      expect(result.lockX).toMatchObject({ type: 'right', value: 300 });
    });

    it('snaps north edge within threshold and emits horizontal guide line', () => {
      const params = createSnapParams({
        rect: rect(100, 104, 200, 146), // top edge at 104
        resize: { hasWest: false, hasEast: false, hasNorth: true, hasSouth: false },
        anchors: {
          x: [],
          y: [{ type: 'top', value: 100, source: 'viewport' }],
        },
      });

      const result = computeResizeSnap(params);

      expect(result.snappedRect.top).toBe(100);
      expect(result.snappedRect.height).toBe(150);
      expect(result.lockY).toMatchObject({ type: 'top', value: 100 });
      expect(result.guideLines).toHaveLength(1);
      expect(result.guideLines[0]).toEqual({ x1: 0, y1: 100, x2: VIEWPORT.width, y2: 100 });
    });

    it('snaps south edge within threshold', () => {
      const params = createSnapParams({
        rect: rect(100, 100, 200, 147), // bottom edge at 247
        resize: { hasWest: false, hasEast: false, hasNorth: false, hasSouth: true },
        anchors: {
          x: [],
          y: [{ type: 'bottom', value: 250, source: 'viewport' }],
        },
      });

      const result = computeResizeSnap(params);

      expect(result.snappedRect.top).toBe(100);
      expect(result.snappedRect.height).toBe(150);
      expect(result.lockY).toMatchObject({ type: 'bottom', value: 250 });
    });
  });

  describe('threshold behavior', () => {
    it('does not snap when distance exceeds threshold', () => {
      const params = createSnapParams({
        rect: rect(100, 100, 200, 150),
        resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false },
        anchors: {
          x: [{ type: 'left', value: 90, source: 'viewport' }], // distance 10 > threshold 6
          y: [],
        },
      });

      const result = computeResizeSnap(params);

      expect(result.snappedRect).toEqual(params.rect);
      expect(result.lockX).toBeNull();
      expect(result.guideLines).toEqual([]);
    });

    it('snaps at exactly the threshold distance', () => {
      const params = createSnapParams({
        rect: rect(106, 100, 194, 150),
        resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false },
        anchors: {
          x: [{ type: 'left', value: 100, source: 'viewport' }], // distance exactly 6
          y: [],
        },
      });

      const result = computeResizeSnap(params);

      expect(result.snappedRect.left).toBe(100);
      expect(result.lockX).not.toBeNull();
    });
  });

  describe('anchor priority', () => {
    it('prefers sibling anchors over viewport anchors at equal distance', () => {
      // Both anchors at same value (100), same type (left), same distance from rect.left (103)
      // When hasWest: true, we're moving the left edge, so 'left' type anchors are allowed
      const siblingRect = rect(50, 0, 50, 50);
      const params = createSnapParams({
        rect: rect(103, 100, 197, 150), // left edge at 103
        resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false },
        anchors: {
          x: [
            { type: 'left', value: 100, source: 'viewport' }, // distance 3, left type
            { type: 'left', value: 100, source: 'sibling', sourceRect: siblingRect }, // distance 3, left type
          ],
          y: [],
        },
      });

      const result = computeResizeSnap(params);

      // Sibling should be preferred over viewport at equal distance
      expect(result.lockX?.source).toBe('sibling');
      expect(result.snappedRect.left).toBe(100);
    });

    it('chooses closest anchor regardless of source when distances differ', () => {
      // Both anchors have 'left' type (allowed for hasWest resize), but different distances
      const siblingRect = rect(50, 0, 50, 50);
      const params = createSnapParams({
        rect: rect(103, 100, 197, 150), // left edge at 103
        resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false },
        anchors: {
          x: [
            { type: 'left', value: 102, source: 'viewport' }, // distance 1, left type
            { type: 'left', value: 100, source: 'sibling', sourceRect: siblingRect }, // distance 3, left type
          ],
          y: [],
        },
      });

      const result = computeResizeSnap(params);

      // Closer anchor (viewport at 102) should be chosen despite sibling having priority at equal distance
      expect(result.lockX?.source).toBe('viewport');
      expect(result.snappedRect.left).toBe(102);
    });
  });

  describe('hysteresis (lock stability)', () => {
    it('maintains existing lock within threshold + hysteresis', () => {
      const lockX: SnapLockX = {
        type: 'left',
        value: 100,
        source: 'viewport',
        sourceRect: null,
      };

      const params = createSnapParams({
        rect: rect(107, 100, 193, 150), // distance 7 from lock (threshold 6 + hysteresis 2 = 8)
        resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false },
        anchors: { x: [], y: [] },
        lockX,
      });

      const result = computeResizeSnap(params);

      expect(result.lockX).toMatchObject({ type: 'left', value: 100 });
      expect(result.snappedRect.left).toBe(100); // still snapped
    });

    it('releases lock when distance exceeds threshold + hysteresis', () => {
      const lockX: SnapLockX = {
        type: 'left',
        value: 100,
        source: 'viewport',
        sourceRect: null,
      };

      const params = createSnapParams({
        rect: rect(109, 100, 191, 150), // distance 9 > threshold 6 + hysteresis 2
        resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false },
        anchors: { x: [], y: [] },
        lockX,
      });

      const result = computeResizeSnap(params);

      expect(result.lockX).toBeNull();
      expect(result.snappedRect.left).toBe(109); // no snap
    });
  });

  describe('minimum size constraint', () => {
    it('rejects snap that would violate minimum width', () => {
      const params = createSnapParams({
        rect: rect(100, 100, 20, 150),
        resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false },
        anchors: {
          x: [{ type: 'left', value: 115, source: 'viewport' }], // would make width = 5
          y: [],
        },
        minSizePx: 10,
        thresholdPx: 20, // large threshold to ensure snap would trigger
      });

      const result = computeResizeSnap(params);

      expect(result.lockX).toBeNull();
      expect(result.snappedRect).toEqual(params.rect);
    });

    it('rejects snap that would violate minimum height', () => {
      const params = createSnapParams({
        rect: rect(100, 100, 200, 15),
        resize: { hasWest: false, hasEast: false, hasNorth: true, hasSouth: false },
        anchors: {
          x: [],
          y: [{ type: 'top', value: 110, source: 'viewport' }], // would make height = 5
        },
        minSizePx: 10,
        thresholdPx: 20,
      });

      const result = computeResizeSnap(params);

      expect(result.lockY).toBeNull();
    });
  });

  describe('invalid rect handling', () => {
    it('returns unchanged rect and clears locks for zero-width rect', () => {
      const lockX: SnapLockX = { type: 'left', value: 0, source: 'viewport', sourceRect: null };
      const params = createSnapParams({
        rect: rect(0, 0, 0, 100),
        lockX,
        lockY: null,
      });

      const result = computeResizeSnap(params);

      expect(result.snappedRect).toEqual(params.rect);
      expect(result.lockX).toBeNull();
      expect(result.lockY).toBeNull();
      expect(result.guideLines).toEqual([]);
    });

    it('returns unchanged rect for zero-height rect', () => {
      const params = createSnapParams({
        rect: rect(0, 0, 100, 0),
      });

      const result = computeResizeSnap(params);

      expect(result.snappedRect).toEqual(params.rect);
    });
  });

  describe('multi-direction resize', () => {
    it('snaps both X and Y axes simultaneously', () => {
      const params = createSnapParams({
        rect: rect(103, 97, 197, 153),
        resize: { hasWest: true, hasEast: false, hasNorth: true, hasSouth: false },
        anchors: {
          x: [{ type: 'left', value: 100, source: 'viewport' }],
          y: [{ type: 'top', value: 100, source: 'viewport' }],
        },
      });

      const result = computeResizeSnap(params);

      expect(result.snappedRect.left).toBe(100);
      expect(result.snappedRect.top).toBe(100);
      expect(result.lockX).not.toBeNull();
      expect(result.lockY).not.toBeNull();
      expect(result.guideLines).toHaveLength(2);
    });
  });

  describe('center/middle anchor snapping', () => {
    it('snaps to center anchor when resizing from west (left is fixed)', () => {
      // When hasEast: true, fixedEdgeX = 'left', allowedTypes = ['right', 'center']
      const params = createSnapParams({
        rect: rect(100, 100, 198, 150), // center at 199, right at 298
        resize: { hasWest: false, hasEast: true, hasNorth: false, hasSouth: false },
        anchors: {
          x: [{ type: 'center', value: 200, source: 'viewport' }], // distance 1
          y: [],
        },
      });

      const result = computeResizeSnap(params);

      // Center snapped to 200, so width = (200 - 100) * 2 = 200
      expect(result.snappedRect.left).toBe(100); // left unchanged
      expect(result.snappedRect.width).toBe(200);
      expect(result.lockX).toMatchObject({ type: 'center', value: 200 });
    });

    it('snaps to middle anchor when resizing from south', () => {
      // When hasSouth: true, fixedEdgeY = 'top', allowedTypes = ['bottom', 'middle']
      const params = createSnapParams({
        rect: rect(100, 100, 200, 148), // middle at 174, bottom at 248
        resize: { hasWest: false, hasEast: false, hasNorth: false, hasSouth: true },
        anchors: {
          x: [],
          y: [{ type: 'middle', value: 175, source: 'viewport' }], // distance 1
        },
      });

      const result = computeResizeSnap(params);

      // Middle snapped to 175, so height = (175 - 100) * 2 = 150
      expect(result.snappedRect.top).toBe(100);
      expect(result.snappedRect.height).toBe(150);
      expect(result.lockY).toMatchObject({ type: 'middle', value: 175 });
    });
  });

  describe('lock invalidation', () => {
    it('clears lock when its type is not allowed for current resize direction', () => {
      // Lock was on 'right' but now we're resizing from east (which allows right/center)
      // When hasWest: true, only 'left' and 'center' are allowed
      const lockX: SnapLockX = {
        type: 'right',
        value: 300,
        source: 'viewport',
        sourceRect: null,
      };

      const params = createSnapParams({
        rect: rect(100, 100, 200, 150),
        resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false },
        anchors: { x: [], y: [] },
        lockX,
      });

      const result = computeResizeSnap(params);

      // Lock should be cleared because 'right' is not in allowed types for west resize
      expect(result.lockX).toBeNull();
    });

    it('clears lock when axis is not being resized', () => {
      // X-axis lock but no X resize is happening
      const lockX: SnapLockX = {
        type: 'left',
        value: 100,
        source: 'viewport',
        sourceRect: null,
      };

      const params = createSnapParams({
        rect: rect(100, 100, 200, 150),
        resize: { hasWest: false, hasEast: false, hasNorth: true, hasSouth: false }, // only Y resize
        anchors: { x: [], y: [] },
        lockX,
      });

      const result = computeResizeSnap(params);

      expect(result.lockX).toBeNull();
    });
  });

  describe('sibling guide line extent', () => {
    it('generates guide line spanning from source to target element for sibling snap', () => {
      const siblingRect = rect(50, 20, 50, 60); // right edge at 100, bottom at 80
      const params = createSnapParams({
        rect: rect(103, 100, 197, 150), // top at 100, bottom at 250
        resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false },
        anchors: {
          x: [{ type: 'left', value: 100, source: 'sibling', sourceRect: siblingRect }],
          y: [],
        },
      });

      const result = computeResizeSnap(params);

      expect(result.guideLines).toHaveLength(1);
      // Guide line should span from sibling's vertical extent to target's vertical extent
      // min(sibling.top, target.top) to max(sibling.bottom, target.bottom)
      expect(result.guideLines[0]).toEqual({
        x1: 100,
        y1: Math.min(siblingRect.top, 100), // 20
        x2: 100,
        y2: Math.max(80, 250), // 250
      });
    });
  });
});

// =============================================================================
// computeDistanceLabels Tests
// =============================================================================

describe('snap-engine: computeDistanceLabels', () => {
  describe('sibling gap labels', () => {
    it('computes vertical gap from X-axis sibling lock', () => {
      const sourceRect = rect(100, 30, 50, 50); // bottom at 80
      const lockX: SnapLockX = {
        type: 'left',
        value: 100,
        source: 'sibling',
        sourceRect,
      };

      const params = createLabelParams({
        rect: rect(100, 100, 200, 150), // top at 100, gap = 20
        lockX,
      });

      const labels = computeDistanceLabels(params);

      expect(labels).toHaveLength(1);
      expect(labels[0]).toMatchObject({
        kind: 'sibling',
        axis: 'y',
        value: 20,
        text: '20px',
      });
      expect(labels[0]?.line).toEqual({ x1: 100, y1: 80, x2: 100, y2: 100 });
    });

    it('computes horizontal gap from Y-axis sibling lock', () => {
      const sourceRect = rect(30, 100, 50, 50); // right at 80
      const lockY: SnapLockY = {
        type: 'top',
        value: 100,
        source: 'sibling',
        sourceRect,
      };

      const params = createLabelParams({
        rect: rect(100, 100, 200, 150), // left at 100, gap = 20
        lockY,
      });

      const labels = computeDistanceLabels(params);

      expect(labels).toHaveLength(1);
      expect(labels[0]).toMatchObject({
        kind: 'sibling',
        axis: 'x',
        value: 20,
        text: '20px',
      });
      expect(labels[0]?.line).toEqual({ x1: 80, y1: 100, x2: 100, y2: 100 });
    });

    it('hides labels for gaps below minGapPx', () => {
      const sourceRect = rect(100, 99.5, 50, 0.3); // bottom at 99.8
      const lockX: SnapLockX = {
        type: 'left',
        value: 100,
        source: 'sibling',
        sourceRect,
      };

      const params = createLabelParams({
        rect: rect(100, 100, 200, 150), // gap = 0.2
        lockX,
        minGapPx: 1,
      });

      const labels = computeDistanceLabels(params);

      expect(labels).toEqual([]);
    });

    it('hides labels for zero gap (touching elements)', () => {
      const sourceRect = rect(100, 50, 50, 50); // bottom at 100
      const lockX: SnapLockX = {
        type: 'left',
        value: 100,
        source: 'sibling',
        sourceRect,
      };

      const params = createLabelParams({
        rect: rect(100, 100, 200, 150), // top at 100, gap = 0
        lockX,
      });

      const labels = computeDistanceLabels(params);

      expect(labels).toEqual([]);
    });

    it('computes vertical gap when target is above source (reverse direction)', () => {
      const sourceRect = rect(100, 150, 50, 50); // top at 150
      const lockX: SnapLockX = {
        type: 'left',
        value: 100,
        source: 'sibling',
        sourceRect,
      };

      const params = createLabelParams({
        rect: rect(100, 50, 200, 80), // bottom at 130, gap = 20
        lockX,
      });

      const labels = computeDistanceLabels(params);

      expect(labels).toHaveLength(1);
      expect(labels[0]).toMatchObject({
        kind: 'sibling',
        axis: 'y',
        value: 20,
        text: '20px',
      });
    });

    it('computes horizontal gap when target is left of source (reverse direction)', () => {
      const sourceRect = rect(250, 100, 50, 50); // left at 250
      const lockY: SnapLockY = {
        type: 'top',
        value: 100,
        source: 'sibling',
        sourceRect,
      };

      const params = createLabelParams({
        rect: rect(100, 100, 100, 150), // right at 200, gap = 50
        lockY,
      });

      const labels = computeDistanceLabels(params);

      expect(labels).toHaveLength(1);
      expect(labels[0]).toMatchObject({
        kind: 'sibling',
        axis: 'x',
        value: 50,
        text: '50px',
      });
    });

    it('hides labels for overlapping elements (negative gap)', () => {
      const sourceRect = rect(100, 80, 50, 50); // bottom at 130
      const lockX: SnapLockX = {
        type: 'left',
        value: 100,
        source: 'sibling',
        sourceRect,
      };

      const params = createLabelParams({
        rect: rect(100, 100, 200, 150), // top at 100, overlaps with source
        lockX,
      });

      const labels = computeDistanceLabels(params);

      // Negative gap (overlap) should not produce labels
      expect(labels).toEqual([]);
    });
  });

  describe('viewport margin labels', () => {
    it('shows viewport margin for X-axis viewport lock (left align)', () => {
      const lockX: SnapLockX = {
        type: 'left',
        value: 50,
        source: 'viewport',
        sourceRect: null,
      };

      const params = createLabelParams({
        rect: rect(50, 100, 200, 150), // left=50, right=250, center at y=175
        lockX,
        viewport: { width: 800, height: 600 },
      });

      const labels = computeDistanceLabels(params);

      expect(labels.length).toBeGreaterThanOrEqual(1);
      const viewportLabel = labels.find((l) => l.kind === 'viewport');
      expect(viewportLabel).toBeDefined();
      expect(viewportLabel).toMatchObject({
        kind: 'viewport',
        axis: 'x',
        value: 50, // left margin
        text: '50px',
      });
      // Line should be horizontal from left edge of viewport to left edge of rect
      expect(viewportLabel?.line).toEqual({ x1: 0, y1: 175, x2: 50, y2: 175 });
    });

    it('shows opposite margin when aligned margin is 0', () => {
      const lockX: SnapLockX = {
        type: 'left',
        value: 0,
        source: 'viewport',
        sourceRect: null,
      };

      const params = createLabelParams({
        rect: rect(0, 100, 200, 150), // left margin = 0, right margin = 600
        lockX,
        viewport: { width: 800, height: 600 },
      });

      const labels = computeDistanceLabels(params);

      const viewportLabel = labels.find((l) => l.kind === 'viewport');
      expect(viewportLabel).toBeDefined();
      // Should show the right margin since left is 0
      expect(viewportLabel?.value).toBe(600);
    });

    it('shows viewport margin for Y-axis viewport lock (top align)', () => {
      const lockY: SnapLockY = {
        type: 'top',
        value: 50,
        source: 'viewport',
        sourceRect: null,
      };

      const params = createLabelParams({
        rect: rect(100, 50, 200, 150), // top=50, bottom=200, center at x=200
        lockY,
        viewport: { width: 800, height: 600 },
      });

      const labels = computeDistanceLabels(params);

      expect(labels.length).toBeGreaterThanOrEqual(1);
      const viewportLabel = labels.find((l) => l.kind === 'viewport');
      expect(viewportLabel).toBeDefined();
      expect(viewportLabel).toMatchObject({
        kind: 'viewport',
        axis: 'y',
        value: 50, // top margin
        text: '50px',
      });
    });

    it('shows both margins for center lock (X-axis)', () => {
      const lockX: SnapLockX = {
        type: 'center',
        value: 400, // viewport center
        source: 'viewport',
        sourceRect: null,
      };

      const params = createLabelParams({
        rect: rect(300, 100, 200, 150), // left=300, right=500, center=400
        lockX,
        viewport: { width: 800, height: 600 },
      });

      const labels = computeDistanceLabels(params);

      // Center lock should produce 2 viewport labels (left and right margins)
      const viewportLabels = labels.filter((l) => l.kind === 'viewport');
      expect(viewportLabels).toHaveLength(2);
      expect(viewportLabels.map((l) => l.value).sort()).toEqual([300, 300]);
    });

    it('shows both margins for middle lock (Y-axis)', () => {
      const lockY: SnapLockY = {
        type: 'middle',
        value: 300, // viewport middle
        source: 'viewport',
        sourceRect: null,
      };

      const params = createLabelParams({
        rect: rect(100, 225, 200, 150), // top=225, bottom=375, middle=300
        lockY,
        viewport: { width: 800, height: 600 },
      });

      const labels = computeDistanceLabels(params);

      // Middle lock should produce 2 viewport labels (top and bottom margins)
      const viewportLabels = labels.filter((l) => l.kind === 'viewport');
      expect(viewportLabels).toHaveLength(2);
      expect(viewportLabels.map((l) => l.value).sort()).toEqual([225, 225]);
    });
  });

  describe('invalid rect handling', () => {
    it('returns empty labels for zero-width rect', () => {
      const params = createLabelParams({
        rect: rect(0, 0, 0, 100),
      });

      const labels = computeDistanceLabels(params);

      expect(labels).toEqual([]);
    });

    it('returns empty labels for zero-height rect', () => {
      const params = createLabelParams({
        rect: rect(0, 0, 100, 0),
      });

      const labels = computeDistanceLabels(params);

      expect(labels).toEqual([]);
    });
  });

  describe('no lock state', () => {
    it('returns empty labels when no locks are active', () => {
      const params = createLabelParams({
        lockX: null,
        lockY: null,
      });

      const labels = computeDistanceLabels(params);

      expect(labels).toEqual([]);
    });
  });
});
