// Copyright (C) 2024 The Android Open Source Project
//
// 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 {type Point2D, Vector2D} from './geom';
import {assertUnreachable} from './assert';

export type CardinalDirection = 'north' | 'south' | 'east' | 'west';

export type ArrowHeadOrientation =
  | CardinalDirection
  | 'auto_vertical' // Either north or south depending on the location of the other end of the arrow
  | 'auto_horizontal' // Either east or west depending on the location of the other end of the arrow
  | 'auto'; // Choose the closest cardinal direction depending on the location of the other end of the arrow

export type ArrowHeadShape = 'none' | 'triangle' | 'circle';

export interface ArrowHeadStyle {
  orientation: ArrowHeadOrientation;
  shape: ArrowHeadShape;
  size?: number;
}

/**
 * Renders an curved arrow using a bezier curve.
 *
 * This arrow is comprised of a line and the arrow caps are filled shapes, so
 * the arrow's colour and width will be dictated by the current canvas
 * strokeStyle, lineWidth, and fillStyle, so adjust these accordingly before
 * calling this function.
 *
 * @param ctx - The canvas to draw on.
 * @param start - Start point of the arrow.
 * @param end - End point of the arrow.
 * @param controlPointOffset - The distance in pixels of the control points from
 * the start and end points, in the direction of the start and end orientation
 * values above.
 * @param startStyle - The style of the start of the arrow.
 * @param endStyle - The style of the end of the arrow.
 */
export function drawBezierArrow(
  ctx: CanvasRenderingContext2D,
  start: Point2D,
  end: Point2D,
  controlPointOffset: number = 30,
  startStyle: ArrowHeadStyle = {
    shape: 'none',
    orientation: 'auto',
  },
  endStyle: ArrowHeadStyle = {
    shape: 'none',
    orientation: 'auto',
  },
): void {
  const startOri = getOri(start, end, startStyle.orientation);
  const endOri = getOri(end, start, endStyle.orientation);

  const startRetreat = drawArrowEnd(ctx, start, startOri, startStyle);
  const endRetreat = drawArrowEnd(ctx, end, endOri, endStyle);

  const startRetreatVec = orientationToUnitVector(startOri).scale(startRetreat);
  const endRetreatVec = orientationToUnitVector(endOri).scale(endRetreat);

  const startVec = new Vector2D(start).add(startRetreatVec);
  const endVec = new Vector2D(end).add(endRetreatVec);

  const startOffset =
    orientationToUnitVector(startOri).scale(controlPointOffset);
  const endOffset = orientationToUnitVector(endOri).scale(controlPointOffset);

  const cp1 = startVec.add(startOffset);
  const cp2 = endVec.add(endOffset);

  ctx.beginPath();
  ctx.moveTo(startVec.x, startVec.y);
  ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, endVec.x, endVec.y);
  ctx.stroke();
}

function getOri(
  pos: Point2D,
  other: Point2D,
  ori: ArrowHeadOrientation,
): CardinalDirection {
  switch (ori) {
    case 'auto_vertical':
      return other.y > pos.y ? 'south' : 'north';
    case 'auto_horizontal':
      return other.x > pos.x ? 'east' : 'west';
    case 'auto':
      const verticalDelta = Math.abs(other.y - pos.y);
      const horizontalDelta = Math.abs(other.x - pos.x);
      if (verticalDelta > horizontalDelta) {
        return other.y > pos.y ? 'south' : 'north';
      } else {
        return other.x > pos.x ? 'east' : 'west';
      }
    default:
      return ori;
  }
}

function drawArrowEnd(
  ctx: CanvasRenderingContext2D,
  pos: Point2D,
  orientation: CardinalDirection,
  style: ArrowHeadStyle,
): number {
  switch (style.shape) {
    case 'triangle':
      const size = style.size ?? 5;
      drawTriangle(ctx, pos, orientation, size);
      return size;
    case 'circle':
      drawCircle(ctx, pos, style.size ?? 3);
      return 0;
    case 'none':
      return 0;
    default:
      assertUnreachable(style.shape);
  }
}

function orientationToAngle(orientation: CardinalDirection): number {
  switch (orientation) {
    case 'north':
      return 0;
    case 'east':
      return Math.PI / 2;
    case 'south':
      return Math.PI;
    case 'west':
      return (3 * Math.PI) / 2;
    default:
      assertUnreachable(orientation);
  }
}

function orientationToUnitVector(orientation: CardinalDirection): Vector2D {
  switch (orientation) {
    case 'north':
      return new Vector2D({x: 0, y: -1});
    case 'east':
      return new Vector2D({x: 1, y: 0});
    case 'south':
      return new Vector2D({x: 0, y: 1});
    case 'west':
      return new Vector2D({x: -1, y: 0});
    default:
      assertUnreachable(orientation);
  }
}

function drawTriangle(
  ctx: CanvasRenderingContext2D,
  pos: Point2D,
  orientation: CardinalDirection,
  size: number,
) {
  // Calculate the transformed coordinates directly
  const angle = orientationToAngle(orientation);
  const cosAngle = Math.cos(angle);
  const sinAngle = Math.sin(angle);

  const transformedPoints = [
    {x: 0, y: 0},
    {x: -1, y: -1},
    {x: 1, y: -1},
  ].map((point) => {
    const scaledX = point.x * size;
    const scaledY = point.y * size;
    const rotatedX = scaledX * cosAngle - scaledY * sinAngle;
    const rotatedY = scaledX * sinAngle + scaledY * cosAngle;
    return {
      x: rotatedX + pos.x,
      y: rotatedY + pos.y,
    };
  });

  ctx.beginPath();
  ctx.moveTo(transformedPoints[0].x, transformedPoints[0].y);
  ctx.lineTo(transformedPoints[1].x, transformedPoints[1].y);
  ctx.lineTo(transformedPoints[2].x, transformedPoints[2].y);
  ctx.closePath();
  ctx.fill();
}

function drawCircle(
  ctx: CanvasRenderingContext2D,
  pos: Point2D,
  radius: number,
) {
  ctx.beginPath();
  ctx.arc(pos.x, pos.y, radius, 0, 2 * Math.PI);
  ctx.closePath();
  ctx.fill();
}
