import {
  ArrayExt,
  Dictionary,
  Dom,
  FunctionExt,
  ObjectExt,
  StringExt,
  Util,
} from '../common'
import { Point, type Rectangle } from '../geometry'
import {
  type AttrDefinition,
  type AttrPositionDefinition,
  type CellAttrs,
  type ComplexAttrs,
  isValidDefinition,
  type OffsetDefinition,
  type SetDefinition,
  type SimpleAttrs,
  type SimpleAttrValue,
} from '../registry/attr'
import type { CellView } from './cell'
import type { MarkupSelectors } from './markup'
import { viewFind } from './view/util'

export interface AttrManagerUpdateOptions {
  rootBBox: Rectangle
  selectors: MarkupSelectors
  scalableNode?: Element | null
  rotatableNode?: Element | null
  /**
   * Rendering only the specified attributes.
   */
  attrs?: CellAttrs | null
}

export interface AttrManagerProcessedAttrs {
  raw: ComplexAttrs
  normal?: SimpleAttrs | undefined
  set?: ComplexAttrs | undefined
  offset?: ComplexAttrs | undefined
  position?: ComplexAttrs | undefined
}

export class AttrManager {
  constructor(protected view: CellView) {}

  protected get cell() {
    return this.view.cell
  }

  protected getDefinition(attrName: string): AttrDefinition | null {
    return this.cell.getAttrDefinition(attrName)
  }

  protected processAttrs(
    elem: Element,
    raw: ComplexAttrs,
  ): AttrManagerProcessedAttrs {
    let normal: SimpleAttrs | undefined
    let set: ComplexAttrs | undefined
    let offset: ComplexAttrs | undefined
    let position: ComplexAttrs | undefined

    const specials: { name: string; definition: AttrDefinition }[] = []

    // divide the attributes between normal and special
    Object.keys(raw).forEach((name) => {
      const val = raw[name]
      const definition = this.getDefinition(name)
      const isValid = FunctionExt.call(
        isValidDefinition,
        this.view,
        definition,
        val,
        {
          elem,
          attrs: raw,
          cell: this.cell,
          view: this.view,
        },
      )

      if (definition && isValid) {
        if (typeof definition === 'string') {
          if (normal == null) {
            normal = {}
          }
          normal[definition] = val as SimpleAttrValue
        } else if (val !== null) {
          specials.push({ name, definition })
        }
      } else {
        if (normal == null) {
          normal = {}
        }
        const normalName = Dom.CASE_SENSITIVE_ATTR.includes(name)
          ? name
          : StringExt.kebabCase(name)
        normal[normalName] = val as SimpleAttrValue
      }
    })

    specials.forEach(({ name, definition }) => {
      const val = raw[name]

      const setDefine = definition as SetDefinition
      if (typeof setDefine.set === 'function') {
        if (set == null) {
          set = {}
        }
        set[name] = val
      }

      const offsetDefine = definition as OffsetDefinition
      if (typeof offsetDefine.offset === 'function') {
        if (offset == null) {
          offset = {}
        }
        offset[name] = val
      }

      const positionDefine = definition as AttrPositionDefinition
      if (typeof positionDefine.position === 'function') {
        if (position == null) {
          position = {}
        }
        position[name] = val
      }
    })

    return {
      raw,
      normal,
      set,
      offset,
      position,
    }
  }

  protected mergeProcessedAttrs(
    allProcessedAttrs: AttrManagerProcessedAttrs,
    roProcessedAttrs: AttrManagerProcessedAttrs,
  ) {
    allProcessedAttrs.set = {
      ...allProcessedAttrs.set,
      ...roProcessedAttrs.set,
    }

    allProcessedAttrs.position = {
      ...allProcessedAttrs.position,
      ...roProcessedAttrs.position,
    }

    allProcessedAttrs.offset = {
      ...allProcessedAttrs.offset,
      ...roProcessedAttrs.offset,
    }

    // Handle also the special transform property.
    const transform = allProcessedAttrs.normal?.transform
    if (transform != null && roProcessedAttrs.normal) {
      roProcessedAttrs.normal.transform = transform
    }
    allProcessedAttrs.normal = roProcessedAttrs.normal
  }

  protected findAttrs(
    cellAttrs: CellAttrs,
    rootNode: Element,
    selectorCache: { [selector: string]: Element[] },
    selectors: MarkupSelectors,
  ) {
    const merge: Element[] = []
    const result: Dictionary<
      Element,
      {
        elem: Element
        array: boolean
        priority: number | number[]
        attrs: ComplexAttrs | ComplexAttrs[]
      }
    > = new Dictionary()

    Object.keys(cellAttrs).forEach((selector) => {
      const attrs = cellAttrs[selector]
      if (!ObjectExt.isPlainObject(attrs)) {
        return
      }

      const { isCSSSelector, elems } = viewFind(selector, rootNode, selectors)
      selectorCache[selector] = elems
      for (let i = 0, l = elems.length; i < l; i += 1) {
        const elem = elems[i]
        const unique = selectors && selectors[selector] === elem
        const prev = result.get(elem)
        if (prev) {
          if (!prev.array) {
            merge.push(elem)
            prev.array = true
            prev.attrs = [prev.attrs as ComplexAttrs]
            prev.priority = [prev.priority as number]
          }

          const attributes = prev.attrs as ComplexAttrs[]
          const selectedLength = prev.priority as number[]
          if (unique) {
            // node referenced by `selector`
            attributes.unshift(attrs)
            selectedLength.unshift(-1)
          } else {
            // node referenced by `groupSelector` or CSSSelector
            const sortIndex = ArrayExt.sortedIndex(
              selectedLength,
              isCSSSelector ? -1 : l,
            )

            attributes.splice(sortIndex, 0, attrs)
            selectedLength.splice(sortIndex, 0, l)
          }
        } else {
          result.set(elem, {
            elem,
            attrs,
            priority: unique ? -1 : l,
            array: false,
          })
        }
      }
    })

    merge.forEach((node) => {
      const item = result.get(node)!
      const arr = item.attrs as ComplexAttrs[]
      item.attrs = arr.reduceRight(
        (memo, attrs) => ObjectExt.merge(memo, attrs),
        {},
      )
    })

    return result as Dictionary<
      Element,
      {
        elem: Element
        array: boolean
        priority: number | number[]
        attrs: ComplexAttrs
      }
    >
  }

  protected updateRelativeAttrs(
    elem: Element,
    processedAttrs: AttrManagerProcessedAttrs,
    refBBox: Rectangle,
  ) {
    const rawAttrs = processedAttrs.raw || {}
    let nodeAttrs = processedAttrs.normal || {}
    const setAttrs = processedAttrs.set
    const positionAttrs = processedAttrs.position
    const offsetAttrs = processedAttrs.offset
    const getOptions = () => ({
      elem,
      cell: this.cell,
      view: this.view,
      attrs: rawAttrs,
      refBBox: refBBox.clone(),
    })

    if (setAttrs != null) {
      Object.keys(setAttrs).forEach((name) => {
        const val = setAttrs[name]
        const def = this.getDefinition(name)
        if (def != null) {
          const ret = FunctionExt.call(
            (def as SetDefinition).set,
            this.view,
            val,
            getOptions(),
          )
          if (typeof ret === 'object') {
            nodeAttrs = {
              ...nodeAttrs,
              ...ret,
            }
          } else if (ret != null) {
            // @ts-expect-error
            nodeAttrs[name] = ret
          }
        }
      })
    }

    if (elem instanceof HTMLElement) {
      // TODO: setting the `transform` attribute on HTMLElements
      // via `node.style.transform = 'matrix(...)';` would introduce
      // a breaking change (e.g. basic.TextBlock).
      this.view.setAttrs(nodeAttrs, elem)
      return
    }

    // The final translation of the subelement.
    const nodeTransform = nodeAttrs.transform
    const transform = nodeTransform ? `${nodeTransform}` : null
    const nodeMatrix = Dom.transformStringToMatrix(transform)
    const nodePosition = new Point(nodeMatrix.e, nodeMatrix.f)
    if (nodeTransform) {
      delete nodeAttrs.transform
      nodeMatrix.e = 0
      nodeMatrix.f = 0
    }

    let positioned = false
    if (positionAttrs != null) {
      Object.keys(positionAttrs).forEach((name) => {
        const val = positionAttrs[name]
        const def = this.getDefinition(name)
        if (def != null) {
          const ts = FunctionExt.call(
            (def as AttrPositionDefinition).position,
            this.view,
            val,
            getOptions(),
          )

          if (ts != null) {
            positioned = true
            nodePosition.translate(Point.create(ts))
          }
        }
      })
    }

    // The node bounding box could depend on the `size`
    // set from the previous loop.
    this.view.setAttrs(nodeAttrs, elem)

    let offseted = false
    if (offsetAttrs != null) {
      // Check if the node is visible
      const nodeBoundingRect = this.view.getBoundingRectOfElement(elem)
      if (nodeBoundingRect.width > 0 && nodeBoundingRect.height > 0) {
        const nodeBBox = Util.transformRectangle(nodeBoundingRect, nodeMatrix)

        Object.keys(offsetAttrs).forEach((name) => {
          const val = offsetAttrs[name]
          const def = this.getDefinition(name)
          if (def != null) {
            const ts = FunctionExt.call(
              (def as OffsetDefinition).offset,
              this.view,
              val,
              {
                elem,
                cell: this.cell,
                view: this.view,
                attrs: rawAttrs,
                refBBox: nodeBBox,
              },
            )

            if (ts != null) {
              offseted = true
              nodePosition.translate(Point.create(ts))
            }
          }
        })
      }
    }

    if (nodeTransform != null || positioned || offseted) {
      nodePosition.round(1)
      nodeMatrix.e = nodePosition.x
      nodeMatrix.f = nodePosition.y
      elem.setAttribute('transform', Dom.matrixToTransformString(nodeMatrix))
    }
  }

  update(
    rootNode: Element,
    attrs: CellAttrs,
    options: AttrManagerUpdateOptions,
  ) {
    const selectorCache: { [selector: string]: Element[] } = {}
    const nodesAttrs = this.findAttrs(
      options.attrs || attrs,
      rootNode,
      selectorCache,
      options.selectors,
    )

    // `nodesAttrs` are different from all attributes, when
    // rendering only attributes sent to this method.
    const nodesAllAttrs = options.attrs
      ? this.findAttrs(attrs, rootNode, selectorCache, options.selectors)
      : nodesAttrs

    const specialItems: {
      node: Element
      refNode: Element | null
      attributes: ComplexAttrs | null
      processedAttributes: AttrManagerProcessedAttrs
    }[] = []

    nodesAttrs.each((data) => {
      const node = data.elem
      const nodeAttrs = data.attrs
      const processed = this.processAttrs(node, nodeAttrs)
      if (
        processed.set == null &&
        processed.position == null &&
        processed.offset == null
      ) {
        this.view.setAttrs(processed.normal, node)
      } else {
        const data = nodesAllAttrs.get(node)
        const nodeAllAttrs = data ? data.attrs : null
        const refSelector =
          nodeAllAttrs && nodeAttrs.ref == null
            ? nodeAllAttrs.ref
            : nodeAttrs.ref

        let refNode: Element | null
        if (refSelector) {
          refNode = (selectorCache[refSelector as string] ||
            this.view.find(
              refSelector as string,
              rootNode,
              options.selectors,
            ))[0]
          if (!refNode) {
            throw new Error(`"${refSelector}" reference does not exist.`)
          }
        } else {
          refNode = null
        }

        const item = {
          node,
          refNode,
          attributes: nodeAllAttrs,
          processedAttributes: processed,
        }

        // If an element in the list is positioned relative to this one, then
        // we want to insert this one before it in the list.
        const index = specialItems.findIndex((item) => item.refNode === node)
        if (index > -1) {
          specialItems.splice(index, 0, item)
        } else {
          specialItems.push(item)
        }
      }
    })

    const bboxCache: Dictionary<Element, Rectangle> = new Dictionary()
    let rotatableMatrix: DOMMatrix
    specialItems.forEach((item) => {
      const node = item.node
      const refNode = item.refNode

      let unrotatedRefBBox: Rectangle | undefined
      const isRefNodeRotatable =
        refNode != null &&
        options.rotatableNode != null &&
        Dom.contains(options.rotatableNode, refNode)

      // Find the reference element bounding box. If no reference was
      // provided, we use the optional bounding box.
      if (refNode) {
        unrotatedRefBBox = bboxCache.get(refNode)
      }

      if (!unrotatedRefBBox) {
        const target = (
          isRefNodeRotatable ? options.rotatableNode! : rootNode
        ) as SVGElement

        unrotatedRefBBox = refNode
          ? Util.getBBox(refNode as SVGElement, { target })
          : options.rootBBox

        if (refNode) {
          bboxCache.set(refNode, unrotatedRefBBox!)
        }
      }

      let processedAttrs: AttrManagerProcessedAttrs
      if (options.attrs && item.attributes) {
        // If there was a special attribute affecting the position amongst
        // passed-in attributes we have to merge it with the rest of the
        // element's attributes as they are necessary to update the position
        // relatively (i.e `ref-x` && 'ref-dx').
        processedAttrs = this.processAttrs(node, item.attributes)
        this.mergeProcessedAttrs(processedAttrs, item.processedAttributes)
      } else {
        processedAttrs = item.processedAttributes
      }

      let refBBox = unrotatedRefBBox!
      if (
        isRefNodeRotatable &&
        options.rotatableNode != null &&
        !options.rotatableNode.contains(node)
      ) {
        // If the referenced node is inside the rotatable group while the
        // updated node is outside, we need to take the rotatable node
        // transformation into account.
        if (!rotatableMatrix) {
          rotatableMatrix = Dom.transformStringToMatrix(
            Dom.attr(options.rotatableNode, 'transform'),
          )
        }
        refBBox = Util.transformRectangle(unrotatedRefBBox!, rotatableMatrix)
      }

      this.updateRelativeAttrs(node, processedAttrs, refBBox)
    })
  }
}
