/*
 * 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 { EventEmitter } from '../../../common/EventEmitter.js';
import { assertTruthy } from '../../../utils.js';
import type { CoreNode } from '../../CoreNode.js';
import type { CoreTextNode } from '../../CoreTextNode.js';
import type { Stage } from '../../Stage.js';
import {
  getNormalizedRgbaComponents,
  getNormalizedAlphaComponent,
} from '../../lib/utils.js';
import { type FontFamilyMap } from '../TrFontManager.js';
import type { TrFontFace } from '../font-face-types/TrFontFace.js';
import { WebTrFontFace } from '../font-face-types/WebTrFontFace.js';
import {
  LightningTextTextureRenderer,
  type RenderInfo,
} from './LightningTextTextureRenderer.js';
import {
  TextRenderer,
  type TextRendererState,
  type TrFontProps,
  type TrPropSetters,
  type TrProps,
} from './TextRenderer.js';

const resolvedGlobal = typeof self === 'undefined' ? globalThis : self;

/**
 * Global font set regardless of if run in the main thread or a web worker
 */
const globalFontSet: FontFaceSet = (resolvedGlobal.document?.fonts ||
  (resolvedGlobal as any).fonts) as FontFaceSet;

declare module './TextRenderer.js' {
  interface TextRendererMap {
    canvas: CanvasTextRenderer;
  }
}

function getFontCssString(props: TrProps): string {
  const { fontFamily, fontStyle, fontWeight, fontStretch, fontSize } = props;
  return [fontStyle, fontWeight, fontStretch, `${fontSize}px`, fontFamily].join(
    ' ',
  );
}

export interface CanvasTextRendererState extends TextRendererState {
  node: CoreTextNode;
  props: TrProps;
  fontInfo:
    | {
        fontFace: WebTrFontFace;
        cssString: string;
        loaded: boolean;
      }
    | undefined;
  textureNode: CoreNode | undefined;
  lightning2TextRenderer: LightningTextTextureRenderer;
  renderInfo: RenderInfo | undefined;
}

export class CanvasTextRenderer extends TextRenderer<CanvasTextRendererState> {
  protected canvas: OffscreenCanvas | HTMLCanvasElement;
  protected context:
    | OffscreenCanvasRenderingContext2D
    | CanvasRenderingContext2D;
  /**
   * Font family map used to store web font faces that were added to the
   * canvas text renderer.
   */
  private fontFamilies: FontFamilyMap = {};
  private fontFamilyArray: FontFamilyMap[] = [this.fontFamilies];

  public type: 'canvas' | 'sdf' = 'canvas';

  constructor(stage: Stage) {
    super(stage);
    if (typeof OffscreenCanvas !== 'undefined') {
      this.canvas = new OffscreenCanvas(0, 0);
    } else {
      this.canvas = document.createElement('canvas');
    }

    let context = this.canvas.getContext('2d', {
      willReadFrequently: true,
    }) as OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D | null;
    if (!context) {
      // A browser may appear to support OffscreenCanvas but not actually support the Canvas '2d' context
      // Here we try getting the context again after falling back to an HTMLCanvasElement.
      // See: https://github.com/lightning-js/renderer/issues/26#issuecomment-1750438486
      this.canvas = document.createElement('canvas');
      context = this.canvas.getContext('2d', {
        willReadFrequently: true,
      });
    }
    assertTruthy(context);
    this.context = context;

    // Install the default 'san-serif' font face
    this.addFontFace(
      new WebTrFontFace({
        fontFamily: 'sans-serif',
        descriptors: {},
        fontUrl: '',
      }),
    );
  }

  //#region Overrides
  override getPropertySetters(): Partial<
    TrPropSetters<CanvasTextRendererState>
  > {
    return {
      fontFamily: (state, value) => {
        state.props.fontFamily = value;
        state.fontInfo = undefined;
        this.invalidateLayoutCache(state);
      },
      fontWeight: (state, value) => {
        state.props.fontWeight = value;
        state.fontInfo = undefined;
        this.invalidateLayoutCache(state);
      },
      fontStyle: (state, value) => {
        state.props.fontStyle = value;
        state.fontInfo = undefined;
        this.invalidateLayoutCache(state);
      },
      fontStretch: (state, value) => {
        state.props.fontStretch = value;
        state.fontInfo = undefined;
        this.invalidateLayoutCache(state);
      },
      fontSize: (state, value) => {
        state.props.fontSize = value;
        state.fontInfo = undefined;
        this.invalidateLayoutCache(state);
      },
      text: (state, value) => {
        state.props.text = value;
        this.invalidateLayoutCache(state);
      },
      textAlign: (state, value) => {
        state.props.textAlign = value;
        this.invalidateLayoutCache(state);
      },
      color: (state, value) => {
        state.props.color = value;
        this.invalidateLayoutCache(state);
      },
      x: (state, value) => {
        state.props.x = value;
      },
      y: (state, value) => {
        state.props.y = value;
      },
      contain: (state, value) => {
        state.props.contain = value;
        this.invalidateLayoutCache(state);
      },
      width: (state, value) => {
        state.props.width = value;
        // Only invalidate layout cache if we're containing in the horizontal direction
        if (state.props.contain !== 'none') {
          this.invalidateLayoutCache(state);
        }
      },
      height: (state, value) => {
        state.props.height = value;
        // Only invalidate layout cache if we're containing in the vertical direction
        if (state.props.contain === 'both') {
          this.invalidateLayoutCache(state);
        }
      },
      offsetY: (state, value) => {
        state.props.offsetY = value;
        this.invalidateLayoutCache(state);
      },
      scrollY: (state, value) => {
        state.props.scrollY = value;
      },
      letterSpacing: (state, value) => {
        state.props.letterSpacing = value;
        this.invalidateLayoutCache(state);
      },
      lineHeight: (state, value) => {
        state.props.lineHeight = value;
        this.invalidateLayoutCache(state);
      },
      maxLines: (state, value) => {
        state.props.maxLines = value;
        this.invalidateLayoutCache(state);
      },
      textBaseline: (state, value) => {
        state.props.textBaseline = value;
        this.invalidateLayoutCache(state);
      },
      verticalAlign: (state, value) => {
        state.props.verticalAlign = value;
        this.invalidateLayoutCache(state);
      },
      wordBreak: (state, value) => {
        state.props.wordBreak = value;
        this.invalidateLayoutCache(state);
      },
      overflowSuffix: (state, value) => {
        state.props.overflowSuffix = value;
        this.invalidateLayoutCache(state);
      },
    };
  }

  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  override canRenderFont(props: TrFontProps): boolean {
    // The canvas renderer can render any font because it automatically
    // falls back to system fonts. The CanvasTextRenderer should be
    // checked last if other renderers are preferred.
    return true;
  }

  override isFontFaceSupported(fontFace: TrFontFace): boolean {
    return fontFace instanceof WebTrFontFace;
  }

  override addFontFace(fontFace: TrFontFace): void {
    // Make sure the font face is an Canvas font face (it should have already passed
    // the `isFontFaceSupported` check)
    assertTruthy(fontFace instanceof WebTrFontFace);

    const fontFamily = fontFace.fontFamily;

    // Add the font face to the document
    // Except for the 'sans-serif' font family, which the Renderer provides
    // as a special default fallback.
    if (fontFamily !== 'sans-serif') {
      // @ts-expect-error `add()` method should be available from a FontFaceSet
      // eslint-disable-next-line @typescript-eslint/no-unsafe-call
      globalFontSet.add(fontFace.fontFace);
    }

    let faceSet = this.fontFamilies[fontFamily];
    if (!faceSet) {
      faceSet = new Set();
      this.fontFamilies[fontFamily] = faceSet;
    }
    faceSet.add(fontFace);
  }

  override createState(
    props: TrProps,
    node: CoreTextNode,
  ): CanvasTextRendererState {
    return {
      node,
      props,
      status: 'initialState',
      updateScheduled: false,
      emitter: new EventEmitter(),
      textureNode: undefined,
      lightning2TextRenderer: new LightningTextTextureRenderer(
        this.canvas,
        this.context,
      ),
      renderInfo: undefined,
      forceFullLayoutCalc: false,
      textW: 0,
      textH: 0,
      fontInfo: undefined,
      isRenderable: false,
      debugData: {
        updateCount: 0,
        layoutCount: 0,
        drawCount: 0,
        lastLayoutNumCharacters: 0,
        layoutSum: 0,
        drawSum: 0,
        bufferSize: 0,
      },
    };
  }

  override updateState(state: CanvasTextRendererState): void {
    // On the first update call we need to set the status to loading
    if (state.status === 'initialState') {
      this.setStatus(state, 'loading');
      // check if we're on screen
      // if (this.isValidOnScreen(state) === true) {
      //   this.setStatus(state, 'loading');
      // }
    }

    if (state.status === 'loaded') {
      // If we're loaded, we don't need to do anything
      return;
    }

    // If fontInfo is invalid, we need to establish it
    if (!state.fontInfo) {
      return this.loadFont(state);
    }

    // If we're waiting for a font face to load, don't render anything
    if (!state.fontInfo.loaded) {
      return;
    }

    if (!state.renderInfo) {
      state.renderInfo = this.calculateRenderInfo(state);
      state.textH = state.renderInfo.lineHeight * state.renderInfo.lines.length;
      state.textW = state.renderInfo.width;
      this.renderSingleCanvasPage(state);
    }

    // handle scrollable text !!!
    // if (state.isScrollable === true) {
    //   return this.renderScrollableCanvasPages(state);
    // }

    // handle single page text
  }

  renderSingleCanvasPage(state: CanvasTextRendererState): void {
    assertTruthy(state.renderInfo);
    const node = state.node;

    const texture = this.stage.txManager.createTexture('ImageTexture', {
      premultiplyAlpha: true,
      src: function (
        this: CanvasTextRenderer,
        lightning2TextRenderer: LightningTextTextureRenderer,
        renderInfo: RenderInfo,
      ) {
        // load the canvas texture
        assertTruthy(renderInfo);
        lightning2TextRenderer.draw(renderInfo, {
          lines: renderInfo.lines,
          lineWidths: renderInfo.lineWidths,
        });
        if (this.canvas.width === 0 || this.canvas.height === 0) {
          return null;
        }
        return this.context.getImageData(
          0,
          0,
          this.canvas.width,
          this.canvas.height,
        );
      }.bind(this, state.lightning2TextRenderer, state.renderInfo),
    });

    if (state.textureNode) {
      // Use the existing texture node
      state.textureNode.texture = texture;
      // Update the alpha
      state.textureNode.alpha = getNormalizedAlphaComponent(state.props.color);
    } else {
      // Create a new texture node
      const textureNode = this.stage.createNode({
        parent: node,
        texture,
        autosize: true,
        // The alpha channel of the color is ignored when rasterizing the text
        // texture so we need to pass it directly to the texture node.
        alpha: getNormalizedAlphaComponent(state.props.color),
      });
      state.textureNode = textureNode;
    }

    this.setStatus(state, 'loaded');
  }

  loadFont = (state: CanvasTextRendererState): void => {
    const cssString = getFontCssString(state.props);

    const trFontFace = this.stage.fontManager.resolveFontFace(
      this.fontFamilyArray,
      state.props,
      'canvas',
    ) as WebTrFontFace | undefined;
    assertTruthy(trFontFace, `Could not resolve font face for ${cssString}`);
    state.fontInfo = {
      fontFace: trFontFace,
      cssString: cssString,
      // TODO: For efficiency we would use this here but it's not reliable on WPE -> document.fonts.check(cssString),
      loaded: false,
    };
    // If font is not loaded, set up a handler to update the font info when the font loads
    if (!state.fontInfo.loaded) {
      globalFontSet
        .load(cssString)
        .then(this.onFontLoaded.bind(this, state, cssString))
        .catch(this.onFontLoadError.bind(this, state, cssString));
      return;
    }
  };

  calculateRenderInfo(state: CanvasTextRendererState): RenderInfo {
    state.lightning2TextRenderer.settings = {
      text: state.props.text,
      textAlign: state.props.textAlign,
      fontFamily: state.props.fontFamily,
      trFontFace: state.fontInfo?.fontFace,
      fontSize: state.props.fontSize,
      fontStyle: [
        state.props.fontStretch,
        state.props.fontStyle,
        state.props.fontWeight,
      ].join(' '),
      textColor: getNormalizedRgbaComponents(state.props.color),
      offsetY: state.props.offsetY,
      wordWrap: state.props.contain !== 'none',
      wordWrapWidth:
        state.props.contain === 'none' ? undefined : state.props.width,
      letterSpacing: state.props.letterSpacing,
      lineHeight: state.props.lineHeight ?? null,
      maxLines: state.props.maxLines,
      maxHeight:
        state.props.contain === 'both'
          ? state.props.height - state.props.offsetY
          : null,
      textBaseline: state.props.textBaseline,
      verticalAlign: state.props.verticalAlign,
      overflowSuffix: state.props.overflowSuffix,
      wordBreak: state.props.wordBreak,
      w: state.props.contain !== 'none' ? state.props.width : undefined,
    };
    state.renderInfo = state.lightning2TextRenderer.calculateRenderInfo();
    return state.renderInfo;
  }

  override renderQuads(): void {
    // Do nothing. The renderer will render the child node(s) that were created
    // in the state update.
    return;
  }

  override destroyState(state: CanvasTextRendererState): void {
    if (state.status === 'destroyed') {
      return;
    }
    super.destroyState(state);

    if (state.textureNode) {
      state.textureNode.destroy();
      delete state.textureNode;
    }
    delete state.renderInfo;
  }
  //#endregion Overrides

  /**
   * Invalidate the layout cache stored in the state. This will cause the text
   * to be re-rendered on the next update.
   *
   * @remarks
   * This also invalidates the visible window cache.
   *
   * @param state
   */
  private invalidateLayoutCache(state: CanvasTextRendererState): void {
    state.renderInfo = undefined;
    this.setStatus(state, 'loading');
    this.scheduleUpdateState(state);
  }

  private onFontLoaded(
    state: CanvasTextRendererState,
    cssString: string,
  ): void {
    if (cssString !== state.fontInfo?.cssString || !state.fontInfo) {
      return;
    }
    state.fontInfo.loaded = true;
    this.scheduleUpdateState(state);
  }

  private onFontLoadError(
    state: CanvasTextRendererState,
    cssString: string,
    error: Error,
  ): void {
    if (cssString !== state.fontInfo?.cssString || !state.fontInfo) {
      return;
    }

    // Font didn't actually load, but we'll log the error and mark it as loaded
    // because the browser can still render with a fallback font.
    state.fontInfo.loaded = true;

    console.error(
      `CanvasTextRenderer: Error loading font '${state.fontInfo.cssString}'`,
      error,
    );
    this.scheduleUpdateState(state);
  }
}
