import {
  contrastRatio,
  contrastRatioAPCA,
  getAPCAThreshold,
  getContrastThreshold,
} from "./ColorUtils"
import type { Bounds, PathCommands, ResetData } from "./common"
import {
  Overlay,
  constrainNumber,
  createChild,
  createElement,
  createTextChild,
  ellipsify,
} from "./common"
import type { PathBounds } from "./highlightCommon"
import {
  drawPath,
  emptyBounds,
  formatColor,
  formatRgba,
  parseHexa,
} from "./highlightCommon"

export interface Path {
  path: PathCommands
  outlineColor?: string
  fillColor: string
  name: string
}

interface ContrastInfo {
  backgroundColor: string
  fontSize: string
  fontWeight: string
  contrastAlgorithm: "apca" | "aa" | "aaa"
  textOpacity: number
}

export interface ElementInfo {
  contrast?: ContrastInfo
  tagName: string
  idValue?: string
  className?: string
  nodeWidth: number
  nodeHeight: number
  isLocked?: boolean
  isLockedAncestor?: boolean
  style?: { [key: string]: string | undefined }
  showAccessibilityInfo?: boolean
  isKeyboardFocusable?: boolean
  accessibleName?: string
  accessibleRole?: string
  layoutObjectName?: string
}

export interface Highlight {
  paths: Path[]
  /** Whether the rulers should be shown. */
  showRulers: boolean
  /** Whether the extension lines from node to the rulers should be shown. */
  showExtensionLines: boolean
  elementInfo?: ElementInfo
  /** The color format used to format color styles. */
  colorFormat: string
}

export class HighlightOverlay extends Overlay {
  private tooltip!: HTMLElement
  private gridLabelState = { gridLayerCounter: 0 }
  #container!: HTMLElement

  setContainer(container: HTMLElement) {
    this.#container = container
  }

  override setPlatform(platform: string) {
    if (this.container) {
      this.container.classList.add("platform-" + platform)
    }

    super.setPlatform(platform)
  }

  get container(): HTMLElement {
    return this.#container
  }

  override reset(resetData: ResetData) {
    super.reset(resetData)
    this.tooltip.innerHTML = ""
    this.gridLabelState.gridLayerCounter = 0
  }

  override install() {
    const canvas = this.document.createElement("canvas")
    this.container.appendChild(canvas)

    const tooltip = this.document.createElement("div")
    this.container.appendChild(tooltip)
    this.tooltip = tooltip

    this.setCanvas(canvas)

    super.install()
  }

  override uninstall() {
    this.document.body.classList.remove("fill")
    this.document.body.innerHTML = ""

    super.uninstall()
  }

  drawHighlight(highlight: Highlight) {
    this.context.save()

    const bounds = emptyBounds()

    for (let paths = highlight.paths.slice(); paths.length; ) {
      const path = paths.pop()
      if (!path) {
        continue
      }
      this.context.save()
      drawPath(
        this.context,
        path.path,
        path.fillColor,
        path.outlineColor,
        undefined,
        bounds,
        this.emulationScaleFactor,
      )
      if (paths.length) {
        this.context.globalCompositeOperation = "destination-out"
        drawPath(
          this.context,
          paths[paths.length - 1].path,
          "red",
          undefined,
          undefined,
          bounds,
          this.emulationScaleFactor,
        )
      }
      this.context.restore()
    }
    this.context.restore()

    this.context.save()

    const rulerAtRight = Boolean(
      highlight.paths.length &&
        highlight.showRulers &&
        bounds.minX < 20 &&
        bounds.maxX + 20 < this.canvasWidth,
    )
    const rulerAtBottom = Boolean(
      highlight.paths.length &&
        highlight.showRulers &&
        bounds.minY < 20 &&
        bounds.maxY + 20 < this.canvasHeight,
    )

    if (highlight.showRulers) {
      this.drawAxis(this.context, rulerAtRight, rulerAtBottom)
    }

    if (highlight.paths.length) {
      if (highlight.showExtensionLines) {
        drawRulers(
          this.context,
          bounds,
          rulerAtRight,
          rulerAtBottom,
          undefined,
          false,
          this.canvasWidth,
          this.canvasHeight,
        )
      }

      if (highlight.elementInfo) {
        drawElementTitle(
          this.tooltip,
          highlight.elementInfo,
          highlight.colorFormat,
          bounds,
          this.canvasWidth,
          this.canvasHeight,
        )
      }
    }

    this.context.restore()

    return { bounds }
  }

  private drawAxis(
    context: CanvasRenderingContext2D,
    rulerAtRight: boolean,
    rulerAtBottom: boolean,
  ) {
    context.save()

    const pageFactor =
      this.pageZoomFactor * this.pageScaleFactor * this.emulationScaleFactor
    const scrollX = this.scrollX * this.pageScaleFactor
    const scrollY = this.scrollY * this.pageScaleFactor
    function zoom(x: number) {
      return Math.round(x * pageFactor)
    }
    function unzoom(x: number) {
      return Math.round(x / pageFactor)
    }

    const width = this.canvasWidth / pageFactor
    const height = this.canvasHeight / pageFactor

    const gridSubStep = 5
    const gridStep = 50

    {
      // Draw X grid background
      context.save()
      context.fillStyle = gridBackgroundColor
      if (rulerAtBottom) {
        context.fillRect(0, zoom(height) - 15, zoom(width), zoom(height))
      } else {
        context.fillRect(0, 0, zoom(width), 15)
      }

      // Clip out backgrounds intersection
      context.globalCompositeOperation = "destination-out"
      context.fillStyle = "red"
      if (rulerAtRight) {
        context.fillRect(zoom(width) - 15, 0, zoom(width), zoom(height))
      } else {
        context.fillRect(0, 0, 15, zoom(height))
      }
      context.restore()

      // Draw Y grid background
      context.fillStyle = gridBackgroundColor
      if (rulerAtRight) {
        context.fillRect(zoom(width) - 15, 0, zoom(width), zoom(height))
      } else {
        context.fillRect(0, 0, 15, zoom(height))
      }
    }

    context.lineWidth = 1
    context.strokeStyle = darkGridColor
    context.fillStyle = darkGridColor
    {
      // Draw labels.
      context.save()
      context.translate(-scrollX, 0.5 - scrollY)
      const maxY = height + unzoom(scrollY)
      for (let y = 2 * gridStep; y < maxY; y += 2 * gridStep) {
        context.save()
        context.translate(scrollX, zoom(y))
        context.rotate(-Math.PI / 2)
        context.fillText(String(y), 2, rulerAtRight ? zoom(width) - 7 : 13)
        context.restore()
      }
      context.translate(0.5, -0.5)
      const maxX = width + unzoom(scrollX)
      for (let x = 2 * gridStep; x < maxX; x += 2 * gridStep) {
        context.save()
        context.fillText(
          String(x),
          zoom(x) + 2,
          rulerAtBottom ? scrollY + zoom(height) - 7 : scrollY + 13,
        )
        context.restore()
      }
      context.restore()
    }

    {
      // Draw vertical grid
      context.save()
      if (rulerAtRight) {
        context.translate(zoom(width), 0)
        context.scale(-1, 1)
      }
      context.translate(-scrollX, 0.5 - scrollY)
      const maxY = height + unzoom(scrollY)
      for (let y = gridStep; y < maxY; y += gridStep) {
        context.beginPath()
        context.moveTo(scrollX, zoom(y))
        const markLength = y % (gridStep * 2) ? 5 : 8
        context.lineTo(scrollX + markLength, zoom(y))
        context.stroke()
      }
      context.strokeStyle = lightGridColor
      for (let y = gridSubStep; y < maxY; y += gridSubStep) {
        if (!(y % gridStep)) {
          continue
        }
        context.beginPath()
        context.moveTo(scrollX, zoom(y))
        context.lineTo(scrollX + gridSubStep, zoom(y))
        context.stroke()
      }
      context.restore()
    }

    {
      // Draw horizontal grid
      context.save()
      if (rulerAtBottom) {
        context.translate(0, zoom(height))
        context.scale(1, -1)
      }
      context.translate(0.5 - scrollX, -scrollY)
      const maxX = width + unzoom(scrollX)
      for (let x = gridStep; x < maxX; x += gridStep) {
        context.beginPath()
        context.moveTo(zoom(x), scrollY)
        const markLength = x % (gridStep * 2) ? 5 : 8
        context.lineTo(zoom(x), scrollY + markLength)
        context.stroke()
      }
      context.strokeStyle = lightGridColor
      for (let x = gridSubStep; x < maxX; x += gridSubStep) {
        if (!(x % gridStep)) {
          continue
        }
        context.beginPath()
        context.moveTo(zoom(x), scrollY)
        context.lineTo(zoom(x), scrollY + gridSubStep)
        context.stroke()
      }
      context.restore()
    }

    context.restore()
  }
}

const lightGridColor = "rgba(0,0,0,0.2)"
const darkGridColor = "rgba(0,0,0,0.7)"
const gridBackgroundColor = "rgba(255, 255, 255, 0.8)"

/**
 * Determine the layout type of the highlighted element based on the config.
 * @param {Object} elementInfo The element information, part of the config object passed to drawHighlight
 * @return {String|null} The layout type of the object, or null if none was found
 */
function getElementLayoutType(elementInfo: ElementInfo): string | null {
  if (elementInfo.layoutObjectName && elementInfo.layoutObjectName.endsWith("Grid")) {
    return "grid"
  }

  if (
    elementInfo.layoutObjectName &&
    elementInfo.layoutObjectName === "LayoutNGFlexibleBox"
  ) {
    return "flex"
  }

  return null
}

/**
 * Create the DOM node that displays the description of the highlighted element
 */
function createElementDescription(
  elementInfo: ElementInfo,
  colorFormat: string,
): Element {
  const elementInfoElement = createElement("div", "element-info")
  const elementInfoHeaderElement = createChild(
    elementInfoElement,
    "div",
    "element-info-header",
  )

  const layoutType = getElementLayoutType(elementInfo)
  if (layoutType) {
    createChild(elementInfoHeaderElement, "div", `element-layout-type ${layoutType}`)
  }
  const descriptionElement = createChild(
    elementInfoHeaderElement,
    "div",
    "element-description",
  )
  const tagNameElement = createChild(descriptionElement, "span", "material-tag-name")
  tagNameElement.textContent = elementInfo.tagName
  const nodeIdElement = createChild(descriptionElement, "span", "material-node-id")
  const maxLength = 80
  nodeIdElement.textContent = elementInfo.idValue
    ? "#" + ellipsify(elementInfo.idValue, maxLength)
    : ""
  nodeIdElement.classList.toggle("hidden", !elementInfo.idValue)

  const classNameElement = createChild(descriptionElement, "span", "material-class-name")
  if (nodeIdElement.textContent.length < maxLength) {
    classNameElement.textContent = ellipsify(
      elementInfo.className || "",
      maxLength - nodeIdElement.textContent.length,
    )
  }
  classNameElement.classList.toggle("hidden", !elementInfo.className)
  const dimensionsElement = createChild(elementInfoHeaderElement, "div", "dimensions")
  createChild(dimensionsElement, "span", "material-node-width").textContent = String(
    Math.round(elementInfo.nodeWidth * 100) / 100,
  )
  createTextChild(dimensionsElement, "\u00d7")
  createChild(dimensionsElement, "span", "material-node-height").textContent = String(
    Math.round(elementInfo.nodeHeight * 100) / 100,
  )

  const style = elementInfo.style || {}
  let elementInfoBodyElement: HTMLElement

  if (elementInfo.isLockedAncestor) {
    addTextRow("Showing content-visibility ancestor", "")
  }

  if (elementInfo.isLocked) {
    addTextRow("Descendants are skipped due to content-visibility", "")
  }

  const { color } = style
  if (color && color !== "#00000000") {
    addColorRow("Color", color, colorFormat)
  }

  const fontFamily = style["font-family"]
  const fontSize = style["font-size"]
  if (fontFamily && fontSize !== "0px") {
    addTextRow("Font", `${fontSize} ${fontFamily}`)
  }

  const bgcolor = style["background-color"]
  if (bgcolor && bgcolor !== "#00000000") {
    addColorRow("Background", bgcolor, colorFormat)
  }

  const { margin } = style
  if (margin && margin !== "0px") {
    addTextRow("Margin", margin)
  }

  const { padding } = style
  if (padding && padding !== "0px") {
    addTextRow("Padding", padding)
  }

  const cbgColor = elementInfo.contrast ? elementInfo.contrast.backgroundColor : null
  const hasContrastInfo =
    color && color !== "#00000000" && cbgColor && cbgColor !== "#00000000"

  if (elementInfo.showAccessibilityInfo) {
    addSection("Accessibility")

    if (hasContrastInfo && style["color"] && elementInfo.contrast) {
      addContrastRow(style["color"], elementInfo.contrast)
    }

    addTextRow("Name", elementInfo.accessibleName)
    addTextRow("Role", elementInfo.accessibleRole)
    addIconRow(
      "Keyboard-focusable",
      elementInfo.isKeyboardFocusable
        ? "a11y-icon a11y-icon-ok"
        : "a11y-icon a11y-icon-not-ok",
    )
  }

  function ensureElementInfoBody() {
    if (!elementInfoBodyElement) {
      elementInfoBodyElement = createChild(elementInfoElement, "div", "element-info-body")
    }
  }

  function addSection(name: string) {
    ensureElementInfoBody()
    const rowElement = createChild(
      elementInfoBodyElement,
      "div",
      "element-info-row element-info-section",
    )
    const nameElement = createChild(rowElement, "div", "section-name")
    nameElement.textContent = name
    createChild(createChild(rowElement, "div", "separator-container"), "div", "separator")
  }

  function addRow(
    name: string,
    rowClassName: string | undefined,
    valueClassName: string | undefined,
  ) {
    ensureElementInfoBody()
    const rowElement = createChild(elementInfoBodyElement, "div", "element-info-row")
    if (rowClassName) {
      rowElement.classList.add(rowClassName)
    }
    const nameElement = createChild(rowElement, "div", "element-info-name")
    nameElement.textContent = name
    createChild(rowElement, "div", "element-info-gap")
    return createChild(rowElement, "div", valueClassName || "")
  }

  function addIconRow(name: string, value: string) {
    createChild(addRow(name, "", "element-info-value-icon"), "div", value)
  }

  function addTextRow(name: string, value?: string) {
    if (value == null) {
      return
    }
    createTextChild(addRow(name, "", "element-info-value-text"), value)
  }

  function addColorRow(name: string, color: string, colorFormat: string) {
    const valueElement = addRow(name, "", "element-info-value-color")
    const swatch = createChild(valueElement, "div", "color-swatch")
    const inner = createChild(swatch, "div", "color-swatch-inner")
    inner.style.backgroundColor = color
    createTextChild(valueElement, formatColor(color, colorFormat))
  }

  function addContrastRow(fgColor: string, contrast: ContrastInfo) {
    const parsedFgColor = parseHexa(fgColor)
    const parsedBgColor = parseHexa(contrast.backgroundColor)
    // Merge text opacity into the alpha channel of the color.
    parsedFgColor[3] *= contrast.textOpacity
    const valueElement = addRow("Contrast", "", "element-info-value-contrast")
    const sampleText = createChild(valueElement, "div", "contrast-text")
    sampleText.style.color = formatRgba(parsedFgColor, "rgb")
    sampleText.style.backgroundColor = contrast.backgroundColor
    sampleText.textContent = "Aa"
    const valueSpan = createChild(valueElement, "span")
    if (contrast.contrastAlgorithm === "apca") {
      const percentage = contrastRatioAPCA(parsedFgColor, parsedBgColor)
      const threshold = getAPCAThreshold(contrast.fontSize, contrast.fontWeight)
      valueSpan.textContent = String(Math.floor(percentage * 100) / 100) + "%"
      createChild(
        valueElement,
        "div",
        threshold === null || Math.abs(percentage) < threshold
          ? "a11y-icon a11y-icon-warning"
          : "a11y-icon a11y-icon-ok",
      )
    } else if (
      contrast.contrastAlgorithm === "aa" ||
      contrast.contrastAlgorithm === "aaa"
    ) {
      const ratio = contrastRatio(parsedFgColor, parsedBgColor)
      const threshold = getContrastThreshold(contrast.fontSize, contrast.fontWeight)[
        contrast.contrastAlgorithm
      ]
      valueSpan.textContent = String(Math.floor(ratio * 100) / 100)
      createChild(
        valueElement,
        "div",
        ratio < threshold ? "a11y-icon a11y-icon-warning" : "a11y-icon a11y-icon-ok",
      )
    }
  }

  return elementInfoElement
}

/**
 * @param elementInfo The highlight config object passed to drawHighlight
 * @param colorFormat
 * @param bounds
 * @param canvasWidth
 * @param canvasHeight
 */
function drawElementTitle(
  tooltipContainer: HTMLElement,
  elementInfo: ElementInfo,
  colorFormat: string,
  bounds: Bounds,
  canvasWidth: number,
  canvasHeight: number,
) {
  // Get the tooltip container and empty it, there can only be one tooltip displayed at the same time.
  tooltipContainer.innerHTML = ""

  // Create the necessary wrappers.
  const wrapper = createChild(tooltipContainer, "div")
  const tooltipContent = createChild(wrapper, "div", "tooltip-content")

  // Create the tooltip content and append it.
  const tooltip = createElementDescription(elementInfo, colorFormat)
  tooltipContent.appendChild(tooltip)

  const titleWidth = tooltipContent.offsetWidth
  const titleHeight = tooltipContent.offsetHeight
  const arrowHalfWidth = 8
  const pageMargin = 2
  const arrowWidth = arrowHalfWidth * 2
  const arrowInset = arrowHalfWidth + 2

  const containerMinX = pageMargin + arrowInset
  const containerMaxX = canvasWidth - pageMargin - arrowInset - arrowWidth

  // Left align arrow to the tooltip but ensure it is pointing to the element.
  // Center align arrow if the inspected element bounds are too narrow.
  const boundsAreTooNarrow = bounds.maxX - bounds.minX < arrowWidth + 2 * arrowInset
  let arrowX: number
  if (boundsAreTooNarrow) {
    arrowX = (bounds.minX + bounds.maxX) * 0.5 - arrowHalfWidth
  } else {
    const xFromLeftBound = bounds.minX + arrowInset
    const xFromRightBound = bounds.maxX - arrowInset - arrowWidth
    if (xFromLeftBound > containerMinX && xFromLeftBound < containerMaxX) {
      arrowX = xFromLeftBound
    } else {
      arrowX = constrainNumber(containerMinX, xFromLeftBound, xFromRightBound)
    }
  }
  // Hide arrow if element is completely off the sides of the page.
  const arrowHidden = arrowX < containerMinX || arrowX > containerMaxX

  let boxX = arrowX - arrowInset
  boxX = constrainNumber(boxX, pageMargin, canvasWidth - titleWidth - pageMargin)

  let boxY = bounds.minY - arrowHalfWidth - titleHeight
  let onTop = true
  if (boxY < 0) {
    boxY = Math.min(canvasHeight - titleHeight, bounds.maxY + arrowHalfWidth)
    onTop = false
  } else if (bounds.minY > canvasHeight) {
    boxY = canvasHeight - arrowHalfWidth - titleHeight
  }

  // If tooltip intersects with the bounds, hide it.
  // Allow bounds to contain the box though for the large elements like <body>.
  const includes =
    boxX >= bounds.minX &&
    boxX + titleWidth <= bounds.maxX &&
    boxY >= bounds.minY &&
    boxY + titleHeight <= bounds.maxY
  const overlaps =
    boxX < bounds.maxX &&
    boxX + titleWidth > bounds.minX &&
    boxY < bounds.maxY &&
    boxY + titleHeight > bounds.minY
  if (overlaps && !includes) {
    tooltipContent.style.display = "none"
    return
  }

  tooltipContent.style.top = boxY + "px"
  tooltipContent.style.left = boxX + "px"
  if (arrowHidden) {
    return
  }

  const tooltipArrow = createChild(tooltipContent, "div", "tooltip-arrow")
  tooltipArrow.style.clipPath = onTop
    ? "polygon(0 0, 100% 0, 50% 100%)"
    : "polygon(50% 0, 0 100%, 100% 100%)"
  // When tooltip is on-top remove 1px from the arrow's top value to get rid of whitespace produced by the tooltip's border.
  tooltipArrow.style.top = (onTop ? titleHeight - 1 : -arrowHalfWidth) + "px"
  tooltipArrow.style.left = arrowX - boxX + "px"
}

const DEFAULT_RULER_COLOR = "rgba(128, 128, 128, 0.3)"

function drawRulers(
  context: CanvasRenderingContext2D,
  bounds: PathBounds,
  rulerAtRight: boolean,
  rulerAtBottom: boolean,
  color: string | undefined,
  dash: boolean,
  canvasWidth: number,
  canvasHeight: number,
) {
  context.save()
  const width = canvasWidth
  const height = canvasHeight
  context.strokeStyle = color || DEFAULT_RULER_COLOR
  context.lineWidth = 1
  context.translate(0.5, 0.5)
  if (dash) {
    context.setLineDash([3, 3])
  }

  if (rulerAtRight) {
    for (const y in bounds.rightmostXForY) {
      context.beginPath()
      context.moveTo(width, Number(y))
      context.lineTo(bounds.rightmostXForY[y], Number(y))
      context.stroke()
    }
  } else {
    for (const y in bounds.leftmostXForY) {
      context.beginPath()
      context.moveTo(0, Number(y))
      context.lineTo(bounds.leftmostXForY[y], Number(y))
      context.stroke()
    }
  }

  if (rulerAtBottom) {
    for (const x in bounds.bottommostYForX) {
      context.beginPath()
      context.moveTo(Number(x), height)
      context.lineTo(Number(x), bounds.topmostYForX[x])
      context.stroke()
    }
  } else {
    for (const x in bounds.topmostYForX) {
      context.beginPath()
      context.moveTo(Number(x), 0)
      context.lineTo(Number(x), bounds.topmostYForX[x])
      context.stroke()
    }
  }

  context.restore()
}
