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

import { describe, expect, it, vi, beforeEach } from 'vitest';
import { CoreTextNode, type CoreTextNodeProps } from './CoreTextNode.js';
import { CoreNodeRenderState } from './CoreNode.js';
import { Stage } from './Stage.js';
import { CoreRenderer } from './renderers/CoreRenderer.js';
import { mock } from 'vitest-mock-extended';
import type { TextRenderer } from './text-rendering/TextRenderer.js';
import { createBound } from './lib/utils.js';

describe('CoreTextNode', () => {
  let stage: Stage;
  let mockTextRenderer: TextRenderer;

  const defaultTextProps: CoreTextNodeProps = {
    // CoreNodeProps
    alpha: 1,
    autosize: false,
    boundsMargin: null,
    clipping: false,
    color: 0xffffffff,
    colorBl: 0xffffffff,
    colorBottom: 0xffffffff,
    colorBr: 0xffffffff,
    colorLeft: 0xffffffff,
    colorRight: 0xffffffff,
    colorTl: 0xffffffff,
    colorTop: 0xffffffff,
    colorTr: 0xffffffff,
    h: 0,
    mount: 0,
    mountX: 0,
    mountY: 0,
    parent: null,
    pivot: 0,
    pivotX: 0,
    pivotY: 0,
    rotation: 0,
    rtt: false,
    scale: 1,
    scaleX: 1,
    scaleY: 1,
    shader: null,
    src: '',
    texture: null,
    textureOptions: {},
    w: 0,
    x: 0,
    y: 0,
    zIndex: 0,
    // TrProps
    text: 'Test',
    textAlign: 'left',
    contain: 'none',
    fontFamily: 'Arial',
    fontStyle: 'normal',
    fontSize: 16,
    letterSpacing: 0,
    lineHeight: 1,
    maxHeight: 0,
    maxLines: 0,
    maxWidth: 0,
    offsetY: 0,
    overflowSuffix: '...',
    verticalAlign: 'top',
    wordBreak: 'break-word',
    // CoreTextNodeProps specific
    textRendererOverride: null,
    forceLoad: false,
  };

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

  beforeEach(() => {
    stage = mock<Stage>({
      strictBound: createBound(0, 0, 1920, 1080),
      preloadBound: createBound(0, 0, 1920, 1080),
      defaultTexture: {
        state: 'loaded',
      },
      renderer: mock<CoreRenderer>() as CoreRenderer,
    });

    // Mock text renderer with basic functionality
    mockTextRenderer = {
      type: 'sdf',
      font: {
        isFontLoaded: vi.fn().mockReturnValue(true),
        waitingForFont: vi.fn(),
        stopWaitingForFont: vi.fn(),
      },
      renderText: vi.fn().mockReturnValue({
        width: 100,
        height: 20,
        layout: { glyphs: [], width: 100, height: 20 },
      }),
      addQuads: vi.fn().mockReturnValue(new Float32Array(0)),
      renderQuads: vi.fn(),
    } as any;
  });

  describe('text property handling', () => {
    it('should normalize empty string and set node as non-renderable', () => {
      const node = new CoreTextNode(stage, defaultTextProps, mockTextRenderer);

      node.text = '';

      expect(node.text).toBe('');
      expect(node.isRenderable).toBe(false);
    });

    it('should normalize null to empty string and set node as non-renderable', () => {
      const node = new CoreTextNode(stage, defaultTextProps, mockTextRenderer);

      node.text = null as any;

      expect(node.text).toBe('');
      expect(node.isRenderable).toBe(false);
    });

    it('should normalize undefined to empty string and set node as non-renderable', () => {
      const node = new CoreTextNode(stage, defaultTextProps, mockTextRenderer);

      node.text = undefined as any;

      expect(node.text).toBe('');
      expect(node.isRenderable).toBe(false);
    });

    it('should convert non-string values to strings', () => {
      const node = new CoreTextNode(stage, defaultTextProps, mockTextRenderer);

      node.text = 123 as any;

      expect(node.text).toBe('123');
    });

    it('should handle valid text and allow node to be renderable', () => {
      const node = new CoreTextNode(stage, defaultTextProps, mockTextRenderer);

      node.text = 'Valid text content';

      expect(node.text).toBe('Valid text content');
      // Note: renderable state is determined during update cycle
    });

    it('should clear cached layout data when text is set to empty', () => {
      const props = { ...defaultTextProps, text: 'Initial text' };
      const node = new CoreTextNode(stage, props, mockTextRenderer);

      // Simulate some cached state
      node.text = '';

      expect(node.text).toBe('');
      expect(node.isRenderable).toBe(false);
    });

    it('should clear cached layout data when text is set to null', () => {
      const props = { ...defaultTextProps, text: 'Initial text' };
      const node = new CoreTextNode(stage, props, mockTextRenderer);

      node.text = null as any;

      expect(node.text).toBe('');
      expect(node.isRenderable).toBe(false);
    });

    it('should clear cached layout data when text is set to undefined', () => {
      const props = { ...defaultTextProps, text: 'Initial text' };
      const node = new CoreTextNode(stage, props, mockTextRenderer);

      node.text = undefined as any;

      expect(node.text).toBe('');
      expect(node.isRenderable).toBe(false);
    });

    it('should not call renderText during update when text is empty', () => {
      const props = { ...defaultTextProps, text: '', forceLoad: true };
      const node = new CoreTextNode(stage, props, mockTextRenderer);

      node.update(16, clippingRect);

      expect(mockTextRenderer.renderText).not.toHaveBeenCalled();
    });

    it('should not call renderText during update when text is null', () => {
      const props = { ...defaultTextProps, text: null as any, forceLoad: true };
      const node = new CoreTextNode(stage, props, mockTextRenderer);

      node.update(16, clippingRect);

      expect(mockTextRenderer.renderText).not.toHaveBeenCalled();
    });

    it('should transition from valid text to empty text correctly', () => {
      const props = { ...defaultTextProps, text: 'Valid text' };
      const node = new CoreTextNode(stage, props, mockTextRenderer);

      // Set to valid text first
      node.text = 'Some content';
      expect(node.text).toBe('Some content');

      // Then set to empty
      node.text = '';
      expect(node.text).toBe('');
      expect(node.isRenderable).toBe(false);
    });

    it('should transition from empty text to valid text correctly', () => {
      const props = { ...defaultTextProps, text: '' };
      const node = new CoreTextNode(stage, props, mockTextRenderer);

      expect(node.text).toBe('');
      expect(node.isRenderable).toBe(false);

      // Set to valid text
      node.text = 'New content';
      expect(node.text).toBe('New content');
    });

    it('should handle rapid text changes including null/undefined', () => {
      const props = { ...defaultTextProps, text: 'Initial' };
      const node = new CoreTextNode(stage, props, mockTextRenderer);

      node.text = 'First';
      expect(node.text).toBe('First');

      node.text = null as any;
      expect(node.text).toBe('');
      expect(node.isRenderable).toBe(false);

      node.text = 'Second';
      expect(node.text).toBe('Second');

      node.text = undefined as any;
      expect(node.text).toBe('');
      expect(node.isRenderable).toBe(false);

      node.text = 'Third';
      expect(node.text).toBe('Third');
    });
  });

  describe('updateIsRenderable with invalid text', () => {
    it('should mark node as non-renderable when text is empty', () => {
      const props = { ...defaultTextProps, text: '' };
      const node = new CoreTextNode(stage, props, mockTextRenderer);

      node.updateIsRenderable();

      expect(node.isRenderable).toBe(false);
    });

    it('should mark node as non-renderable even if layout exists when text is empty', () => {
      const props = { ...defaultTextProps, text: 'Valid' };
      const node = new CoreTextNode(stage, props, mockTextRenderer);

      // Change text to empty after potential layout generation
      node.text = '';
      node.updateIsRenderable();

      expect(node.isRenderable).toBe(false);
    });
  });

  describe('update cycle with invalid text', () => {
    it('should skip layout generation when text is empty', () => {
      const props = { ...defaultTextProps, text: '', forceLoad: true };
      const node = new CoreTextNode(stage, props, mockTextRenderer);

      node.update(16, clippingRect);

      expect(mockTextRenderer.renderText).not.toHaveBeenCalled();
      expect(node.isRenderable).toBe(false);
    });

    it('should skip layout generation when text becomes empty during update', () => {
      const props = { ...defaultTextProps, text: 'Valid', forceLoad: true };
      const node = new CoreTextNode(stage, props, mockTextRenderer);

      node.text = '';
      node.update(16, clippingRect);

      expect(node.isRenderable).toBe(false);
    });
  });

  function makeStageWithDeleteBuffer(deleteBuffer: ReturnType<typeof vi.fn>) {
    return mock<Stage>({
      strictBound: createBound(0, 0, 1920, 1080),
      preloadBound: createBound(0, 0, 1920, 1080),
      defaultTexture: { state: 'loaded' },
      renderer: { deleteBuffer } as unknown as CoreRenderer,
    });
  }

  function createSdfRenderInfo() {
    return {
      type: 'sdf' as const,
      width: 100,
      height: 20,
      atlasTexture: {} as any,
      layout: {
        glyphCount: 1,
        width: 100,
        height: 20,
        fontScale: 1,
        lineHeight: 20,
        fontFamily: 'Arial',
        distanceRange: 4,
        vertexBuffer: new Float32Array([0, 0, 0, 0, 10, 0, 1, 0]),
        truncatedTextLines: 0,
      },
      hasRemainingText: false,
      remainingLines: 0,
    };
  }

  describe('updateRenderState – SDF buffer release on OutOfBounds', () => {
    it('should call renderer.deleteBuffer and clear _sdfBuffer when transitioning to OutOfBounds', () => {
      const deleteBuffer = vi.fn();
      const node = new CoreTextNode(
        makeStageWithDeleteBuffer(deleteBuffer),
        defaultTextProps,
        mockTextRenderer,
      );

      const fakeBuffer = {};
      (node as any)._sdfBuffer = fakeBuffer;
      (node as any)._renderInfo = createSdfRenderInfo();

      node.updateRenderState(CoreNodeRenderState.OutOfBounds);

      expect(deleteBuffer).toHaveBeenCalledWith(fakeBuffer);
      expect((node as any)._sdfBuffer).toBeNull();
    });

    it('should not call renderer.deleteBuffer when _sdfBuffer is already null', () => {
      const deleteBuffer = vi.fn();
      const node = new CoreTextNode(
        makeStageWithDeleteBuffer(deleteBuffer),
        defaultTextProps,
        mockTextRenderer,
      );

      (node as any)._renderInfo = createSdfRenderInfo();
      node.updateRenderState(CoreNodeRenderState.OutOfBounds);

      expect(deleteBuffer).not.toHaveBeenCalled();
    });

    it('should not release the buffer when transitioning to InBounds', () => {
      const deleteBuffer = vi.fn();
      const node = new CoreTextNode(
        makeStageWithDeleteBuffer(deleteBuffer),
        defaultTextProps,
        mockTextRenderer,
      );

      const fakeBuffer = {};
      (node as any)._sdfBuffer = fakeBuffer;
      (node as any)._renderInfo = createSdfRenderInfo();

      node.updateRenderState(CoreNodeRenderState.InBounds);

      expect(deleteBuffer).not.toHaveBeenCalled();
      expect((node as any)._sdfBuffer).toBe(fakeBuffer);
    });

    it('should not release the buffer for a canvas-type text node', () => {
      const deleteBuffer = vi.fn();
      const canvasTextRenderer = {
        ...mockTextRenderer,
        type: 'canvas' as const,
      } as any;

      const node = new CoreTextNode(
        makeStageWithDeleteBuffer(deleteBuffer),
        defaultTextProps,
        canvasTextRenderer,
      );

      (node as any)._renderInfo = {
        type: 'canvas',
        width: 100,
        height: 20,
        imageData: {} as ImageData,
        hasRemainingText: false,
        remainingLines: 0,
      };

      node.updateRenderState(CoreNodeRenderState.OutOfBounds);

      expect(deleteBuffer).not.toHaveBeenCalled();
    });
  });

  describe('SDF buffer release on layout regeneration', () => {
    it('should call renderer.deleteBuffer before regenerating layout when font is already loaded', () => {
      const deleteBuffer = vi.fn();
      const props = { ...defaultTextProps, forceLoad: true };
      const node = new CoreTextNode(
        makeStageWithDeleteBuffer(deleteBuffer),
        props,
        mockTextRenderer,
      );

      const fakeBuffer = {} as WebGLBuffer;
      (node as any)._sdfBuffer = fakeBuffer;

      node.update(16, clippingRect);

      expect(deleteBuffer).toHaveBeenCalledWith(fakeBuffer);
      expect((node as any)._sdfBuffer).toBeNull();
    });

    it('should call renderer.deleteBuffer again on each subsequent layout regeneration', () => {
      const deleteBuffer = vi.fn();
      const props = { ...defaultTextProps, forceLoad: true };
      const node = new CoreTextNode(
        makeStageWithDeleteBuffer(deleteBuffer),
        props,
        mockTextRenderer,
      );

      node.update(16, clippingRect); // first layout – no buffer yet, no delete call
      expect(deleteBuffer).not.toHaveBeenCalled();

      // Trigger a second layout pass by invalidating the layout
      node.fontSize = 24;
      const secondBuffer = {} as WebGLBuffer;
      (node as any)._sdfBuffer = secondBuffer;

      node.update(16, clippingRect);

      expect(deleteBuffer).toHaveBeenCalledWith(secondBuffer);
      expect((node as any)._sdfBuffer).toBeNull();
    });

    it('should not call renderer.deleteBuffer when buffer is already null at regeneration time', () => {
      const deleteBuffer = vi.fn();
      const props = { ...defaultTextProps, forceLoad: true };
      const node = new CoreTextNode(
        makeStageWithDeleteBuffer(deleteBuffer),
        props,
        mockTextRenderer,
      );

      // _sdfBuffer is null by default
      node.update(16, clippingRect);

      expect(deleteBuffer).not.toHaveBeenCalled();
    });
  });

  describe('SDF buffer release when text becomes invalid', () => {
    it('should call renderer.deleteBuffer when text is cleared during update', () => {
      const deleteBuffer = vi.fn();
      const props = { ...defaultTextProps, text: 'Hello', forceLoad: true };
      const node = new CoreTextNode(
        makeStageWithDeleteBuffer(deleteBuffer),
        props,
        mockTextRenderer,
      );

      // Prime the node with a cached buffer
      const fakeBuffer = {} as WebGLBuffer;
      (node as any)._sdfBuffer = fakeBuffer;
      (node as any)._layoutGenerated = true;

      node.text = '';
      node.update(16, clippingRect);

      expect(deleteBuffer).toHaveBeenCalledWith(fakeBuffer);
      expect((node as any)._sdfBuffer).toBeNull();
    });

    it('should not call renderer.deleteBuffer when text is invalid and buffer is already null', () => {
      const deleteBuffer = vi.fn();
      const props = { ...defaultTextProps, text: '', forceLoad: true };
      const node = new CoreTextNode(
        makeStageWithDeleteBuffer(deleteBuffer),
        props,
        mockTextRenderer,
      );

      node.update(16, clippingRect);

      expect(deleteBuffer).not.toHaveBeenCalled();
    });

    it('should also clear _renderInfo when text becomes invalid', () => {
      const deleteBuffer = vi.fn();
      const props = { ...defaultTextProps, text: 'Hello', forceLoad: true };
      const node = new CoreTextNode(
        makeStageWithDeleteBuffer(deleteBuffer),
        props,
        mockTextRenderer,
      );

      (node as any)._renderInfo = createSdfRenderInfo();

      node.text = '';
      node.update(16, clippingRect);

      expect((node as any)._renderInfo).toBeNull();
    });
  });

  describe('SDF buffer release on destroy', () => {
    it('should call renderer.deleteBuffer on destroy when a buffer is held', () => {
      const deleteBuffer = vi.fn();
      const node = new CoreTextNode(
        makeStageWithDeleteBuffer(deleteBuffer),
        defaultTextProps,
        mockTextRenderer,
      );

      const fakeBuffer = {} as WebGLBuffer;
      (node as any)._sdfBuffer = fakeBuffer;

      node.destroy();

      expect(deleteBuffer).toHaveBeenCalledWith(fakeBuffer);
      expect((node as any)._sdfBuffer).toBeNull();
    });

    it('should not call renderer.deleteBuffer on destroy when buffer is already null', () => {
      const deleteBuffer = vi.fn();
      const node = new CoreTextNode(
        makeStageWithDeleteBuffer(deleteBuffer),
        defaultTextProps,
        mockTextRenderer,
      );

      // _sdfBufferRef.current is null by default
      node.destroy();

      expect(deleteBuffer).not.toHaveBeenCalled();
    });

    it('should clear _renderInfo on destroy', () => {
      const node = new CoreTextNode(stage, defaultTextProps, mockTextRenderer);

      (node as any)._renderInfo = createSdfRenderInfo();

      node.destroy();

      expect((node as any)._renderInfo).toBeNull();
    });
  });

  describe('SDF render path', () => {
    it('reuses the uploaded SDF buffer across renderQuads calls', () => {
      const createBuffer = vi.fn().mockReturnValue({ label: 'sdf-buffer' });
      const arrayBufferData = vi.fn();
      const glw = {
        createBuffer,
        arrayBufferData,
        STATIC_DRAW: 0x88e4,
        FLOAT: 0x1406,
      };
      const sdfStage = mock<Stage>({
        strictBound: createBound(0, 0, 1920, 1080),
        preloadBound: createBound(0, 0, 1920, 1080),
        defaultTexture: { state: 'loaded' },
        renderer: { glw } as unknown as CoreRenderer,
      });
      const node = new CoreTextNode(
        sdfStage,
        defaultTextProps,
        mockTextRenderer,
      );
      const transform = new Float32Array([1, 0, 0, 1, 0, 0]);

      (node as any).handleRenderResult(createSdfRenderInfo());
      (node as any).globalTransform = {
        getFloatArr: vi.fn().mockReturnValue(transform),
      };

      node.renderQuads(sdfStage.renderer);
      node.renderQuads(sdfStage.renderer);

      expect(createBuffer).toHaveBeenCalledTimes(1);
      expect(arrayBufferData).toHaveBeenCalledTimes(1);
      expect(mockTextRenderer.renderQuads).toHaveBeenCalledTimes(2);
      expect((node as any)._sdfBuffer).toEqual({ label: 'sdf-buffer' });
      expect(node.sdfShaderProps.transform).toBe(transform);
    });

    it('uses framebuffer-relative scissor coordinates for RTT draws', () => {
      const bindRenderOp = vi.fn();
      const useShader = vi.fn();
      const setScissorTest = vi.fn();
      const scissor = vi.fn();
      const drawArrays = vi.fn();
      const sdfStage = mock<Stage>({
        strictBound: createBound(0, 0, 1920, 1080),
        preloadBound: createBound(0, 0, 1920, 1080),
        defaultTexture: { state: 'loaded' },
        pixelRatio: 2,
        platform: { canvas: { width: 1920, height: 1080 } } as any,
        shManager: { useShader } as any,
      });
      const node = new CoreTextNode(
        sdfStage,
        defaultTextProps,
        mockTextRenderer,
      );
      const shader = { program: { bindRenderOp } };

      node.props.shader = shader as any;
      node.numQuads = 2;
      node.clippingRect = { x: 10, y: 20, w: 30, h: 40, valid: true };
      node.parentHasRenderTexture = true;
      node.rttParent = { framebufferDimensions: { w: 320, h: 180 } } as any;
      node.props.h = 25;

      node.draw({
        glw: {
          TRIANGLES: 4,
          setScissorTest,
          scissor,
          drawArrays,
        },
        stage: sdfStage,
      } as any);

      expect(useShader).toHaveBeenCalledWith(shader.program);
      expect(bindRenderOp).toHaveBeenCalledWith(node);
      expect(setScissorTest).toHaveBeenCalledWith(true);
      expect(scissor).toHaveBeenCalledWith(10, 155, 30, 40);
      expect(drawArrays).toHaveBeenCalledWith(4, 0, 12);
    });
  });
});
