import { mergeAttributes, Node, type CommandProps } from "@tiptap/core"
import { NodeSelection } from "@tiptap/pm/state"
import { ReactNodeViewRenderer } from "@tiptap/react"
import { DocsGraphicNodeView } from "@/components/drive/richtext/docs-graphic-node-view"
import { bboxToPageCoords, usesPageLayer } from "@/lib/drive/docs-graphic-position"
import {
  buildGradientCss,
  DOCS_GRAPHIC_DEFAULTS,
  type DocsGraphicAttrs,
  type DocsGraphicFloatSide,
  type DocsGraphicPlacement,
  type DocsGraphicPositionMode,
  type DocsGraphicType,
  type DocsGraphicWrap,
  type DocsShapeType,
  parseGraphicAttrs,
} from "@/lib/drive/docs-graphic-types"

declare module "@tiptap/core" {
  interface Commands<ReturnType> {
    docsGraphic: {
      insertDocsGraphic: (attrs: Partial<DocsGraphicAttrs>) => ReturnType
      updateDocsGraphic: (attrs: Partial<DocsGraphicAttrs>) => ReturnType
      setDocsGraphicWrap: (wrap: DocsGraphicWrap) => ReturnType
      setDocsGraphicPlacement: (placement: DocsGraphicPlacement) => ReturnType
      setDocsGraphicFloatSide: (floatSide: DocsGraphicFloatSide) => ReturnType
      setDocsGraphicPositionMode: (mode: DocsGraphicPositionMode) => ReturnType
      setDocsGraphicWrapMargin: (mm: number) => ReturnType
      setDocsGraphicAnchor: (anchorPos: number) => ReturnType
      bringDocsGraphicForward: () => ReturnType
      sendDocsGraphicBackward: () => ReturnType
    }
  }
}

const graphicAttributes = {
  graphicType: { default: DOCS_GRAPHIC_DEFAULTS.graphicType },
  src: { default: null as string | null },
  alt: { default: "" },
  shapeType: { default: DOCS_GRAPHIC_DEFAULTS.shapeType },
  fill: { default: DOCS_GRAPHIC_DEFAULTS.fill },
  stroke: { default: DOCS_GRAPHIC_DEFAULTS.stroke },
  strokeWidth: { default: DOCS_GRAPHIC_DEFAULTS.strokeWidth },
  gradientCss: { default: "" },
  gradientType: { default: DOCS_GRAPHIC_DEFAULTS.gradientType },
  gradientAngle: { default: DOCS_GRAPHIC_DEFAULTS.gradientAngle },
  gradientColor1: { default: DOCS_GRAPHIC_DEFAULTS.gradientColor1 },
  gradientColor2: { default: DOCS_GRAPHIC_DEFAULTS.gradientColor2 },
  width: { default: DOCS_GRAPHIC_DEFAULTS.width },
  height: { default: DOCS_GRAPHIC_DEFAULTS.height },
  placement: { default: DOCS_GRAPHIC_DEFAULTS.placement },
  wrap: { default: DOCS_GRAPHIC_DEFAULTS.wrap },
  floatSide: { default: DOCS_GRAPHIC_DEFAULTS.floatSide },
  x: { default: 0 },
  y: { default: 0 },
  positionMode: { default: DOCS_GRAPHIC_DEFAULTS.positionMode },
  anchorPos: { default: -1 },
  pageIndex: { default: 0 },
  pageX: { default: 0 },
  pageY: { default: 0 },
  wrapMarginMm: { default: DOCS_GRAPHIC_DEFAULTS.wrapMarginMm },
  rotationDeg: { default: 0 },
  zIndex: { default: 0 },
  cropX: { default: 0 },
  cropY: { default: 0 },
  cropWidth: { default: 1 },
  cropHeight: { default: 1 },
  cropShape: { default: "rect" },
  lockAspectRatio: { default: true },
  imageFit: { default: "contain" },
  imageFitAnchorH: { default: 0.5 },
  imageFitAnchorV: { default: 0.5 },
  assetId: { default: null as string | null },
  opacity: { default: 1 },
  shadow: { default: "" },
  brightness: { default: 0 },
  contrast: { default: 0 },
  recolor: { default: "" },
  altTitle: { default: "" },
  drawScene: {
    default: null as string | null,
    rendered: false,
    parseHTML: (element) => element.getAttribute("data-draw-scene"),
    renderHTML: (attributes) =>
      attributes.drawScene ? { "data-draw-scene": attributes.drawScene } : {},
  },
  drawDriveFileId: {
    default: null as string | null,
    rendered: false,
    parseHTML: (element) => element.getAttribute("data-draw-drive-file-id"),
    renderHTML: (attributes) =>
      attributes.drawDriveFileId
        ? { "data-draw-drive-file-id": attributes.drawDriveFileId }
        : {},
  },
}

function mergeGraphicAttrs(partial: Partial<DocsGraphicAttrs>): DocsGraphicAttrs {
  const merged = parseGraphicAttrs({ ...DOCS_GRAPHIC_DEFAULTS, ...partial })
  if (merged.graphicType === "gradient" && !partial.gradientCss) {
    merged.gradientCss = buildGradientCss(
      merged.gradientAngle,
      merged.gradientColor1,
      merged.gradientColor2,
      merged.gradientType
    )
  }
  return merged
}

function graphicNodeName(attrs: Partial<DocsGraphicAttrs>): "docsGraphic" | "docsInlineGraphic" {
  const merged = mergeGraphicAttrs(attrs)
  return merged.placement === "inline" || merged.wrap === "inline"
    ? "docsInlineGraphic"
    : "docsGraphic"
}

function activeGraphicName(editor: { isActive: (name: string) => boolean }): string | null {
  if (editor.isActive("docsInlineGraphic")) return "docsInlineGraphic"
  if (editor.isActive("docsGraphic")) return "docsGraphic"
  return null
}

type GraphicCommandProps = CommandProps

function selectedGraphicNode(
  state: import("@tiptap/pm/state").EditorState
): import("@tiptap/pm/model").Node | null {
  const selection = state.selection
  if (!(selection instanceof NodeSelection)) return null
  const node = selection.node
  if (node.type.name !== "docsGraphic" && node.type.name !== "docsInlineGraphic") return null
  return node
}

function isGraphicNodeName(name: string): name is "docsGraphic" | "docsInlineGraphic" {
  return name === "docsGraphic" || name === "docsInlineGraphic"
}

function restoreGraphicNodeSelection(
  tr: import("@tiptap/pm/state").Transaction,
  preferredPos: number
): void {
  const candidates = [preferredPos, preferredPos + 1, preferredPos - 1]
  for (const tryPos of candidates) {
    if (tryPos < 0 || tryPos > tr.doc.content.size) continue
    const nodeAt = tr.doc.nodeAt(tryPos)
    if (nodeAt && isGraphicNodeName(nodeAt.type.name)) {
      tr.setSelection(NodeSelection.create(tr.doc, tryPos))
      return
    }
  }

  let best: { pos: number; dist: number } | null = null
  tr.doc.descendants((node, pos) => {
    if (!isGraphicNodeName(node.type.name)) return
    const dist = Math.abs(pos - preferredPos)
    if (!best || dist < best.dist) best = { pos, dist }
  })
  if (best) tr.setSelection(NodeSelection.create(tr.doc, best.pos))
}

function replaceBlockGraphicWithInline(
  tr: import("@tiptap/pm/state").Transaction,
  state: import("@tiptap/pm/state").EditorState,
  pos: number,
  node: import("@tiptap/pm/model").Node,
  merged: DocsGraphicAttrs,
  inlineType: import("@tiptap/pm/model").NodeType
): number {
  const paragraphType = state.schema.nodes.paragraph
  if (!paragraphType) return pos
  const inlineNode = inlineType.create(merged)
  const paragraphNode = paragraphType.create(null, inlineNode)
  tr.replaceWith(pos, pos + node.nodeSize, paragraphNode)
  return pos + 1
}

function replaceInlineGraphicWithBlock(
  tr: import("@tiptap/pm/state").Transaction,
  state: import("@tiptap/pm/state").EditorState,
  pos: number,
  node: import("@tiptap/pm/model").Node,
  merged: DocsGraphicAttrs,
  blockType: import("@tiptap/pm/model").NodeType
): number {
  const $pos = state.doc.resolve(pos)
  const parent = $pos.parent
  const blockNode = blockType.create(merged)

  if (parent.type.name === "paragraph" && parent.childCount === 1) {
    const paragraphPos = $pos.before($pos.depth)
    tr.replaceWith(paragraphPos, $pos.after($pos.depth), blockNode)
    return paragraphPos
  }

  const paragraphPos = $pos.before($pos.depth)
  tr.insert(paragraphPos, blockNode)
  const mappedPos = tr.mapping.map(pos)
  tr.delete(mappedPos, mappedPos + node.nodeSize)
  return paragraphPos
}

function replaceGraphicWithAttrs(
  { state, tr, dispatch }: GraphicCommandProps,
  patch: Partial<DocsGraphicAttrs>
): boolean {
  const { selection } = state
  const node = selectedGraphicNode(state)
  if (!node) return false
  const currentName = node.type.name as "docsGraphic" | "docsInlineGraphic"

  const merged = mergeGraphicAttrs({ ...(node.attrs as Record<string, unknown>), ...patch })
  const targetName = graphicNodeName(merged)
  const pos = selection.from
  let selectionPos = pos

  if (targetName === currentName) {
    tr.setNodeMarkup(pos, undefined, merged)
  } else {
    const targetType = state.schema.nodes[targetName]
    if (!targetType) return false
    if (currentName === "docsGraphic" && targetName === "docsInlineGraphic") {
      selectionPos = replaceBlockGraphicWithInline(tr, state, pos, node, merged, targetType)
    } else if (currentName === "docsInlineGraphic" && targetName === "docsGraphic") {
      selectionPos = replaceInlineGraphicWithBlock(tr, state, pos, node, merged, targetType)
    } else {
      tr.replaceWith(pos, pos + node.nodeSize, targetType.create(merged))
      selectionPos = tr.mapping.map(pos, -1)
    }
  }

  restoreGraphicNodeSelection(tr, selectionPos)

  if (dispatch) dispatch(tr)
  return true
}

function updateGraphicAttrs(ctx: GraphicCommandProps, patch: Partial<DocsGraphicAttrs>) {
  if (selectedGraphicNode(ctx.state)) {
    return replaceGraphicWithAttrs(ctx, patch)
  }
  const name = activeGraphicName(ctx.editor)
  if (!name) return false
  return ctx.chain().updateAttributes(name, patch).run()
}

/** Read current on-screen page coords of the selected graphic (DOM measure). */
function measureSelectedGraphicPageCoords(): Partial<DocsGraphicAttrs> | null {
  if (typeof document === "undefined") return null
  const el = document.querySelector(
    ".docs-graphic--selected, .ProseMirror-selectednode .docs-graphic"
  ) as HTMLElement | null
  const stack = document.querySelector("[data-docs-page-stack]") as HTMLElement | null
  if (!el || !stack) return null
  const scale = Number.parseFloat(stack.dataset.docsPageScale ?? "1") || 1
  const pageHeight = Number.parseFloat(stack.dataset.docsPageHeight ?? "0") || 0
  if (pageHeight <= 0) return null
  const coords = bboxToPageCoords(
    el.getBoundingClientRect(),
    stack.getBoundingClientRect(),
    scale,
    pageHeight
  )
  return { pageIndex: coords.pageIndex, pageX: coords.pageX, pageY: coords.pageY }
}

/** Patch parts for promoting the selected graphic to the fixed page layer. */
function fixedOnPagePatch(ctx: GraphicCommandProps): Partial<DocsGraphicAttrs> {
  const patch: Partial<DocsGraphicAttrs> = {
    positionMode: "fixed-on-page",
    placement: "absolute",
  }
  const node = selectedGraphicNode(ctx.state)
  const alreadyOnLayer =
    node && usesPageLayer(node.attrs as Record<string, unknown>)
  if (!alreadyOnLayer) {
    // Keep the graphic where it visually sits when leaving the text flow.
    Object.assign(patch, measureSelectedGraphicPageCoords())
  }
  return patch
}

/** Patch parts for returning the selected graphic to the text flow. */
function moveWithTextPatch(currentWrap: unknown): Partial<DocsGraphicAttrs> {
  const wrap: DocsGraphicWrap =
    currentWrap === "behind" || currentWrap === "in-front"
      ? "square"
      : ((currentWrap as DocsGraphicWrap) ?? "square")
  return {
    positionMode: "move-with-text",
    placement: wrap === "inline" ? "inline" : "block",
    wrap,
  }
}

function graphicCommands() {
  return {
    insertDocsGraphic:
      (partial: Partial<DocsGraphicAttrs>) =>
      ({ chain, editor }: GraphicCommandProps) => {
        const anchorPos = partial.anchorPos ?? editor.state.selection.from
        const attrs = mergeGraphicAttrs({ ...partial, anchorPos })
        const type = graphicNodeName(attrs)
        const ok = chain().focus().insertContent({ type, attrs }).run()
        if (!ok) return false

        let graphicPos: number | null = null
        let bestDist = Infinity
        editor.state.doc.descendants((node, pos) => {
          if (!isGraphicNodeName(node.type.name)) return
          const dist = Math.abs(pos - anchorPos)
          if (dist < bestDist) {
            bestDist = dist
            graphicPos = pos
          }
        })
        if (graphicPos != null) {
          editor.commands.setNodeSelection(graphicPos)
        }
        return true
      },
    updateDocsGraphic:
      (partial: Partial<DocsGraphicAttrs>) =>
      (ctx: GraphicCommandProps) =>
        updateGraphicAttrs(ctx, partial),
    setDocsGraphicWrap:
      (wrap: DocsGraphicWrap) =>
      (ctx: GraphicCommandProps) => {
        let patch: Partial<DocsGraphicAttrs>
        if (wrap === "behind" || wrap === "in-front") {
          patch = { ...fixedOnPagePatch(ctx), wrap }
        } else {
          // Choosing a text-wrap mode puts the graphic back into the flow.
          patch = {
            wrap,
            positionMode: "move-with-text",
            placement: wrap === "inline" ? "inline" : "block",
          }
        }
        return replaceGraphicWithAttrs(ctx, patch)
      },
    setDocsGraphicPlacement:
      (placement: DocsGraphicPlacement) =>
      (ctx: GraphicCommandProps) => {
        if (placement === "absolute") {
          return replaceGraphicWithAttrs(ctx, fixedOnPagePatch(ctx))
        }
        const currentWrap = selectedGraphicNode(ctx.state)?.attrs?.wrap
        const patch = moveWithTextPatch(currentWrap)
        patch.placement = placement
        if (placement === "inline") patch.wrap = "inline"
        return replaceGraphicWithAttrs(ctx, patch)
      },
    setDocsGraphicFloatSide:
      (floatSide: DocsGraphicFloatSide) =>
      (ctx: GraphicCommandProps) =>
        updateGraphicAttrs(ctx, { floatSide }),
    setDocsGraphicPositionMode:
      (positionMode: DocsGraphicPositionMode) =>
      (ctx: GraphicCommandProps) => {
        if (positionMode === "fixed-on-page") {
          return replaceGraphicWithAttrs(ctx, fixedOnPagePatch(ctx))
        }
        const currentWrap = selectedGraphicNode(ctx.state)?.attrs?.wrap
        return replaceGraphicWithAttrs(ctx, moveWithTextPatch(currentWrap))
      },
    setDocsGraphicWrapMargin:
      (wrapMarginMm: number) =>
      (ctx: GraphicCommandProps) =>
        updateGraphicAttrs(ctx, { wrapMarginMm: Math.max(0, wrapMarginMm) }),
    setDocsGraphicAnchor:
      (anchorPos: number) =>
      (ctx: GraphicCommandProps) =>
        updateGraphicAttrs(ctx, { anchorPos }),
    bringDocsGraphicForward:
      () =>
      (ctx: GraphicCommandProps) => {
        const name = activeGraphicName(ctx.editor)
        if (!name) return false
        const z = Number(ctx.editor.getAttributes(name).zIndex ?? 0)
        return ctx.chain().updateAttributes(name, { zIndex: z + 1 }).run()
      },
    sendDocsGraphicBackward:
      () =>
      (ctx: GraphicCommandProps) => {
        const name = activeGraphicName(ctx.editor)
        if (!name) return false
        const z = Number(ctx.editor.getAttributes(name).zIndex ?? 0)
        return ctx.chain().updateAttributes(name, { zIndex: Math.max(0, z - 1) }).run()
      },
  }
}

export const DocsGraphic = Node.create({
  name: "docsGraphic",
  group: "block",
  atom: true,
  draggable: true,
  selectable: true,

  addAttributes() {
    return graphicAttributes
  },

  parseHTML() {
    return [{ tag: 'div[data-type="docs-graphic"]' }]
  },

  renderHTML({ HTMLAttributes }) {
    return ["div", mergeAttributes(HTMLAttributes, { "data-type": "docs-graphic" })]
  },

  addNodeView() {
    return ReactNodeViewRenderer(DocsGraphicNodeView)
  },

  addCommands() {
    return graphicCommands()
  },
})

export const DocsInlineGraphic = Node.create({
  name: "docsInlineGraphic",
  group: "inline",
  inline: true,
  atom: true,
  draggable: true,
  selectable: true,

  addAttributes() {
    return graphicAttributes
  },

  parseHTML() {
    return [{ tag: 'span[data-type="docs-inline-graphic"]' }]
  },

  renderHTML({ HTMLAttributes }) {
    return ["span", mergeAttributes(HTMLAttributes, { "data-type": "docs-inline-graphic" })]
  },

  addNodeView() {
    return ReactNodeViewRenderer(DocsGraphicNodeView)
  },
})

export function buildInsertGraphicAttrs(
  graphicType: DocsGraphicType,
  partial: Partial<DocsGraphicAttrs> = {}
): DocsGraphicAttrs {
  return mergeGraphicAttrs({ ...DOCS_GRAPHIC_DEFAULTS, graphicType, ...partial })
}

/** Build image attrs with a frame sized to the source aspect ratio. */
export async function buildImageInsertGraphicAttrs(
  partial: Partial<DocsGraphicAttrs> & { src: string }
): Promise<DocsGraphicAttrs> {
  const { computeImageInsertFrameSize, readImageNaturalSize } = await import(
    "../docs-graphic-image-size.ts"
  )
  try {
    const natural = await readImageNaturalSize(partial.src)
    const { width, height } = computeImageInsertFrameSize(natural.width, natural.height)
    return buildInsertGraphicAttrs("image", {
      ...partial,
      width,
      height,
      lockAspectRatio: true,
    })
  } catch {
    return buildInsertGraphicAttrs("image", partial)
  }
}

export { mergeGraphicAttrs, graphicNodeName }
