import {useLayoutEffect, useState} from 'react';
import Fuse from 'fuse.js';
import {mat3, vec2} from 'gl-matrix';

import {CanvasView} from 'sentry/utils/profiling/canvasView';
import {FlamegraphFrame} from 'sentry/utils/profiling/flamegraphFrame';
import {FlamegraphRenderer} from 'sentry/utils/profiling/renderers/flamegraphRenderer';

import {CanvasPoolManager} from '../canvasScheduler';
import {clamp} from '../colors/utils';
import {FlamegraphCanvas} from '../flamegraphCanvas';
import {SpanChartRenderer2D} from '../renderers/spansRenderer';
import {SpanChartNode} from '../spanChart';
import {Rect} from '../speedscope';

export function createShader(
  gl: WebGLRenderingContext,
  type: WebGLRenderingContext['VERTEX_SHADER'] | WebGLRenderingContext['FRAGMENT_SHADER'],
  source: string
): WebGLShader {
  const shader = gl.createShader(type);
  if (!shader) {
    throw new Error('Could not create shader');
  }
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  const success = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
  if (success) {
    return shader;
  }

  gl.deleteShader(shader);
  throw new Error(`Failed to compile ${type} shader`);
}

export function createProgram(
  gl: WebGLRenderingContext,
  vertexShader: WebGLShader,
  fragmentShader: WebGLShader
): WebGLProgram {
  const program = gl.createProgram();
  if (!program) {
    throw new Error('Could not create program');
  }
  gl.attachShader(program, vertexShader);
  gl.attachShader(program, fragmentShader);
  gl.linkProgram(program);
  const success = gl.getProgramParameter(program, gl.LINK_STATUS);
  if (success) {
    return program;
  }

  gl.deleteProgram(program);
  throw new Error('Failed to create program');
}

export function getUniform(
  gl: WebGLRenderingContext,
  program: WebGLProgram,
  name: string
): WebGLUniformLocation {
  const uniform = gl.getUniformLocation(program, name);
  if (!uniform) {
    throw new Error(`Could not locate uniform ${name} in shader`);
  }
  return uniform;
}

export function getAttribute(
  gl: WebGLRenderingContext,
  program: WebGLProgram,
  name: string
): number {
  const attribute = gl.getAttribLocation(program, name);
  if (attribute === -1) {
    throw new Error(`Could not locate attribute ${name} in shader`);
  }
  return attribute;
}

export function createAndBindBuffer(
  gl: WebGLRenderingContext,
  data: ArrayBufferView,
  usage: number
): WebGLBuffer {
  const buffer = gl.createBuffer();
  if (!buffer) {
    throw new Error('Could not create buffer');
  }
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
  gl.bufferData(gl.ARRAY_BUFFER, data, usage);
  return buffer;
}

export function pointToAndEnableVertexAttribute(
  gl: WebGLRenderingContext,
  attribute: number,
  attributeInfo: {
    normalized: boolean;
    offset: number;
    size: number;
    stride: number;
    type: number;
  }
) {
  gl.vertexAttribPointer(
    attribute,
    attributeInfo.size,
    attributeInfo.type,
    attributeInfo.normalized,
    attributeInfo.stride,
    attributeInfo.offset
  );
  gl.enableVertexAttribArray(attribute);
}

// Create a projection matrix with origins at 0,0 in top left corner, scaled to width/height
export function makeProjectionMatrix(width: number, height: number): mat3 {
  const projectionMatrix = mat3.create();
  mat3.translate(projectionMatrix, projectionMatrix, vec2.fromValues(-1, 1));
  mat3.scale(
    projectionMatrix,
    projectionMatrix,
    vec2.divide(vec2.create(), vec2.fromValues(2, -2), vec2.fromValues(width, height))
  );

  return projectionMatrix;
}

const canvasToDisplaySizeMap = new Map<HTMLCanvasElement, [number, number]>();

function onResize(entries: ResizeObserverEntry[]) {
  for (const entry of entries) {
    let width;
    let height;
    let dpr = window.devicePixelRatio;
    if (entry.devicePixelContentBoxSize) {
      // NOTE: Only this path gives the correct answer
      // The other paths are imperfect fallbacks
      // for browsers that don't provide anyway to do this
      width = entry.devicePixelContentBoxSize[0].inlineSize;
      height = entry.devicePixelContentBoxSize[0].blockSize;
      dpr = 1; // it's already in width and height
    } else if (entry.contentBoxSize) {
      if (entry.contentBoxSize[0]) {
        width = entry.contentBoxSize[0].inlineSize;
        height = entry.contentBoxSize[0].blockSize;
      } else {
        // @ts-expect-error
        width = entry.contentBoxSize.inlineSize;
        // @ts-expect-error
        height = entry.contentBoxSize.blockSize;
      }
    } else {
      width = entry.contentRect.width;
      height = entry.contentRect.height;
    }
    const displayWidth = Math.round(width * dpr);
    const displayHeight = Math.round(height * dpr);
    canvasToDisplaySizeMap.set(entry.target as HTMLCanvasElement, [
      displayWidth,
      displayHeight,
    ]);

    resizeCanvasToDisplaySize(entry.target as HTMLCanvasElement);
  }
}

export const watchForResize = (
  canvas: HTMLCanvasElement[],
  callback?: (entries: ResizeObserverEntry[], observer: ResizeObserver) => void
): ResizeObserver => {
  const handler: ResizeObserverCallback = (entries, observer) => {
    onResize(entries);
    callback?.(entries, observer);
  };

  for (const c of canvas) {
    canvasToDisplaySizeMap.set(c, [c.width, c.height]);
  }

  const resizeObserver = new ResizeObserver(handler);

  try {
    // only call us of the number of device pixels changed
    canvas.forEach(c => {
      resizeObserver.observe(c, {box: 'device-pixel-content-box'});
    });
  } catch (ex) {
    // device-pixel-content-box is not supported so fallback to this
    canvas.forEach(c => {
      resizeObserver.observe(c, {box: 'content-box'});
    });
  }

  return resizeObserver;
};

export function resizeCanvasToDisplaySize(canvas: HTMLCanvasElement): boolean {
  // Get the size the browser is displaying the canvas in device pixels.
  const size = canvasToDisplaySizeMap.get(canvas);

  if (!size) {
    const displayWidth = canvas.clientWidth * window.devicePixelRatio;
    const displayHeight = canvas.clientHeight * window.devicePixelRatio;

    canvas.width = displayWidth;
    canvas.height = displayHeight;

    return false;
  }

  const [displayWidth, displayHeight] = size;
  // Check if the canvas is not the same size.
  const needResize = canvas.width !== displayWidth || canvas.height !== displayHeight;

  if (needResize) {
    // Make the canvas the same size
    canvas.width = displayWidth;
    canvas.height = displayHeight;
  }

  return needResize;
}

export function transformMatrixBetweenRect(from: Rect, to: Rect): mat3 {
  return mat3.fromValues(
    to.width / from.width,
    0,
    0,
    0,
    to.height / from.height,
    0,
    to.x - from.x * (to.width / from.width),
    to.y - from.y * (to.height / from.height),
    1
  );
}

function getContext(canvas: HTMLCanvasElement, context: '2d'): CanvasRenderingContext2D;
function getContext(canvas: HTMLCanvasElement, context: 'webgl'): WebGLRenderingContext;
function getContext(canvas: HTMLCanvasElement, context: string): RenderingContext {
  const ctx =
    context === 'webgl'
      ? canvas.getContext(context, {antialias: false})
      : canvas.getContext(context);
  if (!ctx) {
    throw new Error(`Could not get context ${context}`);
  }
  return ctx;
}

// Exported separately as writing export function for each overload as
// breaks the line width rules and makes it harder to read.
export {getContext};

export function measureText(string: string, ctx?: CanvasRenderingContext2D): Rect {
  if (!string) {
    return Rect.Empty();
  }

  const context = ctx || getContext(document.createElement('canvas'), '2d');
  const measures = context.measureText(string);

  return new Rect(
    0,
    0,
    measures.width,
    // https://stackoverflow.com/questions/1134586/how-can-you-find-the-height-of-text-on-an-html-canvas
    measures.actualBoundingBoxAscent + measures.actualBoundingBoxDescent
  );
}

/**
 * Returns first index of value in array where value.start < target
 * Example: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], target = 5, returns 4 which points to value 3
 * @param target {number}
 * @param values {Array<T> | ReadonlyArray<T>}
 * @returns number
 */
export function upperBound<T extends {end: number; start: number}>(
  target: number,
  values: Array<T> | ReadonlyArray<T>
) {
  let low = 0;
  let high = values.length;

  if (high === 0) {
    return 0;
  }

  if (high === 1) {
    return values[0].start < target ? 1 : 0;
  }

  while (low !== high) {
    const mid = low + Math.floor((high - low) / 2);

    if (values[mid].start < target) {
      low = mid + 1;
    } else {
      high = mid;
    }
  }

  return low;
}

/**
 * Returns first index of value in array where value.end < target
 * Example: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], target = 5, returns 3 which points to value 4
 * @param target {number}
 * @param values {Array<T> | ReadonlyArray<T>}
 * @returns number
 */
export function lowerBound<T extends {end: number; start: number}>(
  target: number,
  values: Array<T> | ReadonlyArray<T>
) {
  let low = 0;
  let high = values.length;

  if (high === 0) {
    return 0;
  }

  if (high === 1) {
    return values[0].end < target ? 1 : 0;
  }

  while (low !== high) {
    const mid = low + Math.floor((high - low) / 2);

    if (values[mid].end < target) {
      low = mid + 1;
    } else {
      high = mid;
    }
  }

  return low;
}

export function formatColorForSpan(
  frame: SpanChartNode,
  renderer: SpanChartRenderer2D
): string {
  const color = renderer.getColorForFrame(frame);
  if (Array.isArray(color)) {
    if (color.length === 4) {
      return `rgba(${color
        .slice(0, 3)
        .map(n => n * 255)
        .join(',')}, ${color[3]})`;
    }

    return `rgba(${color.map(n => n * 255).join(',')}, 1.0)`;
  }
  return '';
}

export function formatColorForFrame(
  frame: FlamegraphFrame,
  renderer: FlamegraphRenderer
): string {
  const color = renderer.getColorForFrame(frame);
  if (color.length === 4) {
    return `rgba(${color
      .slice(0, 3)
      .map(n => n * 255)
      .join(',')}, ${color[3]})`;
  }

  return `rgba(${color.map(n => n * 255).join(',')}, 1.0)`;
}

export const ELLIPSIS = '\u2026';
export interface TrimTextCenter {
  end: number;
  length: number;
  start: number;
  text: string;
}

// Utility function to compute a clamped view. This is essentially a bounds check
// to ensure that zoomed viewports stays in the bounds and does not escape the view.
export function computeClampedConfigView(
  newConfigView: Rect,
  {width, height}: {height: {max: number; min: number}; width: {max: number; min: number}}
) {
  if (!newConfigView.isValid()) {
    throw new Error(newConfigView.toString());
  }

  const clampedWidth = clamp(newConfigView.width, width.min, width.max);
  const clampedHeight = clamp(newConfigView.height, height.min, height.max);

  const maxX = width.max - clampedWidth;
  const maxY = Math.max(height.max - clampedHeight, 0);

  const clampedX = clamp(newConfigView.x, 0, maxX);
  const clampedY = clamp(newConfigView.y, 0, maxY);

  return new Rect(clampedX, clampedY, clampedWidth, clampedHeight);
}

/**
 * computeHighlightedBounds determines if a supplied boundary should be reduced in size
 * or shifted based on the results of a trim operation
 */
export function computeHighlightedBounds(
  bounds: Fuse.RangeTuple,
  trim: TrimTextCenter
): Fuse.RangeTuple {
  if (!trim.length) {
    return bounds;
  }

  const isStartBetweenTrim = bounds[0] >= trim.start && bounds[0] <= trim.end;
  const isEndBetweenTrim = bounds[1] >= trim.start && bounds[1] <= trim.end;
  const isFullyTruncated = isStartBetweenTrim && isEndBetweenTrim;

  // example:
  // -[UIScrollView _smoothScrollDisplayLink:]

  // "smooth" in "-[UIScrollView _…ScrollDisplayLink:]"
  //                              ^^
  if (isFullyTruncated) {
    return [trim.start, trim.start + 1];
  }

  if (bounds[0] < trim.start) {
    // "ScrollView" in '-[UIScrollView _sm…rollDisplayLink:]'
    //                      ^--------^
    if (bounds[1] < trim.start) {
      return [bounds[0], bounds[1]];
    }

    // "smoothScroll" in -[UIScrollView _smooth…DisplayLink:]'
    //                                   ^-----^
    if (isEndBetweenTrim) {
      return [bounds[0], trim.start + 1];
    }

    // "smoothScroll" in -[UIScrollView _sm…llDisplayLink:]'
    //                                   ^---^
    if (bounds[1] > trim.end) {
      return [bounds[0], bounds[1] - trim.length + 1];
    }
  }

  // "smoothScroll" in -[UIScrollView _…scrollDisplayLink:]'
  //                                   ^-----^
  if (isStartBetweenTrim && bounds[1] > trim.end) {
    return [trim.start, bounds[1] - trim.length + 1];
  }

  // "display" in -[UIScrollView _…scrollDisplayLink:]'
  //                                     ^-----^
  if (bounds[0] > trim.end) {
    return [bounds[0] - trim.length + 1, bounds[1] - trim.length + 1];
  }

  throw new Error(`Unhandled case: ${JSON.stringify(bounds)} ${trim}`);
}

// Utility function to allow zooming into frames using a specific strategy. Supports
// min zooming and exact strategy. Min zooming means we will zoom into a frame by doing
// the minimal number of moves to get a frame into view - for example, if the view is large
// enough and the frame we are zooming to is just outside of the viewport to the right,
// we will only move the viewport to the right until the frame is in view. Exact strategy
// means we will zoom into the frame by moving the viewport to the exact location of the frame
// and setting the width of the view to that of the frame.
export function computeConfigViewWithStrategy(
  strategy: 'min' | 'exact',
  view: Rect,
  frame: Rect
): Rect {
  if (strategy === 'exact') {
    return frame.withHeight(view.height);
  }

  if (strategy === 'min') {
    // If frame is in view, do nothing
    if (view.containsRect(frame)) {
      return view;
    }

    // If view width <= frame width, we need to zoom out, so the behavior is the
    // same as if we were using 'exact'
    if (view.width <= frame.width) {
      return frame.withHeight(view.height);
    }

    // If frame is to the left of the view, translate it left
    // to frame.x so that start of the frame is in the view
    let offset = view.clone();
    if (frame.left < view.left) {
      offset = offset.withX(frame.x);
    } else if (frame.right > view.right) {
      // If the right boundary of a frame is outside of the view, translate the view
      // by the difference between the right edge of the frame and the right edge of the view
      offset = view.withX(offset.x + frame.right - offset.right);
    }

    // If frame is above the view, translate view to top of frame
    if (frame.bottom < view.top) {
      offset = offset.withY(frame.top);
    } else if (frame.bottom > view.bottom) {
      // If frame is below the view, translate view by the difference
      // of the bottom edge of the frame and the view
      offset = offset.translateY(offset.y + frame.bottom - offset.bottom);
    }

    return offset;
  }

  return frame.withHeight(view.height);
}

export function computeMinZoomConfigViewForFrames(view: Rect, frames: Rect[]): Rect {
  if (!frames.length) {
    return view;
  }

  if (frames.length === 1) {
    return new Rect(frames[0].x, frames[0].y, frames[0].width, view.height);
  }

  const frame = frames.reduce(
    (min, f) => {
      return {
        x: Math.min(min.x, f.x),
        y: Math.min(min.y, f.y),
        right: Math.max(min.right, f.right),
        bottom: 0,
      };
    },
    {x: Number.MAX_SAFE_INTEGER, y: Number.MAX_SAFE_INTEGER, right: 0, bottom: 0}
  );

  return computeConfigViewWithStrategy(
    'exact',
    view,
    new Rect(frame.x, frame.y, frame.right - frame.x, view.height)
  );
}

// Compute the X and Y position based on offset and canvas resolution
export function getPhysicalSpacePositionFromOffset(offsetX: number, offsetY: number) {
  const logicalMousePos = vec2.fromValues(offsetX, offsetY);
  return vec2.scale(vec2.create(), logicalMousePos, window.devicePixelRatio);
}

export function getCenterScaleMatrixFromConfigPosition(scale: vec2, center: vec2) {
  const invertedConfigCenter = vec2.fromValues(-center[0], -center[1]);

  const centerScaleMatrix = mat3.create();
  mat3.fromTranslation(centerScaleMatrix, center);
  mat3.scale(centerScaleMatrix, centerScaleMatrix, scale);
  mat3.translate(centerScaleMatrix, centerScaleMatrix, invertedConfigCenter);
  return centerScaleMatrix;
}

// Translates the offsetX and offsetY into a config space position to find the center
// and apply the scaling transformation from there
export function getCenterScaleMatrixFromMousePosition(
  scale: number,
  cursor: vec2,
  view: CanvasView<any>,
  canvas: FlamegraphCanvas
): mat3 {
  const configSpaceMouse = view.getConfigViewCursor(cursor, canvas);

  const configCenter = vec2.fromValues(configSpaceMouse[0], view.configView.y);
  return getCenterScaleMatrixFromConfigPosition(vec2.fromValues(scale, 1), configCenter);
}

export function getTranslationMatrixFromConfigSpace(deltaX: number, deltaY: number) {
  const configDelta = vec2.fromValues(deltaX, deltaY);
  return mat3.fromTranslation(mat3.create(), configDelta);
}

// Translates the offsetX and offsetY into a config space units and return a translation
// matrix that can be applied to the view
export function getTranslationMatrixFromPhysicalSpace(
  deltaX: number,
  deltaY: number,
  view: CanvasView<any>,
  canvas: FlamegraphCanvas,
  multiplierX: number = 0.8,
  multiplierY: number = 1
) {
  const physicalDelta = vec2.fromValues(deltaX * multiplierX, deltaY * multiplierY);
  const physicalToConfig = mat3.invert(
    mat3.create(),
    view.fromConfigView(canvas.physicalSpace)
  );
  const [m00, m01, m02, m10, m11, m12] = physicalToConfig;

  const configDelta = vec2.transformMat3(vec2.create(), physicalDelta, [
    m00,
    m01,
    m02,
    m10,
    m11,
    m12,
    0,
    0,
    0,
  ]);

  return getTranslationMatrixFromConfigSpace(configDelta[0], configDelta[1]);
}

export function getConfigViewTranslationBetweenVectors(
  offsetX: number,
  offsetY: number,
  start: vec2,
  view: CanvasView<any>,
  canvas: FlamegraphCanvas,
  invert?: boolean
): mat3 | null {
  const physicalMousePos = getPhysicalSpacePositionFromOffset(offsetX, offsetY);
  const physicalDelta = invert
    ? vec2.subtract(vec2.create(), physicalMousePos, start)
    : vec2.subtract(vec2.create(), start, physicalMousePos);

  if (physicalDelta[0] === 0 && physicalDelta[1] === 0) {
    return null;
  }

  const physicalToConfig = mat3.invert(
    mat3.create(),
    view.fromConfigView(canvas.physicalSpace)
  );
  const [m00, m01, m02, m10, m11, m12] = physicalToConfig;

  const configDelta = vec2.transformMat3(vec2.create(), physicalDelta, [
    m00,
    m01,
    m02,
    m10,
    m11,
    m12,
    0,
    0,
    0,
  ]);

  return mat3.fromTranslation(mat3.create(), configDelta);
}

export function getConfigSpaceTranslationBetweenVectors(
  offsetX: number,
  offsetY: number,
  start: vec2,
  view: CanvasView<any>,
  canvas: FlamegraphCanvas,
  invert?: boolean
): mat3 | null {
  const physicalMousePos = getPhysicalSpacePositionFromOffset(offsetX, offsetY);
  const physicalDelta = invert
    ? vec2.subtract(vec2.create(), physicalMousePos, start)
    : vec2.subtract(vec2.create(), start, physicalMousePos);

  if (physicalDelta[0] === 0 && physicalDelta[1] === 0) {
    return null;
  }

  const physicalToConfig = mat3.invert(
    mat3.create(),
    view.fromConfigSpace(canvas.physicalSpace)
  );
  const [m00, m01, m02, m10, m11, m12] = physicalToConfig;
  const configDelta = vec2.transformMat3(vec2.create(), physicalDelta, [
    m00,
    m01,
    m02,
    m10,
    m11,
    m12,
    0,
    0,
    0,
  ]);

  return mat3.fromTranslation(mat3.create(), configDelta);
}

export function getMinimapCanvasCursor(
  configView: Rect | undefined,
  configSpaceCursor: vec2 | null,
  borderWidth: number
) {
  if (!configView || !configSpaceCursor) {
    return 'col-resize';
  }

  const nearestEdge = Math.min(
    Math.abs(configView.left - configSpaceCursor[0]),
    Math.abs(configView.right - configSpaceCursor[0])
  );
  const isWithinBorderSize = nearestEdge <= borderWidth;
  if (isWithinBorderSize) {
    return 'ew-resize';
  }

  if (configView.contains(configSpaceCursor)) {
    return 'grab';
  }

  return 'col-resize';
}

export function useResizeCanvasObserver(
  canvases: (HTMLCanvasElement | null)[],
  canvasPoolManager: CanvasPoolManager,
  canvas: FlamegraphCanvas | null,
  view: CanvasView<any> | null
): Rect {
  const [bounds, setCanvasBounds] = useState<Rect>(Rect.Empty());

  useLayoutEffect(() => {
    if (!canvas || !canvases.length) {
      return undefined;
    }

    if (canvases.some(c => c === null)) {
      return undefined;
    }

    const observer = watchForResize(canvases as HTMLCanvasElement[], entries => {
      const contentRect =
        entries[0].contentRect ?? entries[0].target.getBoundingClientRect();

      setCanvasBounds(
        new Rect(contentRect.x, contentRect.y, contentRect.width, contentRect.height)
      );

      canvas.initPhysicalSpace();
      if (view) {
        view.resizeConfigSpace(canvas);
      }
      canvasPoolManager.drawSync();
    });

    return () => {
      observer.disconnect();
    };
  }, [canvases, canvas, view, canvasPoolManager]);

  return bounds;
}
