/*
 * 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.
 */

/**
 * Dirty quad buffer (C2) unit tests for WebGlRenderer.
 *
 * Validates the surgical `bufferSubData` upload path: permanent slot
 * assignment in addQuad, the full-vs-surgical decision in render(), the
 * structural invalidation hook, and the dedicated RTT buffer. The renderer is
 * created via Object.create so no WebGL context is required; only the fields
 * under test are wired up.
 */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { CoreNode, CoreNodeRenderState } from '../../CoreNode.js';
import { TextureType, type Texture } from '../../textures/Texture.js';
import type { Stage } from '../../Stage.js';
import { WebGlRenderer } from './WebGlRenderer.js';
import { makeMockStage, makeNodeProps } from '../../../../test/mockStage.js';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

const clippingRect = {
  x: 0,
  y: 0,
  w: 200,
  h: 200,
  valid: false,
  clipRadius: 0,
};

const makeTexture = (): Texture =>
  ({
    type: TextureType.image,
    ctxTexture: {},
    state: 'loaded',
    setRenderableOwner: vi.fn(),
    retryCount: 0,
    maxRetryCount: 0,
  } as unknown as Texture);

interface GlwMock {
  arrayBufferData: ReturnType<typeof vi.fn>;
  arrayBufferSubData: ReturnType<typeof vi.fn>;
  bindFramebuffer: ReturnType<typeof vi.fn>;
  viewport: ReturnType<typeof vi.fn>;
  clearColor: ReturnType<typeof vi.fn>;
  STATIC_DRAW: number;
  DYNAMIC_DRAW: number;
}

interface RendererHarness {
  renderer: WebGlRenderer;
  glw: GlwMock;
  stage: Stage;
}

const makeRenderer = (nodes: CoreNode[] = []): RendererHarness => {
  const glw = {
    arrayBufferData: vi.fn(),
    arrayBufferSubData: vi.fn(),
    bindFramebuffer: vi.fn(),
    viewport: vi.fn(),
    clearColor: vi.fn(),
    STATIC_DRAW: 0x88e4,
    DYNAMIC_DRAW: 0x88e8,
    setScissorTest: vi.fn(),
    setStencilTest: vi.fn(),
    clear: vi.fn(),
    canvas: { width: 1920, height: 1080 },
  };

  const quadBuffer = new ArrayBuffer(80 * 20); // 20-quad capacity
  const stage = makeMockStage({
    renderListNodes: nodes,
    renderListLen: nodes.length,
    options: { quadBufferSize: quadBuffer.byteLength, enableClear: true },
    // A concrete default shader so reuseRenderOp batches via the
    // shaderKey==='default' fast path instead of touching shader programs.
    defShaderNode: {
      shaderKey: 'default',
      program: {},
    } as unknown as Stage['defShaderNode'],
  } as Partial<Stage>);

  const renderer = Object.create(WebGlRenderer.prototype) as WebGlRenderer;
  (renderer as any).glw = glw;
  (renderer as any).stage = stage;
  (renderer as any).quadBuffer = quadBuffer;
  (renderer as any).fQuadBuffer = new Float32Array(quadBuffer);
  (renderer as any).uiQuadBuffer = new Uint32Array(quadBuffer);
  (renderer as any).renderOps = [];
  (renderer as any).rttNodes = [];
  (renderer as any).activeRttNode = null;
  (renderer as any).renderToTextureActive = false;
  (renderer as any).curBufferIdx = 0;
  (renderer as any).curRenderOp = null;
  (renderer as any).dirtyQuadCount = 0;
  (renderer as any).needsFullUpload = true;
  (renderer as any).lastUploadedBufferSize = 0;
  (renderer as any).rttQuadBuffer = null;
  (renderer as any).fRttQuadBuffer = null;
  (renderer as any).uiRttQuadBuffer = null;
  (renderer as any).clearColor = { raw: 0, normalized: [0, 0, 0, 0] };
  (renderer as any).quadBufferCollection = {
    getBuffer: () => 'quad-buffer',
  };
  (renderer as any).defaultTextureCoords = { x1: 0, y1: 0, x2: 1, y2: 1 };
  const scratch = new Float32Array(20);
  (renderer as any)._quadScratchBuffer = scratch.buffer;
  (renderer as any)._quadScratchF = scratch;

  // Shared SDF text buffers (unused by these tests; sized so the upload paths
  // early-return). Same mock style as `quadBufferCollection` above.
  const sdfBuffer = {
    idx: 0,
    changed: true,
    lastUploadedSize: 0,
    clear: vi.fn(),
    quadBufferCollection: { getBuffer: () => 'sdf-buffer' },
  };
  (renderer as any).sdfBufferPlain = sdfBuffer;
  (renderer as any).sdfBufferRich = sdfBuffer;
  (renderer as any).curSdfRenderOp = null;

  return { renderer, glw, stage };
};

const makeNode = (stage: Stage): CoreNode => {
  const node = new CoreNode(stage, makeNodeProps({ w: 100, h: 100 }));
  (node as any).props.texture = makeTexture();
  // The draw loop calls op.draw(this); unit tests focus on the quad buffer
  // bookkeeping, so draw is stubbed out here.
  vi.spyOn(node, 'draw').mockReturnValue(undefined);
  node.update(0, clippingRect);
  return node;
};

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('WebGlRenderer dirty quad buffer — slot assignment', () => {
  let harness: RendererHarness;
  let renderer: WebGlRenderer;
  let stage: Stage;
  let a: CoreNode;
  let b: CoreNode;
  let c: CoreNode;

  beforeEach(() => {
    harness = makeRenderer();
    renderer = harness.renderer;
    stage = harness.stage;
    a = makeNode(stage);
    b = makeNode(stage);
    c = makeNode(stage);
    stage.renderListNodes = [a, b, c];
    stage.renderListLen = 3;
  });

  it('assigns contiguous permanent slots and advances curBufferIdx by 20 per quad', () => {
    renderer.reset();
    renderer.addQuad(a);
    expect(a.quadBufferIndex).toBe(0);
    expect(renderer.curBufferIdx).toBe(20);

    renderer.addQuad(b);
    expect(b.quadBufferIndex).toBe(20);
    expect(renderer.curBufferIdx).toBe(40);

    renderer.addQuad(c);
    expect(c.quadBufferIndex).toBe(40);
    expect(renderer.curBufferIdx).toBe(60);

    expect(renderer.fQuadBuffer[0]).toBe(a.renderCoords!.x1);
    expect(renderer.fQuadBuffer[20]).toBe(b.renderCoords!.x1);
    expect(renderer.fQuadBuffer[40]).toBe(c.renderCoords!.x1);
  });

  it('re-assigns the same slots on a stable replay frame', () => {
    renderer.reset();
    renderer.addQuad(a);
    renderer.addQuad(b);
    renderer.addQuad(c);
    const slots = [a.quadBufferIndex, b.quadBufferIndex, c.quadBufferIndex];

    renderer.reset();
    renderer.addQuad(a);
    renderer.addQuad(b);
    renderer.addQuad(c);

    expect([a.quadBufferIndex, b.quadBufferIndex, c.quadBufferIndex]).toEqual(
      slots,
    );
  });

  it('counts dirty main-scene nodes in dirtyQuadCount during the pass', () => {
    a.isQuadDirty = false;
    b.isQuadDirty = true;
    c.isQuadDirty = false;
    renderer.reset();
    renderer.addQuad(a);
    renderer.addQuad(b);
    renderer.addQuad(c);
    expect(renderer.dirtyQuadCount).toBe(1);
  });

  it('does not count RTT nodes in dirtyQuadCount', () => {
    b.isQuadDirty = true;
    renderer.renderToTextureActive = true;
    renderer.reset();
    renderer.addQuad(a);
    renderer.addQuad(b);
    expect(renderer.dirtyQuadCount).toBe(0);
    renderer.renderToTextureActive = false;
  });
});

describe('WebGlRenderer dirty quad buffer — render() upload decision', () => {
  let harness: RendererHarness;
  let renderer: WebGlRenderer;
  let glw: GlwMock;
  let stage: Stage;
  let a: CoreNode;
  let b: CoreNode;
  let c: CoreNode;

  beforeEach(() => {
    harness = makeRenderer();
    renderer = harness.renderer;
    glw = harness.glw;
    stage = harness.stage;
    a = makeNode(stage);
    b = makeNode(stage);
    c = makeNode(stage);
    stage.renderListNodes = [a, b, c];
    stage.renderListLen = 3;
  });

  it('full uploads on the first frame with DYNAMIC_DRAW and clears dirty flags', () => {
    renderer.reset();
    renderer.addQuad(a);
    renderer.addQuad(b);
    renderer.addQuad(c);
    renderer.render();

    expect(glw.arrayBufferData).toHaveBeenCalledTimes(1);
    const [buffer, data, usage] = glw.arrayBufferData.mock.calls[0]!;
    expect(buffer).toBe('quad-buffer');
    expect((data as Float32Array).length).toBe(60);
    expect(usage).toBe(glw.DYNAMIC_DRAW);
    expect(glw.arrayBufferSubData).not.toHaveBeenCalled();
    expect(a.isQuadDirty).toBe(false);
    expect(renderer.needsFullUpload).toBe(false);
    expect(renderer.lastUploadedBufferSize).toBe(60);
  });

  it('surgically uploads only the dirty slot via arrayBufferSubData', () => {
    renderer.reset();
    renderer.addQuad(a);
    renderer.addQuad(b);
    renderer.addQuad(c);
    renderer.render(); // full upload frame 1
    expect(glw.arrayBufferData).toHaveBeenCalledTimes(1);

    // Frame 2: only node b changed.
    b.isQuadDirty = true;
    renderer.reset();
    renderer.addQuad(a);
    renderer.addQuad(b);
    renderer.addQuad(c);
    renderer.render();

    expect(glw.arrayBufferData).toHaveBeenCalledTimes(1);
    expect(glw.arrayBufferSubData).toHaveBeenCalledTimes(1);
    const [buffer, byteOffset, data] = glw.arrayBufferSubData.mock.calls[0]!;
    expect(buffer).toBe('quad-buffer');
    expect(byteOffset).toBe(b.quadBufferIndex * 4);
    expect((data as Float32Array).length).toBe(20);
    expect(b.isQuadDirty).toBe(false);
    expect(a.isQuadDirty).toBe(false);
    expect(c.isQuadDirty).toBe(false);
  });

  it('falls back to a full upload when more than 40% of nodes are dirty', () => {
    renderer.reset();
    renderer.addQuad(a);
    renderer.addQuad(b);
    renderer.addQuad(c);
    renderer.render(); // full upload frame 1
    expect(glw.arrayBufferData).toHaveBeenCalledTimes(1);

    // Frame 2: 2 of 3 nodes dirty → 2 > 1.2 → full upload.
    a.isQuadDirty = true;
    b.isQuadDirty = true;
    renderer.reset();
    renderer.addQuad(a);
    renderer.addQuad(b);
    renderer.addQuad(c);
    renderer.render();

    expect(glw.arrayBufferData).toHaveBeenCalledTimes(2);
    expect(glw.arrayBufferSubData).not.toHaveBeenCalled();
    expect(a.isQuadDirty).toBe(false);
    expect(b.isQuadDirty).toBe(false);
  });

  it('full uploads when the buffer grows past the last uploaded size', () => {
    renderer.reset();
    renderer.addQuad(a);
    renderer.addQuad(b);
    renderer.addQuad(c);
    renderer.render(); // full upload, lastUploadedBufferSize = 60
    expect(renderer.lastUploadedBufferSize).toBe(60);

    // Scene grows: two more nodes join this frame.
    stage.renderListNodes = [a, b, c, makeNode(stage), makeNode(stage)];
    stage.renderListLen = stage.renderListNodes.length;
    renderer.reset();
    for (let i = 0; i < stage.renderListLen; i++) {
      renderer.addQuad(stage.renderListNodes[i]!);
    }
    renderer.render();

    expect(glw.arrayBufferData).toHaveBeenCalledTimes(2);
    const [, data] = glw.arrayBufferData.mock.calls[1]!;
    expect((data as Float32Array).length).toBe(100);
    expect(renderer.lastUploadedBufferSize).toBe(100);
  });

  it('uses the preallocated scratch buffer for surgical uploads', () => {
    renderer.reset();
    renderer.addQuad(a);
    renderer.addQuad(b);
    renderer.addQuad(c);
    renderer.render();

    b.isQuadDirty = true;
    renderer.reset();
    renderer.addQuad(a);
    renderer.addQuad(b);
    renderer.addQuad(c);
    renderer.render();

    const [, , data] = glw.arrayBufferSubData.mock.calls[0]!;
    expect(data).toBe((renderer as any)._quadScratchF);
    // The scratch holds the slot's fresh data.
    expect((data as Float32Array)[0]).toBe(
      renderer.fQuadBuffer[b.quadBufferIndex],
    );
  });
});

describe('WebGlRenderer dirty quad buffer — invalidateQuadBuffer', () => {
  let harness: RendererHarness;
  let renderer: WebGlRenderer;
  let stage: Stage;
  let a: CoreNode;
  let b: CoreNode;
  let c: CoreNode;

  beforeEach(() => {
    harness = makeRenderer();
    renderer = harness.renderer;
    stage = harness.stage;
    a = makeNode(stage);
    b = makeNode(stage);
    c = makeNode(stage);
    stage.renderListNodes = [a, b, c];
    stage.renderListLen = 3;
  });

  it('resets all slots, marks everything dirty and forces a full upload', () => {
    renderer.reset();
    renderer.addQuad(a);
    renderer.addQuad(b);
    renderer.addQuad(c);
    renderer.render();
    expect(renderer.needsFullUpload).toBe(false);

    renderer.invalidateQuadBuffer();

    expect(a.quadBufferIndex).toBe(-1);
    expect(b.quadBufferIndex).toBe(-1);
    expect(c.quadBufferIndex).toBe(-1);
    expect(a.isQuadDirty).toBe(true);
    expect(b.isQuadDirty).toBe(true);
    expect(c.isQuadDirty).toBe(true);
    expect(renderer.needsFullUpload).toBe(true);
    expect(renderer.curBufferIdx).toBe(0);
    expect(renderer.lastUploadedBufferSize).toBe(0);
  });

  it('reassigns compact slots from 0 on the next frame', () => {
    renderer.reset();
    renderer.addQuad(a);
    renderer.addQuad(b);
    renderer.render();

    renderer.invalidateQuadBuffer();
    renderer.reset();
    renderer.addQuad(a);
    renderer.addQuad(b);

    expect(a.quadBufferIndex).toBe(0);
    expect(b.quadBufferIndex).toBe(20);
  });
});

describe('WebGlRenderer dirty quad buffer — RTT isolation', () => {
  let harness: RendererHarness;
  let renderer: WebGlRenderer;
  let glw: GlwMock;
  let stage: Stage;
  let child: CoreNode;

  beforeEach(() => {
    harness = makeRenderer();
    renderer = harness.renderer;
    glw = harness.glw;
    stage = harness.stage;
    child = makeNode(stage);
  });

  it('writes RTT quads into the dedicated RTT buffer without touching main slots', () => {
    child.quadBufferIndex = 123; // simulate an existing main-scene slot
    renderer.renderToTextureActive = true;
    renderer.addQuad(child);

    expect(renderer.rttQuadBuffer).not.toBeNull();
    expect(renderer.curBufferIdx).toBe(20);
    expect(child.quadBufferIndex).toBe(123);
    expect((renderer.fRttQuadBuffer as Float32Array)[0]).toBe(
      child.renderCoords!.x1,
    );
    // Main CPU buffer is untouched.
    expect(renderer.fQuadBuffer[0]).toBe(0);
    renderer.renderToTextureActive = false;
  });

  it('renderRTT full uploads from the RTT buffer with STATIC_DRAW', () => {
    const drawSpy = vi.spyOn(child, 'draw').mockReturnValue(undefined);
    renderer.renderToTextureActive = true;
    renderer.addQuad(child);
    (renderer as any).renderRTT();

    expect(glw.arrayBufferData).toHaveBeenCalledTimes(1);
    const [buffer, data, usage] = glw.arrayBufferData.mock.calls[0]!;
    expect(buffer).toBe('quad-buffer');
    expect((data as Float32Array).length).toBe(20);
    expect(usage).toBe(glw.STATIC_DRAW);
    expect(drawSpy).toHaveBeenCalledTimes(1);
    renderer.renderToTextureActive = false;
  });

  it('renderRTTNodes restores the main buffer state and forces a full re-upload', () => {
    const rttNode = new CoreNode(stage, makeNodeProps({ rtt: false }));
    (rttNode as any).props.texture = {
      type: TextureType.renderToTexture,
      state: 'loaded',
      ctxTexture: { framebuffer: {}, w: 100, h: 100 },
    };
    rttNode.hasRTTupdates = true;
    rttNode.worldAlpha = 1;
    rttNode.renderState = CoreNodeRenderState.InBounds;
    (stage as any).addSubtreeQuads = vi.fn();

    renderer.rttNodes = [rttNode];
    renderer.curBufferIdx = 999;
    renderer.needsFullUpload = false;
    renderer.lastUploadedBufferSize = 999;
    (renderer as any).rttQuadBuffer = new ArrayBuffer(80);
    (renderer as any).fRttQuadBuffer = new Float32Array(
      (renderer as any).rttQuadBuffer,
    );
    (renderer as any).uiRttQuadBuffer = new Uint32Array(
      (renderer as any).rttQuadBuffer,
    );

    renderer.renderRTTNodes();

    expect(renderer.curBufferIdx).toBe(999);
    expect(renderer.needsFullUpload).toBe(true);
    expect(renderer.lastUploadedBufferSize).toBe(0);
    expect(renderer.renderToTextureActive).toBe(false);
  });
});
