import { camelCase, throttle } from "lodash"
import { ValueEmitter } from "../../shared/emitter"
import { hex, parse } from "./src/color"
import { elementRoles } from "./src/elementRoles"
import {
  type ElementInfo,
  type Highlight,
  HighlightOverlay,
  type Path,
} from "./src/overlay/toolHighlight"
import type { PathCommands } from "./src/overlay/common"

interface IRect {
  left: number
  top: number
  width: number
  height: number
}

export interface IRgb {
  r: number
  g: number
  b: number
  a?: number
}

/** IOptions */
export interface IOptions {
  /** Whether the rulers should be shown. */
  showRulers: boolean
  /** Whether the extension lines from node to the rulers should be shown. */
  showExtensionLines: boolean
  /** Whether the node info tooltip should be shown. */
  showInfo: boolean
  /** Whether the node styles in the tooltip. */
  showStyles: boolean
  /** Whether the a11y info should be shown. */
  showAccessibilityInfo: boolean
  /** The color format used to format color styles. */
  colorFormat: "rgb" | "hsl" | "hex"
  /** The content box highlight fill color. */
  contentColor: string | IRgb
  /** The padding highlight fill color. */
  paddingColor: string | IRgb
  /** The border highlight fill color. */
  borderColor: string | IRgb
  /** The margin highlight fill color. */
  marginColor: string | IRgb
  /** Auto redraw if target element is resized. */
  monitorResize: boolean | IRgb
}

class ResizeSensor extends ValueEmitter<ResizeObserverEntry[]> {
  #observer: ResizeObserver

  constructor(private target: HTMLElement) {
    super()
    this.#observer = new ResizeObserver(e => super.emit(e))
    this.#observer.observe(target)
  }

  destroy() {
    this.#observer.unobserve(this.target)
    super.clear()
  }
}

/**
 * Highlighter for html elements.
 *
 * @example
 * const domHighlighter = new LunaDomHighlighter(container, {
 *   showRulers: true,
 *   showExtensionLines: true,
 *   showInfo: true,
 * })
 * domHighlighter.highlight(document.getElementById('test'))
 * domHighlighter.hide()
 */
export class DomHighlighter {
  private options: IOptions
  private overlay: HighlightOverlay = new HighlightOverlay(window)
  private target?: HTMLElement | Text | null
  private resizeSensor?: ResizeSensor
  private redraw: () => void
  private interceptor?: (highlight: Highlight) => Highlight | null

  constructor(container: HTMLElement, options: Partial<IOptions> = {}) {
    this.options = {
      showRulers: false,
      showExtensionLines: false,
      showInfo: true,
      showStyles: true,
      showAccessibilityInfo: true,
      colorFormat: "hex",
      contentColor: "rgba(111, 168, 220, .66)",
      paddingColor: "rgba(147, 196, 125, .55)",
      borderColor: "rgba(255, 229, 153, .66)",
      marginColor: "rgba(246, 178, 107, .66)",
      monitorResize: true,
      ...options,
    }

    this.overlay.setContainer(container)
    this.overlay.setPlatform("mac")

    this.redraw = throttle(() => {
      this.reset()
      this.draw()
    }, 16)

    this.redraw()
    this.bindEvent()
  }

  /** Highlight element or text node. */
  highlight(target: HTMLElement | Text, options?: IOptions) {
    if (options) {
      Object.assign(this.options, options)
    }

    this.target = target

    if (target instanceof HTMLElement && this.options.monitorResize) {
      if (this.resizeSensor) {
        this.resizeSensor.destroy()
      }
      this.resizeSensor = new ResizeSensor(target)
      this.resizeSensor.listen(this.redraw)
    }

    this.redraw()
  }

  /** Hide highlight. */
  hide() {
    this.target = null
    this.redraw()
  }

  intercept(interceptor: (highlight: Highlight) => Highlight | null) {
    this.interceptor = interceptor
  }

  destroy() {
    window.removeEventListener("resize", this.redraw)
    window.removeEventListener("scroll", this.redraw)
    this.resizeSensor?.destroy()
  }

  private draw() {
    const { target } = this
    if (!target) {
      return
    }

    if (target instanceof Text) {
      this.drawText(target)
    } else {
      this.drawElement(target)
    }
  }

  private drawText(target: Text) {
    const { options } = this
    const range = document.createRange()
    range.selectNode(target)
    const { left, top, width, height } = range.getBoundingClientRect()
    range.detach()

    const highlight: Highlight = {
      paths: [
        {
          path: rectToPath({ left, top, width, height }),
          fillColor: normalizeColor(options.contentColor),
          name: "content",
        },
      ],
      showExtensionLines: options.showExtensionLines,
      showRulers: options.showRulers,
      colorFormat: undefined!,
    }

    if (options.showInfo) {
      highlight.elementInfo = {
        tagName: "#text",
        nodeWidth: width,
        nodeHeight: height,
      }
    }

    this.overlay.drawHighlight(highlight)
  }

  private drawElement(target: HTMLElement): void {
    let highlight: Highlight = {
      paths: this.getPaths(target),
      showExtensionLines: this.options.showExtensionLines,
      showRulers: this.options.showRulers,
      colorFormat: this.options.colorFormat,
    }

    if (this.options.showInfo) {
      highlight.elementInfo = this.getElementInfo(target)
    }

    if (this.interceptor) {
      const result = this.interceptor(highlight)
      if (result) {
        highlight = result
      }
    }
    this.overlay.drawHighlight(highlight)
  }

  private getPaths(target: HTMLElement): Path[] {
    const { options } = this
    const computedStyle = window.getComputedStyle(target)

    const { left, top, width, height } = target.getBoundingClientRect()

    const getNumStyle = (name: string) => pxToNum(computedStyle.getPropertyValue(name))

    const ml = getNumStyle("margin-left")
    const mr = getNumStyle("margin-right")
    const mt = getNumStyle("margin-top")
    const mb = getNumStyle("margin-bottom")

    const bl = getNumStyle("border-left-width")
    const br = getNumStyle("border-right-width")
    const bt = getNumStyle("border-top-width")
    const bb = getNumStyle("border-bottom-width")

    const pl = getNumStyle("padding-left")
    const pr = getNumStyle("padding-right")
    const pt = getNumStyle("padding-top")
    const pb = getNumStyle("padding-bottom")

    const contentPath: Path = {
      path: rectToPath({
        left: left + bl + pl,
        top: top + bt + pt,
        width: width - bl - pl - br - pr,
        height: height - bt - pt - bb - pb,
      }),
      fillColor: normalizeColor(options.contentColor),
      name: "content",
    }

    const paddingPath: Path = {
      path: rectToPath({
        left: left + bl,
        top: top + bt,
        width: width - bl - br,
        height: height - bt - bb,
      }),
      fillColor: normalizeColor(options.paddingColor),
      name: "padding",
    }

    const borderPath: Path = {
      path: rectToPath({
        left,
        top,
        width,
        height,
      }),
      fillColor: normalizeColor(options.borderColor),
      name: "border",
    }

    const marginPath: Path = {
      path: rectToPath({
        left: left - ml,
        top: top - mt,
        width: width + ml + mr,
        height: height + mt + mb,
      }),
      fillColor: normalizeColor(options.marginColor),
      name: "margin",
    }

    return [contentPath, paddingPath, borderPath, marginPath]
  }

  private getElementInfo(target: HTMLElement): ElementInfo {
    const { width, height } = target.getBoundingClientRect()
    // svg element's className is not a string
    let className = target.getAttribute("class") || ""
    className = className
      .split(/\s+/)
      .map(c => "." + c)
      .join("")

    const elementInfo: ElementInfo = {
      tagName: target.tagName.toLowerCase(),
      className,
      idValue: target.id,
      nodeWidth: width,
      nodeHeight: height,
    }

    if (this.options.showStyles) {
      elementInfo.style = getStyles(target)
    }

    if (this.options.showAccessibilityInfo) {
      Object.assign(elementInfo, getAccessibilityInfo(target))
    }

    return elementInfo
  }

  private bindEvent(): void {
    window.addEventListener("resize", this.redraw)
    window.addEventListener("scroll", this.redraw)
  }

  private reset = () => {
    const viewportWidth = document.documentElement.clientWidth
    const viewportHeight = document.documentElement.clientHeight

    this.overlay.reset({
      viewportSize: {
        width: viewportWidth,
        height: viewportHeight,
      },
      deviceScaleFactor: 1,
      pageScaleFactor: 1,
      pageZoomFactor: 1,
      emulationScaleFactor: 1,
      scrollX: window.scrollX,
      scrollY: window.scrollY,
    })
  }
}

// third_party/blink/renderer/core/inspector/inspector_highlight.cc
function getStyles(target: HTMLElement) {
  const computedStyle = window.getComputedStyle(target)
  let hasTextChildren = false
  const { childNodes } = target
  for (let i = 0, len = childNodes.length; i < len; i++) {
    if (childNodes[i].nodeType === 3) {
      hasTextChildren = true
    }
  }

  const properties = []
  if (hasTextChildren) {
    properties.push("color", "font-family", "font-size", "line-height")
  }
  properties.push("padding", "margin", "background-color")

  return propertiesToValues(computedStyle, properties)
}

function getAccessibilityInfo(target: HTMLElement): Partial<ElementInfo> {
  const computedStyle = window.getComputedStyle(target)

  return {
    showAccessibilityInfo: true,
    contrast: {
      contrastAlgorithm: "aa",
      textOpacity: 0.1,
      ...(propertiesToValues(
        computedStyle,
        ["font-size", "font-weight", "background-color", "text-opacity"],
        true,
      ) as {
        fontSize: string
        fontWeight: string
        backgroundColor: string
        textOpacity?: number
      }),
    },
    isKeyboardFocusable: isFocusable(target),
    ...getAccessibleNameAndRole(target),
  }
}

function isFocusable(target: HTMLElement) {
  const tagName = target.tagName.toLowerCase()

  if (["a", "button", "input", "textarea", "select", "details"].includes(tagName)) {
    return true
  }

  const tabIdx = target.getAttribute("tabindex")
  if (tabIdx && parseInt(tabIdx, 10) > -1) {
    return true
  }

  return false
}

function getAccessibleNameAndRole(target: HTMLElement) {
  const tagName = target.tagName.toLowerCase()
  return {
    accessibleName:
      target.getAttribute("labelledby") ||
      target.getAttribute("aria-label") ||
      target.getAttribute("title") ||
      "",
    accessibleRole:
      target.getAttribute("role") ||
      elementRoles.find(([name, , attributes]) => {
        if (name !== tagName) {
          return false
        }
        if (attributes) {
          return attributes.every(([attr, value]) => target.getAttribute(attr) === value)
        }
        return true
      })?.[1] ||
      "generic",
  }
}

function rectToPath({ left, top, width, height }: IRect): PathCommands {
  // prettier-ignore
  return [
    "M", left, top,
    "L", left + width, top,
    "L", left + width, top + height,
    "L", left, top + height,
    "Z",
  ];
}

const regRgb = /^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/
const regRgba = /^rgba\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3}),\s*(\d*(?:\.\d+)?)\)$/

function isColor(color: string) {
  return regRgb.test(color) || regRgba.test(color)
}

function rgbToHex(rgb: string) {
  const color = parse(rgb)
  const opacity = color.val[3] || 1
  color.val = color.val.slice(0, 3)
  color.val.push(Math.round(255 * opacity))
  return "#" + hex.encode(color.val).toLocaleUpperCase()
}

function normalizeColor(color: string | IRgb) {
  if (typeof color === "string") {
    return color
  }

  if (color.a) {
    return `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`
  }

  return `rgb(${color.r}, ${color.g}, ${color.b})`
}

function propertiesToValues(
  computedStyle: CSSStyleDeclaration,
  properties: string[],
  useCamelCase = false,
) {
  const ret: Record<string, any> = {}
  for (let property of properties) {
    let value = (computedStyle as any)[property === "text-opacity" ? "color" : property]
    if (!value) {
      return
    }
    if (isColor(value)) {
      value = rgbToHex(value)
      if (property === "text-opacity") {
        value = value.slice(7)
        value = hex.decode(value)[0] / 255
      }
    }
    if (useCamelCase) {
      property = camelCase(property)
    }
    ret[property] = value
  }
  return ret
}

function pxToNum(str: string) {
  return parseInt(str.replace("px", ""), 10)
}
