/*
 * If not stated otherwise in this file or this component's LICENSE file the
 * following copyright and licenses apply:
 *
 * Copyright 2023 Comcast Cable Communications Management, LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the License);
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * Tests for the batched SDF write paths, render op grouping, and upload skip.
 *
 * A full renderer needs a live GL context, so methods are exercised on a
 * minimal fake `this` via the prototype, holding only the fields each method
 * touches. The SdfBuffer instances themselves are real (they only need
 * `glw.createBuffer()`), so cursor/growth/change-tracking behavior is the
 * production code.
 */
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { WebGlRenderer } from './WebGlRenderer.js';
import {
  SdfBuffer,
  SDF_PLAIN_FLOATS_PER_VERTEX,
  SDF_RICH_FLOATS_PER_VERTEX,
  SDF_PLAIN_GLYPH_STRIDE,
  SDF_RICH_GLYPH_STRIDE,
} from './SdfBuffer.js';
import type { GlContextWrapper } from '../../platforms/GlContextWrapper.js';
import type { WebGlCtxTexture } from './WebGlCtxTexture.js';
import type { Stage } from '../../Stage.js';
import type { WebGlShaderNode } from './WebGlShaderNode.js';
import type {
  TextLayout,
  TextRenderProps,
  SdfVertexCache,
} from '../../text-rendering/TextRenderer.js';
import * as SdfFontHandler from '../../text-rendering/SdfFontHandler.js';
import SdfTextRenderer from '../../text-rendering/SdfTextRenderer.js';

const FLOATS_PER_GLYPH_PLAIN = SDF_PLAIN_FLOATS_PER_VERTEX * 4; // 24
const FLOATS_PER_GLYPH_RICH = SDF_RICH_FLOATS_PER_VERTEX * 4; // 28

const makeGlw = () =>
  ({
    FLOAT: 5126,
    UNSIGNED_BYTE: 5121,
    createBuffer: () => ({}),
  } as unknown as GlContextWrapper);

const makeSdfBuffer = (layout: 'plain' | 'rich', initialBytes?: number) =>
  new SdfBuffer(makeGlw(), layout, initialBytes);

const NO_CLIP = { x: 0, y: 0, w: 0, h: 0, valid: false };

/**
 * Build one glyph record of design-unit data (see SdfBuffer docs for strides).
 * The packed span color is written bit-exactly via a Uint32 view so float32
 * NaN bit patterns survive (0xffffffff is a float32 NaN).
 */
const makeGlyph = (
  stride: number,
  packedSpanColor: number,
  style = 0,
): Float32Array => {
  const buf = new ArrayBuffer(stride * 4);
  const f = new Float32Array(buf);
  const u = new Uint32Array(buf);
  // x, y, w, h, u, v, uw, vh
  const fields = [0, 0, 10, 10, 0, 0, 0.1, 0.1] as const;
  for (let i = 0; i < fields.length; i++) {
    f[i] = fields[i]!;
  }
  if (stride === SDF_RICH_GLYPH_STRIDE) {
    // 12-float rich record: 8/9 = shearTop/shearBot (0 = no italic lean),
    // 10 = packed_span_color, 11 = style.
    u[10] = packedSpanColor;
    f[11] = style;
  }
  return f;
};
const IDENTITY = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]);

const atlasA = {} as WebGlCtxTexture;
const atlasB = {} as WebGlCtxTexture;

type FakeFinalize = {
  finalizeSdfBatch: ReturnType<typeof vi.fn>;
};

const makeFinalizerFake = (): FakeFinalize => ({
  finalizeSdfBatch: vi.fn(),
});

const addSdfQuads = (
  fake: unknown,
  sdfBuffer: SdfBuffer,
  glyphs: Float32Array,
  glyphCount: number,
  overrides: {
    color?: number;
    worldAlpha?: number;
    distanceRange?: number;
    atlas?: WebGlCtxTexture;
    transform?: Float32Array;
  } = {},
): void => {
  const {
    color = 0xffffffff,
    worldAlpha = 1,
    distanceRange = 4,
    atlas = atlasA,
    transform = IDENTITY,
  } = overrides;
  (
    WebGlRenderer.prototype as unknown as {
      addSdfQuads: (this: unknown, ...args: unknown[]) => void;
    }
  ).addSdfQuads.call(
    fake,
    sdfBuffer,
    glyphs,
    glyphCount,
    1, // fontScale
    transform,
    color,
    worldAlpha,
    distanceRange,
    atlas,
    NO_CLIP,
    100,
    50,
    false,
    null,
    {},
  );
};

const addSdfTranslatedQuads = (
  fake: unknown,
  sdfBuffer: SdfBuffer,
  cached: Float32Array,
  numGlyphs: number,
  dx: number,
  dy: number,
): void => {
  (
    WebGlRenderer.prototype as unknown as {
      addSdfTranslatedQuads: (this: unknown, ...args: unknown[]) => void;
    }
  ).addSdfTranslatedQuads.call(
    fake,
    sdfBuffer,
    cached,
    numGlyphs,
    dx,
    dy,
    atlasA,
    NO_CLIP,
    1,
    100,
    50,
    false,
    null,
    {},
  );
};

// ---------------------------------------------------------------------------
// SdfBuffer — layouts, growth, change tracking
// ---------------------------------------------------------------------------

describe('SdfBuffer', () => {
  it('plain layout: 6 floats/vertex, no a_style attribute', () => {
    const b = makeSdfBuffer('plain');
    expect(b.floatsPerVertex).toBe(SDF_PLAIN_FLOATS_PER_VERTEX);
    expect(b.quadBufferCollection.getAttributeInfo('a_position')!.stride).toBe(
      SDF_PLAIN_FLOATS_PER_VERTEX * 4,
    );
    expect(b.quadBufferCollection.getAttributeInfo('a_style')).toBeUndefined();
  });

  it('rich layout: 7 floats/vertex, a_style at offset 5f', () => {
    const b = makeSdfBuffer('rich');
    expect(b.floatsPerVertex).toBe(SDF_RICH_FLOATS_PER_VERTEX);
    expect(b.quadBufferCollection.getAttributeInfo('a_style')).toBeDefined();
    expect(b.quadBufferCollection.getAttributeInfo('a_style')!.offset).toBe(
      5 * 4,
    );
    expect(b.quadBufferCollection.getAttributeInfo('a_distRange')!.offset).toBe(
      6 * 4,
    );
  });

  it('clear resets the cursor but not the changed flag', () => {
    const b = makeSdfBuffer('plain');
    b.idx = 100;
    b.quadCount = 5;
    b.changed = false;
    b.clear();
    expect(b.idx).toBe(0);
    expect(b.quadCount).toBe(0);
    // changed is preserved — a later identical fill may still skip safely.
    expect(b.changed).toBe(false);
  });

  it('ensureCapacity is a no-op when the data fits', () => {
    const b = makeSdfBuffer('plain');
    const before = b.fBuffer;
    b.changed = false;
    b.ensureCapacity(10);
    expect(b.fBuffer).toBe(before);
    expect(b.changed).toBe(false);
  });

  it('ensureCapacity doubles and preserves content, marking changed', () => {
    const b = makeSdfBuffer('plain', 1024); // 256 floats
    b.fBuffer[0] = 42;
    b.fBuffer[1] = -7;
    b.changed = false;
    b.ensureCapacity(200); // fits in 256 floats — no growth
    expect(b.changed).toBe(false);
    b.ensureCapacity(300); // > capacity — doubles to 512 floats
    expect(b.changed).toBe(true);
    expect(b.fBuffer.length).toBeGreaterThanOrEqual(300);
    expect(b.fBuffer[0]).toBe(42);
    expect(b.fBuffer[1]).toBe(-7);
  });
});

// ---------------------------------------------------------------------------
// addSdfQuads — per-glyph CPU transform into the shared buffer
// ---------------------------------------------------------------------------

describe('WebGlRenderer.addSdfQuads', () => {
  it('writes a plain glyph (24 floats) with transformed world position', () => {
    const fake = makeFinalizerFake();
    const b = makeSdfBuffer('plain');
    const glyphs = makeGlyph(SDF_PLAIN_GLYPH_STRIDE, 0);

    addSdfQuads(fake, b, glyphs, 1);

    const f = b.fBuffer;
    expect([f[0], f[1]]).toEqual([0, 0]); // TL
    expect([f[6], f[7]]).toEqual([10, 0]); // TR
    expect([f[12], f[13]]).toEqual([0, 10]); // BL
    expect([f[18], f[19]]).toEqual([10, 10]); // BR
    expect([f[2], f[3], f[5]]).toEqual([0, 0, 4]); // uv, distRange
    expect(b.idx).toBe(FLOATS_PER_GLYPH_PLAIN);
    expect(b.quadCount).toBe(1);
    expect(b.changed).toBe(true);
    expect(fake.finalizeSdfBatch).toHaveBeenCalledTimes(1);
  });

  it('writes a rich glyph (28 floats) with style and merged span color', () => {
    const fake = makeFinalizerFake();
    const b = makeSdfBuffer('rich');
    // Node color red RGBA(255,0,0,255) packed = 0xff0000ff; span green
    // RGBA(0,255,0,255) packed = 0xff00ff00. Multiplicative merge → black.
    const glyphs = makeGlyph(SDF_RICH_GLYPH_STRIDE, 0xff00ff00, 3);

    addSdfQuads(fake, b, glyphs, 1, { color: 0xff0000ff });

    expect(b.idx).toBe(FLOATS_PER_GLYPH_RICH);
    // Merge rounds 255*255 → 254, so RGBA(0,0,0,254): 0xfe000000
    expect(b.uiBuffer[4]).toBe(0xfe000000);
    expect([b.fBuffer[5], b.fBuffer[6]]).toEqual([3, 4]); // style, distRange
    expect(b.quadCount).toBe(1);
  });

  it('appends after existing content (startQuad continues)', () => {
    const fake = makeFinalizerFake();
    const b = makeSdfBuffer('plain');
    const glyphs = makeGlyph(SDF_PLAIN_GLYPH_STRIDE, 0);

    addSdfQuads(fake, b, glyphs, 1);
    addSdfQuads(fake, b, glyphs, 1);

    expect(b.idx).toBe(FLOATS_PER_GLYPH_PLAIN * 2);
    expect(b.quadCount).toBe(2);
    // finalizeSdfBatch(sdfBuffer, startQuad, glyphCount, …)
    expect(fake.finalizeSdfBatch.mock.calls[0]![1]).toBe(0);
    expect(fake.finalizeSdfBatch.mock.calls[1]![1]).toBe(1);
  });

  it('is a no-op for zero glyphs', () => {
    const fake = makeFinalizerFake();
    const b = makeSdfBuffer('plain');
    b.changed = false;
    const glyphs = makeGlyph(SDF_PLAIN_GLYPH_STRIDE, 0);

    addSdfQuads(fake, b, glyphs, 0);

    expect(b.idx).toBe(0);
    expect(b.changed).toBe(false);
    expect(fake.finalizeSdfBatch).not.toHaveBeenCalled();
  });
});

// ---------------------------------------------------------------------------
// addSdfTranslatedQuads — scroll fast path
// ---------------------------------------------------------------------------

describe('WebGlRenderer.addSdfTranslatedQuads', () => {
  const makeCachedGlyph = (color: number, rich: boolean): Float32Array => {
    const vpf = rich ? SDF_RICH_FLOATS_PER_VERTEX : SDF_PLAIN_FLOATS_PER_VERTEX;
    const buf = new ArrayBuffer(vpf * 4 * 4);
    const f = new Float32Array(buf);
    const u = new Uint32Array(buf);
    const corners = [
      [10, 20],
      [30, 20],
      [10, 40],
      [30, 40],
    ];
    for (let v = 0; v < 4; v++) {
      const i = v * vpf;
      f[i] = corners[v]![0]!;
      f[i + 1] = corners[v]![1]!;
      f[i + 2] = 0.1;
      f[i + 3] = 0.2;
      u[i + 4] = color;
      if (rich) {
        f[i + 5] = 2;
        f[i + 6] = 4;
      } else {
        f[i + 5] = 4;
      }
    }
    return f;
  };

  it('shifts positions by (dx, dy) and marks changed', () => {
    const fake = makeFinalizerFake();
    const b = makeSdfBuffer('plain');
    const cached = makeCachedGlyph(0x00ff00ff, false);

    addSdfTranslatedQuads(fake, b, cached, 1, 5, -3);

    const f = b.fBuffer;
    expect([f[0], f[1]]).toEqual([15, 17]);
    expect([f[6], f[7]]).toEqual([35, 17]);
    expect([f[12], f[13]]).toEqual([15, 37]);
    expect([f[18], f[19]]).toEqual([35, 37]);
    // UVs, color, distRange untouched (float32-rounded source values)
    expect([f[2], f[3], f[5]]).toEqual([Math.fround(0.1), Math.fround(0.2), 4]);
    expect(b.idx).toBe(FLOATS_PER_GLYPH_PLAIN);
    expect(b.quadCount).toBe(1);
    expect(b.changed).toBe(true);
    expect(fake.finalizeSdfBatch).toHaveBeenCalledTimes(1);
    // Source cache untouched
    expect(cached[0]).toBe(10);
    expect(cached[1]).toBe(20);
  });

  it('preserves packed color bits exactly, including float32 NaN patterns', () => {
    const fake = makeFinalizerFake();
    const b = makeSdfBuffer('rich');
    // 0xffffffff is a float32 NaN bit pattern — element-wise float copies
    // could canonicalize it; the memcpy must not.
    const cached = makeCachedGlyph(0xffffffff, true);

    addSdfTranslatedQuads(fake, b, cached, 1, 100, 200);

    for (let v = 0; v < 4; v++) {
      expect(b.uiBuffer[v * SDF_RICH_FLOATS_PER_VERTEX + 4]).toBe(0xffffffff);
    }
  });

  it('is a no-op for zero glyphs', () => {
    const fake = makeFinalizerFake();
    const b = makeSdfBuffer('plain');
    b.changed = false;
    const cached = makeCachedGlyph(0x000000ff, false);

    addSdfTranslatedQuads(fake, b, cached, 0, 5, 5);

    expect(b.idx).toBe(0);
    expect(b.changed).toBe(false); // nothing written — no fresh bytes
    expect(fake.finalizeSdfBatch).not.toHaveBeenCalled();
  });
});

// ---------------------------------------------------------------------------
// addSdfCachedQuads — exact cache hits must NOT mark changed
// ---------------------------------------------------------------------------

describe('WebGlRenderer.addSdfCachedQuads', () => {
  it('memcpys identical bytes without raising the changed flag', () => {
    const fake = makeFinalizerFake();
    const b = makeSdfBuffer('plain');
    b.changed = false;
    const buf = new ArrayBuffer(FLOATS_PER_GLYPH_PLAIN * 4);
    const f = new Float32Array(buf);
    const u = new Uint32Array(buf);
    for (let v = 0; v < 4; v++) {
      const i = v * SDF_PLAIN_FLOATS_PER_VERTEX;
      f[i] = v * 10;
      f[i + 1] = 20;
      u[i + 4] = 0xffffffff; // NaN bit pattern
      f[i + 5] = 4;
    }

    (
      WebGlRenderer.prototype as unknown as {
        addSdfCachedQuads: (this: unknown, ...args: unknown[]) => void;
      }
    ).addSdfCachedQuads.call(
      fake,
      b,
      f,
      1,
      atlasA,
      NO_CLIP,
      1,
      100,
      50,
      false,
      null,
      {},
    );

    expect(b.idx).toBe(FLOATS_PER_GLYPH_PLAIN);
    expect(b.quadCount).toBe(1);
    expect(b.changed).toBe(false); // cache-hit memcpy — upload may be skipped
    expect(b.uiBuffer[4]).toBe(0xffffffff); // bits preserved
    expect(fake.finalizeSdfBatch).toHaveBeenCalledTimes(1);
  });

  it('is a no-op for zero glyphs', () => {
    const fake = makeFinalizerFake();
    const b = makeSdfBuffer('plain');
    b.changed = false;

    (
      WebGlRenderer.prototype as unknown as {
        addSdfCachedQuads: (this: unknown, ...args: unknown[]) => void;
      }
    ).addSdfCachedQuads.call(
      fake,
      b,
      new Float32Array(FLOATS_PER_GLYPH_PLAIN),
      0,
      atlasA,
      NO_CLIP,
      1,
      100,
      50,
      false,
      null,
      {},
    );

    expect(b.idx).toBe(0);
    expect(b.changed).toBe(false);
    expect(fake.finalizeSdfBatch).not.toHaveBeenCalled();
  });
});

// ---------------------------------------------------------------------------
// finalizeSdfBatch — render op grouping / merge
// ---------------------------------------------------------------------------

type RendererStub = {
  stage: { pixelRatio: number };
  glw: ReturnType<typeof makeGlw> & {
    arrayBufferData: ReturnType<typeof vi.fn>;
  };
  sdfBufferPlain: SdfBuffer;
  sdfBufferRich: SdfBuffer;
  curSdfRenderOp: unknown;
  curRenderOp: unknown;
  renderOps: unknown[];
};

const makeRendererStub = (): RendererStub => {
  const glw = {
    FLOAT: 5126,
    UNSIGNED_BYTE: 5121,
    createBuffer: () => ({}),
    arrayBufferData: vi.fn(),
    DYNAMIC_DRAW: 35048,
  } as unknown as RendererStub['glw'];
  // Object.create so the real production methods (addSdfQuads, finalizeSdfBatch,
  // uploadSdfBuffer) are reachable via the prototype.
  const stub = Object.create(WebGlRenderer.prototype) as RendererStub;
  stub.stage = { pixelRatio: 1 };
  stub.glw = glw;
  stub.sdfBufferPlain = new SdfBuffer(glw, 'plain');
  stub.sdfBufferRich = new SdfBuffer(glw, 'rich');
  stub.curSdfRenderOp = null;
  stub.curRenderOp = null;
  stub.renderOps = [];
  return stub;
};

const finalize = (
  stub: RendererStub,
  sdfBuffer: SdfBuffer,
  startQuad: number,
  glyphCount: number,
  atlas: WebGlCtxTexture = atlasA,
  clip = NO_CLIP,
): void => {
  (
    stub as unknown as {
      finalizeSdfBatch: (this: unknown, ...args: unknown[]) => void;
    }
  ).finalizeSdfBatch.call(
    stub,
    sdfBuffer,
    startQuad,
    glyphCount,
    atlas,
    clip,
    1,
    100,
    50,
    false,
    null,
    {},
  );
};

describe('WebGlRenderer.finalizeSdfBatch', () => {
  it('merges into the current op when buffer, atlas and clip match', () => {
    const stub = makeRendererStub();

    finalize(stub, stub.sdfBufferPlain, 0, 3);
    finalize(stub, stub.sdfBufferPlain, 3, 2);

    expect(stub.renderOps.length).toBe(1);
    const op = stub.renderOps[0] as {
      startQuad: number;
      numQuads: number;
    };
    expect(op.startQuad).toBe(0);
    expect(op.numQuads).toBe(5);
    expect(stub.curSdfRenderOp).toBe(op);
  });

  it('breaks the batch on a different atlas texture', () => {
    const stub = makeRendererStub();

    finalize(stub, stub.sdfBufferPlain, 0, 3, atlasA);
    finalize(stub, stub.sdfBufferPlain, 3, 2, atlasB);

    expect(stub.renderOps.length).toBe(2);
    expect((stub.renderOps[0] as { numQuads: number }).numQuads).toBe(3);
    expect((stub.renderOps[1] as { numQuads: number }).numQuads).toBe(2);
  });

  it('breaks the batch on a different clipping rect', () => {
    const stub = makeRendererStub();
    const clipB = { x: 10, y: 10, w: 50, h: 50, valid: true };

    finalize(stub, stub.sdfBufferPlain, 0, 3, atlasA, NO_CLIP);
    finalize(stub, stub.sdfBufferPlain, 3, 2, atlasA, clipB);

    expect(stub.renderOps.length).toBe(2);
  });

  it('breaks the batch across layouts (different SdfBuffer)', () => {
    const stub = makeRendererStub();

    finalize(stub, stub.sdfBufferPlain, 0, 3);
    finalize(stub, stub.sdfBufferRich, 3, 2);

    expect(stub.renderOps.length).toBe(2);
  });

  it('resets the regular render op chain so nodes do not extend an SDF op', () => {
    const stub = makeRendererStub();
    stub.curRenderOp = { some: 'prior-op' };

    finalize(stub, stub.sdfBufferPlain, 0, 3);

    expect(stub.curRenderOp).toBeNull();
  });
});

// ---------------------------------------------------------------------------
// finalizeSdfBatch — inline op placement preserves z-order
// ---------------------------------------------------------------------------

describe('WebGlRenderer.finalizeSdfBatch inline placement', () => {
  it('pushes SDF ops directly into renderOps in write order', () => {
    const stub = makeRendererStub();

    // Simulate scene order: text, then a quad, then more text. Each non-mergeable
    // write becomes its own op positioned exactly where it was written, so an
    // intervening quad can never be covered by text that draws after it.
    finalize(stub, stub.sdfBufferPlain, 0, 2);
    stub.renderOps.push({ kind: 'quad' });
    finalize(stub, stub.sdfBufferPlain, 2, 1);

    expect(stub.renderOps.length).toBe(3);
    expect(stub.renderOps[0]).toHaveProperty('numQuads', 2);
    expect(stub.renderOps[1]).toMatchObject({ kind: 'quad' });
    expect(stub.renderOps[2]).toHaveProperty('numQuads', 1);
  });
});

// ---------------------------------------------------------------------------
// uploadSdfBuffer skip
// ---------------------------------------------------------------------------

describe('WebGlRenderer.uploadSdfBuffer skip', () => {
  const makeUploadStub = () => {
    const glw = {
      FLOAT: 5126,
      UNSIGNED_BYTE: 5121,
      DYNAMIC_DRAW: 35048,
      createBuffer: () => ({}),
      arrayBufferData: vi.fn(),
    } as unknown as RendererStub['glw'];
    const stub = makeRendererStub();
    stub.glw = glw;
    return { stub, glw };
  };

  const upload = (stub: RendererStub) => {
    (stub as unknown as { uploadSdfBuffer: () => void }).uploadSdfBuffer();
  };

  it('does nothing when no SDF glyphs were written', () => {
    const { stub, glw } = makeUploadStub();

    upload(stub);

    expect(glw.arrayBufferData).not.toHaveBeenCalled();
    // Flags preserved so a later frame with content still uploads
    expect(stub.sdfBufferPlain.changed).toBe(true);
  });

  it('uploads when changed, then skips identical follow-up frames', () => {
    const { stub, glw } = makeUploadStub();
    // 2 plain glyphs via cache hits (changed stays false)…
    stub.sdfBufferPlain.changed = false;
    stub.sdfBufferPlain.idx = FLOATS_PER_GLYPH_PLAIN * 2;

    upload(stub);
    expect(glw.arrayBufferData).toHaveBeenCalledTimes(1);
    expect(stub.sdfBufferPlain.changed).toBe(false);
    expect(stub.sdfBufferPlain.lastUploadedSize).toBe(
      FLOATS_PER_GLYPH_PLAIN * 2,
    );

    // Next frame: exact cache hits rewrote identical bytes, same size
    upload(stub);
    expect(glw.arrayBufferData).toHaveBeenCalledTimes(1);
  });

  it('uploads again when the changed flag is raised', () => {
    const { stub, glw } = makeUploadStub();
    stub.sdfBufferPlain.idx = FLOATS_PER_GLYPH_PLAIN;

    upload(stub);
    stub.sdfBufferPlain.changed = true;
    upload(stub);

    expect(glw.arrayBufferData).toHaveBeenCalledTimes(2);
  });

  it('uploads when the size differs even if the flag is clear', () => {
    const { stub, glw } = makeUploadStub();
    stub.sdfBufferPlain.idx = FLOATS_PER_GLYPH_PLAIN;

    upload(stub);
    stub.sdfBufferPlain.idx = FLOATS_PER_GLYPH_PLAIN / 2;
    upload(stub);

    expect(glw.arrayBufferData).toHaveBeenCalledTimes(2);
    expect(stub.sdfBufferPlain.lastUploadedSize).toBe(
      FLOATS_PER_GLYPH_PLAIN / 2,
    );
  });

  it('uploads each layout independently', () => {
    const { stub, glw } = makeUploadStub();
    stub.sdfBufferPlain.idx = FLOATS_PER_GLYPH_PLAIN;
    stub.sdfBufferRich.idx = FLOATS_PER_GLYPH_RICH;

    upload(stub);

    expect(glw.arrayBufferData).toHaveBeenCalledTimes(2);
  });
});

// ---------------------------------------------------------------------------
// renderQuads — cache-hit offset tracking (reorder / dirty-write detection)
// ---------------------------------------------------------------------------

const GLYPH_VERTS_PLAIN = (() => {
  const f = new Float32Array(FLOATS_PER_GLYPH_PLAIN);
  for (let i = 0; i < f.length; i++) {
    f[i] = i;
  }
  return f;
})();

const makePlainLayout = (glyphCount = 1) =>
  ({
    richText: false,
    glyphCount,
    width: 100,
    height: 50,
  } as unknown as TextLayout);

const makeRenderProps = (
  cache: SdfVertexCache,
  transform?: Float32Array,
): TextRenderProps =>
  ({
    fontFamily: 'renderQuads-reorder-test',
    color: 0xffffffff,
    worldAlpha: 1,
    globalTransform: transform ?? new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]),
    clippingRect: NO_CLIP,
    width: 100,
    height: 50,
    parentHasRenderTexture: false,
    framebufferDimensions: null,
    sdfCache: cache,
  } as unknown as TextRenderProps);

const makeCache = (
  layout: TextLayout,
  opts: Partial<SdfVertexCache> = {},
): SdfVertexCache => ({
  vertices: GLYPH_VERTS_PLAIN,
  glyphCount: 1,
  color: 0xffffffff,
  alpha: 1,
  transform: new Float32Array([1, 0, 0, 1, 0, 0]),
  layoutRef: layout,
  lastStartQuad: 0,
  lastWriteDirty: false,
  ...opts,
});

describe('SdfTextRenderer.renderQuads cache-hit offset tracking', () => {
  const fakeShader = {} as WebGlShaderNode;
  const fakeStage = {
    shManager: {
      registerShaderType: vi.fn(),
      createShader: vi.fn(() => fakeShader),
    },
  } as unknown as Stage;

  beforeAll(() => {
    SdfTextRenderer.init(fakeStage);
    vi.spyOn(SdfFontHandler, 'getAtlas').mockReturnValue({
      ctxTexture: atlasA,
    } as never);
  });

  it('static hit at the same offset leaves the buffer unchanged (upload may skip)', () => {
    const stub = makeRendererStub();
    stub.sdfBufferPlain.changed = false;
    const layout = makePlainLayout();
    const cache = makeCache(layout);

    SdfTextRenderer.renderQuads(
      stub as unknown as never,
      layout,
      null,
      makeRenderProps(cache),
    );

    expect(stub.sdfBufferPlain.changed).toBe(false);
    expect(cache.lastStartQuad).toBe(0);
    expect(cache.lastWriteDirty).toBe(false);
  });

  it('marks the buffer changed when a reorder moves the node quad range', () => {
    const stub = makeRendererStub();
    stub.sdfBufferPlain.changed = false;
    const layout = makePlainLayout();

    // Node B writes first, occupying quad 0 at its own unchanged offset.
    SdfTextRenderer.renderQuads(
      stub as unknown as never,
      layout,
      null,
      makeRenderProps(makeCache(layout, { lastStartQuad: 0 })),
    );
    expect(stub.sdfBufferPlain.changed).toBe(false);

    // Node A previously lived at quad 0 (lastStartQuad 0) but a render-list
    // reorder now draws it after B, so it lands at quad 1. The GPU copy is
    // stale at both offsets — the buffer must be re-uploaded.
    const cacheA = makeCache(layout, { lastStartQuad: 0 });
    SdfTextRenderer.renderQuads(
      stub as unknown as never,
      layout,
      null,
      makeRenderProps(cacheA),
    );

    expect(stub.sdfBufferPlain.changed).toBe(true);
    expect(cacheA.lastStartQuad).toBe(1);
    expect(cacheA.lastWriteDirty).toBe(false);
  });

  it('re-uploads when a node returns to base after a translated frame', () => {
    const stub = makeRendererStub();
    const layout = makePlainLayout();
    const cache = makeCache(layout);

    // Translated frame: shifted bytes written, buffer marked changed.
    SdfTextRenderer.renderQuads(
      stub as unknown as never,
      layout,
      null,
      makeRenderProps(cache, new Float32Array([1, 0, 0, 0, 1, 0, 5, 10, 1])),
    );
    expect(stub.sdfBufferPlain.changed).toBe(true);
    expect(cache.lastWriteDirty).toBe(true);

    // The upload consumed the flag.
    stub.sdfBufferPlain.changed = false;

    // The node returns to its exact base position: the GPU still holds the
    // shifted bytes, so the exact cache hit must force a re-upload.
    SdfTextRenderer.renderQuads(
      stub as unknown as never,
      layout,
      null,
      makeRenderProps(cache),
    );
    expect(stub.sdfBufferPlain.changed).toBe(true);
    expect(cache.lastWriteDirty).toBe(false);
  });
});
