/**
 * Markdown decorations plugin for ProseMirror.
 *
 * Parses markdown text using Lezer and renders decorations for:
 * - Bold, italic, strikethrough, inline code, highlight
 * - Links (hidden delimiters, styled text)
 * - Page references [[Page Name]] and [[Page Name|Alias]]
 * - Block references ((block-id))
 * - Tags #tag
 * - Property lines (key:: value)
 *
 * Marker visibility modes:
 * - 'live-preview': markers hidden when blurred, revealed near cursor when focused
 * - 'always-visible': markers always visible (traditional markdown editor feel)
 */

import type { Tree } from "@lezer/common"
import type { Node as ProsemirrorNode } from "prosemirror-model"
import { Plugin, PluginKey, TextSelection } from "prosemirror-state"
import { Decoration, DecorationSet } from "prosemirror-view"
import { getWidgetFactory } from "@/lib/comark-registry"
import { inlineToString } from "@/lib/content-model"
import { renderKatexSync } from "@/lib/katex"
import { createLogger } from "@/lib/logger"
import {
  type DecorationRange,
  MarkdownRuleEngine,
  type ParsedDecorations,
  parseMarkdown,
} from "@/lib/markdown-parser"
import type { BlockDecoration } from "@/lib/markdown-rules"
import { safeImageSrc } from "@/lib/safe-image-src"

const log = createLogger("Decorations")

export type MarkerVisibilityMode = "live-preview" | "always-visible"

const defaultMarkerMode: MarkerVisibilityMode = "live-preview"

interface ParsedToken {
  type: string
  decorationRanges: DecorationRange[]
  wrapperAttrs?: Record<string, string>
  nodeWrapper?: { className: string; attrs?: Record<string, string> }
  metadata?: Record<string, unknown>
}

/**
 * Cached token for the combined content of all paragraphs in a block.
 * The token array is the source of truth — decorations are derived.
 */
interface BlockTokenCache {
  /** Combined text of all paragraphs (joined with \n) */
  text: string
  /** Cumulative start offsets of each paragraph in the combined text.
   *  For N paragraphs, boundaries has N+1 entries — the last is the
   *  combined text length (excluding the trailing \n on the last para). */
  boundaries: number[]
  /** Lezer AST tree for incremental re-parsing */
  tree?: Tree
  /** Inline tokens (bold, links, refs, etc.) */
  content: ParsedToken[]
  /** Block-level tokens (headings, tasks, etc.) */
  blocks: ParsedToken[]
  /** Property decorations */
  properties: Array<{
    keyRange: [number, number]
    delimiterRange: [number, number]
    valueRange: [number, number]
    value?: string
  }>
}

/**
 * Parse tokens for the combined text of a block. Returns cached structure.
 * Only runs when document content changes.
 * On parse failure, logs a warning and returns an empty cache (raw text).
 */
function parseBlockTokens(
  text: string,
  boundaries: number[],
  engine: MarkdownRuleEngine,
  oldTree?: Tree,
): BlockTokenCache {
  let result: ParsedDecorations & { tree: Tree }
  try {
    result = parseMarkdown(text, engine, oldTree)
  } catch (e) {
    log.warn("parseBlockTokens failed", { error: e, textLen: text.length })
    return {
      text,
      boundaries,
      tree: undefined,
      content: [],
      blocks: [],
      properties: [],
    }
  }
  return {
    text,
    boundaries,
    tree: result.tree,
    content: result.content as ParsedToken[],
    blocks: result.blocks as ParsedToken[],
    properties: result.properties,
  }
}

/**
 * Collect all non-code textblocks from a PM doc.
 */
interface ParagraphInfo {
  node: ProsemirrorNode
  pos: number
  text: string
}

function collectParagraphs(doc: ProsemirrorNode): ParagraphInfo[] {
  const paragraphs: ParagraphInfo[] = []
  doc.descendants((node: ProsemirrorNode, pos: number) => {
    if (!node.isTextblock) return
    if (node.type.spec.code) return
    paragraphs.push({ node, pos, text: inlineToString(node) })
  })
  return paragraphs
}

/**
 * Check if text looks like a GFM table row (starts with |, contains more |).
 */
function isTableRow(text: string): boolean {
  const trimmed = text.trim()
  return trimmed.startsWith("|") && trimmed.indexOf("|", 1) !== -1
}

/**
 * Check if text is a GFM table delimiter row (e.g., |---|---|).
 */
function isTableDelimiter(text: string): boolean {
  const trimmed = text.trim()
  if (!trimmed.startsWith("|") || !trimmed.endsWith("|")) return false
  const inner = trimmed.slice(1, -1).trim()
  return /^[-:| ]+$/.test(inner) && inner.includes("---")
}

/**
 * Determine the separator between paragraph i and i+1.
 * Table rows stay joined with \n so GFM recognizes the full table.
 * Everything else uses \n\n so GFM sees them as separate paragraphs
 * (needed for task/callout detection).
 */
function separatorBetween(paragraphs: ParagraphInfo[], i: number): string {
  if (i >= paragraphs.length - 1) return ""
  const cur = paragraphs[i]!.text.trim()
  const nxt = paragraphs[i + 1]!.text.trim()
  if (isTableRow(cur) && (isTableDelimiter(nxt) || isTableRow(nxt))) return "\n"
  if (isTableDelimiter(cur) && isTableRow(nxt)) return "\n"
  return "\n\n"
}

/**
 * Build the combined text and paragraph boundary offsets.
 * Uses dynamic separators: \n between table rows, \n\n elsewhere.
 * boundaries[0] = 0, boundaries[i] = start offset of paragraph i,
 * boundaries[N] = total length (last para end, no trailing separator).
 */
function buildCombinedText(paragraphs: ParagraphInfo[]): {
  text: string
  boundaries: number[]
} {
  const boundaries: number[] = [0]
  let text = ""
  for (let i = 0; i < paragraphs.length; i++) {
    text += paragraphs[i]?.text
    const sepLen =
      i < paragraphs.length - 1 ? separatorBetween(paragraphs, i).length : 1
    boundaries.push(text.length + sepLen)
    if (i < paragraphs.length - 1) {
      text += separatorBetween(paragraphs, i)
    }
  }
  return { text, boundaries }
}

/**
 * Map a combined-text offset to PM document position.
 * Returns null if the offset falls on a \n separator between paragraphs.
 */
interface ResolvedOffset {
  paraIndex: number
  paraOffset: number
  pmPos: number
}

function resolveOffset(
  combinedOffset: number,
  boundaries: number[],
  paragraphs: ParagraphInfo[],
): ResolvedOffset | null {
  for (let i = 0; i < paragraphs.length; i++) {
    const start = boundaries[i]!
    const end = boundaries[i + 1]!
    // end includes the \n separator, so para text is [start, end - 1)
    // <= accepts end - 1 (the \n) for block-wrapper positioning
    if (combinedOffset >= start && combinedOffset <= end - 1) {
      const paraOffset = combinedOffset - start
      const pmPos = paragraphs[i]!.pos + 1 + paraOffset
      return { paraIndex: i, paraOffset, pmPos }
    }
    // combinedOffset === end - 1 means the \n separator
    // combinedOffset > end - 1 means beyond this paragraph
  }
  return null
}

/**
 * Build decorations from cached tokens.
 * Runs lazily in props.decorations — only when ProseMirror asks for them.
 * Handles live preview by toggling marker visibility based on cursor proximity.
 */
function buildDecorationsFromCache(
  doc: ProsemirrorNode,
  contentCaches: Map<string, BlockTokenCache>,
  isFocused: boolean,
  markerMode: MarkerVisibilityMode,
  resolvedRefs: Set<string>,
): DecorationSet {
  const decorationSpecs: Decoration[] = []

  const paragraphs = collectParagraphs(doc)
  if (paragraphs.length === 0) return DecorationSet.empty

  const { text: combinedText, boundaries } = buildCombinedText(paragraphs)

  const cache = contentCaches.get(combinedText)
  if (!cache) return DecorationSet.empty

  applyCalloutGroupDecorations(
    cache.blocks,
    boundaries,
    paragraphs,
    decorationSpecs,
    isFocused,
    markerMode,
  )

  for (const token of cache.content) {
    applyInlineDecorations(
      token,
      boundaries,
      paragraphs,
      decorationSpecs,
      isFocused,
      markerMode,
      resolvedRefs,
    )
  }

  // Properties — keyed by single-line paragraphs
  if (cache.properties.length > 0) {
    buildPropertyDecorations(
      cache.properties,
      boundaries,
      paragraphs,
      decorationSpecs,
    )
  }

  log.debug("build decorations", {
    count: decorationSpecs.length,
    paragraphs: paragraphs.length,
  })
  return DecorationSet.create(doc, decorationSpecs)
}

/**
 * Apply callout group decorations, merging blockquote continuations into
 * their preceding callout block so they share the same node wrapper class.
 */
function applyCalloutGroupDecorations(
  blocks: ParsedToken[],
  boundaries: number[],
  paragraphs: ParagraphInfo[],
  decorationSpecs: Decoration[],
  isFocused: boolean,
  markerMode: MarkerVisibilityMode,
) {
  let calloutType: string | null = null

  for (const block of blocks) {
    if (block.type === "callout") {
      calloutType =
        (block as BlockDecoration & { type: "callout" }).metadata
          ?.calloutType ?? null
    }

    if (calloutType && block.type === "blockquote") {
      applyBlockDecorations(
        {
          ...block,
          type: "callout" as const,
          nodeWrapper: {
            className: "md-callout",
            attrs: { "data-callout-type": calloutType },
          },
          metadata: { calloutType },
        },
        boundaries,
        paragraphs,
        decorationSpecs,
        isFocused,
        markerMode,
      )
    } else {
      applyBlockDecorations(
        block,
        boundaries,
        paragraphs,
        decorationSpecs,
        isFocused,
        markerMode,
      )
    }

    if (block.type !== "callout" && block.type !== "blockquote") {
      calloutType = null
    }
  }
}

/**
 * Apply table decorations — blurred state hides the raw | pipes and renders
 * an HTML table widget; focused state shows the source with | as muted
 * decorators. Node wrapper is applied in both states.
 */
function applyTableDecorations(
  block: ParsedToken,
  sorted: DecorationRange[],
  boundaries: number[],
  paragraphs: ParagraphInfo[],
  decorationSpecs: Decoration[],
  isFocused: boolean,
) {
  if (block.nodeWrapper) {
    const firstRange = sorted[0]
    const lastRange = sorted[sorted.length - 1]
    if (firstRange && lastRange) {
      const resolvedFirst = resolveOffset(
        firstRange.from,
        boundaries,
        paragraphs,
      )
      const resolvedLast = resolveOffset(lastRange.to, boundaries, paragraphs)
      if (resolvedFirst && resolvedLast) {
        const fromNode = paragraphs[resolvedFirst.paraIndex]!
        const toNode = paragraphs[resolvedLast.paraIndex]!
        decorationSpecs.push(
          Decoration.node(fromNode.pos, toNode.pos + toNode.node.nodeSize, {
            class: block.nodeWrapper.className ?? undefined,
            ...((block.nodeWrapper?.attrs as Record<string, string>) ?? {}),
          }),
        )
      }
    }
  }

  if (!isFocused) {
    const firstRange = sorted[0]
    const tokenStart = firstRange
      ? resolveOffset(firstRange.from, boundaries, paragraphs)
      : null
    const source = block.wrapperAttrs?.["data-table-source"] ?? ""
    for (const range of sorted) {
      const specs = buildDecorationsForRange(range, boundaries, paragraphs)
      for (const spec of specs) {
        decorationSpecs.push(
          Decoration.inline(spec.from, spec.to, {
            class: "md-table-hidden",
          }),
        )
      }
    }
    if (source && tokenStart) {
      decorationSpecs.push(
        Decoration.widget(
          tokenStart.pmPos,
          (view) => {
            const widgetSpan = document.createElement("span")
            widgetSpan.className = "md-table-rendered"
            widgetSpan.innerHTML = renderTableHtml(source)
            widgetSpan.addEventListener("mousedown", (e) => {
              e.preventDefault()
              e.stopPropagation()
              view.dispatch(
                view.state.tr.setSelection(
                  TextSelection.near(view.state.doc.resolve(tokenStart.pmPos)),
                ),
              )
              view.focus()
            })
            return widgetSpan
          },
          { side: -1 },
        ),
      )
    }
    return
  }

  const source = block.wrapperAttrs?.["data-table-source"] ?? ""
  const tableStart = sorted[0]!.from
  let idx = 0
  for (;;) {
    idx = source.indexOf("|", idx)
    if (idx === -1) break
    const combinedFrom = tableStart + idx
    const combinedTo = tableStart + idx + 1
    const specs = buildDecorationsForRange(
      { type: "hidden", from: combinedFrom, to: combinedTo },
      boundaries,
      paragraphs,
    )
    for (const spec of specs) {
      decorationSpecs.push(
        Decoration.inline(spec.from, spec.to, {
          class: "md-decorator",
          ...(block.wrapperAttrs ?? {}),
        }),
      )
    }
    idx++
  }
}

/**
 * Apply decorations for a ::query{...} block (blurred state only).
 * Creates a widget DOM element via the registered factory
 * so query results render inline inside the ProseMirror view.
 */
function applyQueryDecorations(
  block: ParsedToken,
  sorted: DecorationRange[],
  boundaries: number[],
  paragraphs: ParagraphInfo[],
  decorationSpecs: Decoration[],
) {
  // Hide raw syntax (same as tables)
  for (const range of sorted) {
    const specs = buildDecorationsForRange(range, boundaries, paragraphs)
    for (const spec of specs) {
      decorationSpecs.push(
        Decoration.inline(spec.from, spec.to, { class: "md-query-hidden" }),
      )
    }
  }

  // Insert widget after the block if a factory is registered
  const factory = getWidgetFactory("query")
  const rawAttrs = block.wrapperAttrs?.["data-query"]
  if (factory && rawAttrs) {
    const lastRange = sorted[sorted.length - 1]
    if (lastRange) {
      const resolved = resolveOffset(lastRange.to, boundaries, paragraphs)
      if (resolved) {
        let widgetDestroy: (() => void) | undefined
        decorationSpecs.push(
          Decoration.widget(resolved.pmPos, () => {
            const { dom, destroy } = factory(rawAttrs)
            widgetDestroy = destroy
            return dom
          }, { side: 1, destroy: () => widgetDestroy?.() }),
        )
      }
    }
  }
}

/**
 * Apply block-level decorations (node wrapper + hidden/visible ranges).
 */
function applyBlockDecorations(
  block: ParsedToken,
  boundaries: number[],
  paragraphs: ParagraphInfo[],
  decorationSpecs: Decoration[],
  isFocused: boolean,
  markerMode: MarkerVisibilityMode,
) {
  const ranges = block.decorationRanges
  if (ranges.length === 0 && !block.nodeWrapper) return

  const sorted = [...ranges].sort((a, b) => a.from - b.from)

  if (block.type === "table") {
    applyTableDecorations(
      block,
      sorted,
      boundaries,
      paragraphs,
      decorationSpecs,
      isFocused,
    )
    return
  }

  if (block.type === "query" && !isFocused) {
    applyQueryDecorations(
      block,
      sorted,
      boundaries,
      paragraphs,
      decorationSpecs,
    )
    return
  }

  // ── Blockquote / callout continuation merge support ──
  if (block.nodeWrapper) {
    const firstRange = sorted[0]
    const lastRange = sorted[sorted.length - 1]
    if (firstRange && lastRange) {
      const resolvedFirst = resolveOffset(
        firstRange.from,
        boundaries,
        paragraphs,
      )
      // lastRange.to may point at a \n separator → resolveOffset returns null
      // (node wrapper is silently dropped — fine for block text ranges).
      const resolvedLast = resolveOffset(lastRange.to, boundaries, paragraphs)
      if (resolvedFirst && resolvedLast) {
        const fromNode = paragraphs[resolvedFirst.paraIndex]!
        const toNode = paragraphs[resolvedLast.paraIndex]!
        const startPos = fromNode.pos
        const endPos = toNode.pos + toNode.node.nodeSize
        const wrapperClassName = block.nodeWrapper?.className
        decorationSpecs.push(
          Decoration.node(startPos, endPos, {
            class: wrapperClassName ?? undefined,
            ...((block.nodeWrapper?.attrs as Record<string, string>) ?? {}),
          }),
        )
      }
    }
  }

  // ── Decorations for each range ──
  for (const range of sorted) {
    const specs = buildDecorationsForRange(range, boundaries, paragraphs)
    for (const spec of specs) {
      const isHiddenDecorator = range.type === "hidden"

      let rangeClass =
        range.className ??
        (isHiddenDecorator ? "md-decorator" : `md-${range.type}`)

      if (isHiddenDecorator) {
        const baseClass = range.className ?? "md-decorator"
        if (markerMode === "always-visible") {
          rangeClass = baseClass
        } else if (markerMode === "live-preview") {
          rangeClass = isFocused
            ? baseClass
            : `${baseClass} md-decorator-hidden`
        }
      }

      decorationSpecs.push(
        Decoration.inline(spec.from, spec.to, {
          nodeName: "span",
          class: rangeClass,
          ...(block.wrapperAttrs ?? {}),
        }),
      )
    }
  }
}

/**
 * Apply inline token decorations (bold, italic, links, tags, etc.).
 */
function applyInlineDecorations(
  token: ParsedToken,
  boundaries: number[],
  paragraphs: ParagraphInfo[],
  decorationSpecs: Decoration[],
  isFocused: boolean,
  markerMode: MarkerVisibilityMode,
  resolvedRefs: Set<string>,
) {
  const ranges = token.decorationRanges
  if (ranges.length === 0) return

  const sorted = [...ranges].sort((a, b) => a.from - b.from)

  // ── Inline math: show KaTeX when blurred; show source when focused ──
  if (token.type === "inline-math") {
    if (!isFocused) {
      const firstRange = sorted[0]
      const tokenStart = firstRange
        ? resolveOffset(firstRange.from, boundaries, paragraphs)
        : null
      const source = token.wrapperAttrs?.["data-math"] ?? ""
      for (const range of sorted) {
        const specs = buildDecorationsForRange(range, boundaries, paragraphs)
        for (const spec of specs) {
          decorationSpecs.push(
            Decoration.inline(spec.from, spec.to, {
              class: "md-math-hidden",
            }),
          )
        }
      }
      if (source && tokenStart) {
        decorationSpecs.push(
          Decoration.widget(
            tokenStart.pmPos,
            (view) => {
              const widgetSpan = document.createElement("span")
              widgetSpan.className = "md-math-rendered"
              // If KaTeX fails, show the raw source as an error decoration
              // instead of letting the exception propagate.
              try {
                renderKatexSync(source, widgetSpan, false)
              } catch {
                widgetSpan.textContent = source
                widgetSpan.className = "md-math-error"
                widgetSpan.setAttribute("aria-errormessage", "math-parse-error")
              }
              widgetSpan.addEventListener("mousedown", (e) => {
                e.preventDefault()
                e.stopPropagation()
                view.dispatch(
                  view.state.tr.setSelection(
                    TextSelection.near(
                      view.state.doc.resolve(tokenStart.pmPos),
                    ),
                  ),
                )
                view.focus()
              })
              return widgetSpan
            },
            { side: 1 },
          ),
        )
      }
      return
    }
  }

  // ── Image: show <img> when blurred; show source when focused ──
  if (token.type === "image") {
    if (!isFocused) {
      const firstRange = sorted[0]
      const tokenStart = firstRange
        ? resolveOffset(firstRange.from, boundaries, paragraphs)
        : null
      const src = safeImageSrc(token.wrapperAttrs?.["data-src"] ?? "")
      const alt = token.wrapperAttrs?.["data-alt"] ?? ""
      for (const range of sorted) {
        const specs = buildDecorationsForRange(range, boundaries, paragraphs)
        for (const spec of specs) {
          decorationSpecs.push(
            Decoration.inline(spec.from, spec.to, {
              class: "md-image-hidden",
            }),
          )
        }
      }
      if (src && tokenStart) {
        decorationSpecs.push(
          Decoration.widget(
            tokenStart.pmPos,
            (view) => {
              const wrapper = document.createElement("span")
              wrapper.className = "md-image-wrapper"
              const img = document.createElement("img")
              img.src = src
              img.alt = alt
              img.className = "md-image-rendered"
              img.loading = "lazy"
              wrapper.appendChild(img)
              wrapper.addEventListener("mousedown", (e) => {
                e.preventDefault()
                e.stopPropagation()
                const $pos = view.state.doc.resolve(tokenStart.pmPos)
                view.dispatch(
                  view.state.tr.setSelection(TextSelection.near($pos)),
                )
                view.focus()
              })
              return wrapper
            },
            { side: -1 },
          ),
        )
      }
      return
    }
  }

  for (const range of sorted) {
    const specs = buildDecorationsForRange(range, boundaries, paragraphs)

    for (const spec of specs) {
      const isHiddenDecorator = range.type === "hidden"

      let rangeClass =
        range.className ??
        (isHiddenDecorator ? "md-decorator" : `md-${range.type}`)

      if (isHiddenDecorator) {
        const baseClass = range.className ?? "md-decorator"
        if (markerMode === "always-visible") {
          rangeClass = baseClass
        } else if (markerMode === "live-preview") {
          rangeClass = isFocused
            ? baseClass
            : `${baseClass} md-decorator-hidden`
        }
      }

      let extraAttrs: Record<string, string> = {}

      // Unresolved reference detection: if a page-ref or block-ref
      // chip's target is absent from resolvedRefs, style it with a
      // dashed underline and title tooltip.
      if (
        (token.type === "page-ref" || token.type === "block-ref") &&
        !isHiddenDecorator
      ) {
        const refName =
          token.type === "page-ref"
            ? token.wrapperAttrs?.["data-page-name"]
            : token.wrapperAttrs?.["data-block-id"]
        if (refName && !resolvedRefs.has(refName)) {
          rangeClass = `${rangeClass} md-ref-unresolved`
          extraAttrs = { title: "Unresolved reference" }
        }
      }

      decorationSpecs.push(
        Decoration.inline(spec.from, spec.to, {
          nodeName: "span",
          class: rangeClass,
          ...extraAttrs,
          ...(token.wrapperAttrs ?? {}),
        }),
      )
    }
  }
}

/**
 * Map a single decoration range (combined-text offsets) to PM-relative specs,
 * splitting across paragraph boundaries if needed.
 */
function buildDecorationsForRange(
  range: DecorationRange,
  boundaries: number[],
  paragraphs: ParagraphInfo[],
): Array<{ from: number; to: number; className?: string }> {
  const specs: Array<{ from: number; to: number }> = []
  let pos = range.from

  while (pos < range.to) {
    // Find paragraph containing this offset
    let paraIdx = -1
    let paraStart = 0
    let paraEnd = 0
    for (let i = 0; i < paragraphs.length; i++) {
      const s = boundaries[i]!
      const e = boundaries[i + 1]! // includes the \n separator
      if (pos >= s && pos < e - 1) {
        paraIdx = i
        paraStart = s
        paraEnd = e - 1
        break
      }
      if (pos >= s && pos === e - 1 && pos < range.to) {
        pos = e
      }
    }

    if (paraIdx < 0) break

    const segmentEnd = Math.min(range.to, paraEnd)
    const localFrom = pos - paraStart
    const localTo = segmentEnd - paraStart
    const pmPos = paragraphs[paraIdx]!.pos + 1
    specs.push({ from: pmPos + localFrom, to: pmPos + localTo })
    pos = segmentEnd
  }

  return specs
}

/**
 * Build property decorations (key:: value).
 */
function buildPropertyDecorations(
  properties: Array<{
    keyRange: [number, number]
    delimiterRange: [number, number]
    valueRange: [number, number]
    value?: string
  }>,
  boundaries: number[],
  paragraphs: ParagraphInfo[],
  decorationSpecs: Decoration[],
) {
  const propertyParagraphs = new Set<number>()
  for (const prop of properties) {
    const resolved = resolveOffset(prop.keyRange[0], boundaries, paragraphs)
    if (!resolved) continue

    const paraIdx = resolved.paraIndex
    if (!propertyParagraphs.has(paraIdx)) {
      propertyParagraphs.add(paraIdx)
      const para = paragraphs[paraIdx]!
      decorationSpecs.push(
        Decoration.node(para.pos, para.pos + para.node.nodeSize, {
          class: "md-property",
        }),
      )
    }
    const base = paragraphs[paraIdx]!.pos + 1 - boundaries[paraIdx]!

    const [keyFrom, keyTo] = prop.keyRange
    const [delimFrom, delimTo] = prop.delimiterRange
    const [valueFrom, valueTo] = prop.valueRange

    decorationSpecs.push(
      Decoration.inline(base + keyFrom, base + keyTo, {
        class: "md-property-key",
      }),
    )
    decorationSpecs.push(
      Decoration.inline(base + delimFrom, base + delimTo, {
        class: "md-property-delimiter",
      }),
    )

    if (prop.value) {
      decorationSpecs.push(
        Decoration.inline(base + valueFrom, base + valueTo, {
          class: "md-property-value",
        }),
      )
    }
  }
}

/**
 * Render a GFM table string to an HTML table.
 */
function renderTableHtml(source: string): string {
  const lines = source.split("\n").filter((l) => l.trim().length > 0)
  if (lines.length < 2) return ""

  // line[1] is expected to be the GFM separator row (|---|)
  // If it doesn't look like one, skip it to avoid rendering dashes as cells
  if (lines.length >= 2 && !/^[\s:|:-]+$/.test(lines[1]!)) {
    lines.splice(1, 1)
  }

  const rows: string[] = []

  const headerCells = parseRow(lines[0]!)
  rows.push(
    `<thead><tr>${headerCells.map((c) => `<th>${renderCellContent(c.trim())}</th>`).join("")}</tr></thead>`,
  )

  if (lines.length > 2) {
    const body: string[] = []
    for (let i = 2; i < lines.length; i++) {
      const cells = parseRow(lines[i]!)
      body.push(
        `<tr>${cells.map((c) => `<td>${renderCellContent(c.trim())}</td>`).join("")}</tr>`,
      )
    }
    rows.push(`<tbody>${body.join("")}</tbody>`)
  }

  return `<table>${rows.join("")}</table>`
}

function parseRow(line: string): string[] {
  const trimmed = line.trim()
  const content = trimmed.startsWith("|") ? trimmed.slice(1) : trimmed
  const endContent = content.endsWith("|") ? content.slice(0, -1) : content
  // Split on | but not inside escaped pipes
  return endContent.split(/(?<!\\)\|/).map((c) => c.trim())
}

function escapeHtml(s: string): string {
  return s
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
}

interface InlineTagInfo {
  open: string
  close: string
}

/**
 * Map an inline token to its HTML open/close tags.
 * For links, extracts the URL from the decoration ranges.
 */
function getInlineTagInfo(
  token: { type: string; decorationRanges: DecorationRange[] },
  text: string,
): InlineTagInfo | null {
  switch (token.type) {
    case "strong":
      return { open: "<strong>", close: "</strong>" }
    case "em":
      return { open: "<em>", close: "</em>" }
    case "code":
      return { open: "<code>", close: "</code>" }
    case "strike":
      return { open: "<s>", close: "</s>" }
    case "highlight":
      return { open: "<mark>", close: "</mark>" }
    case "link": {
      const urlRange = token.decorationRanges[2]
      if (urlRange?.type === "hidden") {
        const url = text.slice(urlRange.from + 2, urlRange.to - 1)
        return { open: `<a href="${escapeHtml(url)}">`, close: "</a>" }
      }
      return null
    }
    case "page-ref":
      return { open: '<span class="md-page-ref">', close: "</span>" }
    case "block-ref":
      return { open: '<span class="md-block-ref">', close: "</span>" }
    case "tag":
      return { open: '<span class="md-tag">', close: "</span>" }
    case "image": {
      const urlRange = token.decorationRanges[2]
      if (urlRange?.type === "hidden") {
        const url = text.slice(urlRange.from + 2, urlRange.to - 1)
        const safe = safeImageSrc(url)
        if (safe)
          return {
            open: `<img src="${escapeHtml(safe)}" class="md-image-rendered" loading="lazy">`,
            close: "",
          }
      }
      return null
    }
    default:
      return null
  }
}

const cellParseCache = new Map<string, ParsedDecorations>()
const CELL_CACHE_MAX = 50

/**
 * Render inline markdown to HTML for table cells.
 * Uses the existing Lezer-based parser to extract tokens,
 * then builds HTML by skipping hidden delimiter positions
 * and wrapping content ranges in the appropriate tags.
 *
 * Cell parse results are cached by text content to avoid redundant
 * Lezer parses on every decoration rebuild.
 */
function renderCellContent(text: string): string {
  if (!text) return ""
  let result = cellParseCache.get(text)
  if (!result) {
    result = parseMarkdown(text)
    if (cellParseCache.size >= CELL_CACHE_MAX) {
      const firstKey = cellParseCache.keys().next().value
      if (firstKey !== undefined) cellParseCache.delete(firstKey)
    }
    cellParseCache.set(text, result)
  }
  if (result.content.length === 0) return escapeHtml(text)

  const hidden = new Set<number>()
  const opens: Record<number, string> = {}
  const closes: Record<number, string> = {}

  for (const token of result.content) {
    if (token.type === "inline-math") {
      const contentRange = token.decorationRanges.find(
        (r) => r.type === "content",
      )
      if (contentRange) {
        for (const range of token.decorationRanges) {
          for (let i = range.from; i < range.to; i++) hidden.add(i)
        }
        const mathSource = text.slice(contentRange.from, contentRange.to)
        const temp = document.createElement("span")
        // If KaTeX fails in a table cell, fall back to escaped source.
        try {
          renderKatexSync(mathSource, temp, false)
          opens[contentRange.from] =
            (opens[contentRange.from] ?? "") + temp.innerHTML
        } catch {
          opens[contentRange.from] =
            `${opens[contentRange.from] ?? ""}<span class="md-math-error">${escapeHtml(mathSource)}</span>`
        }
      }
      continue
    }

    const tagInfo = getInlineTagInfo(token, text)
    if (!tagInfo) continue

    for (const range of token.decorationRanges) {
      if (range.type === "hidden") {
        for (let i = range.from; i < range.to; i++) hidden.add(i)
      } else if (range.type === "content") {
        opens[range.from] = (opens[range.from] ?? "") + tagInfo.open
        closes[range.to] = (closes[range.to] ?? "") + tagInfo.close
      }
    }
  }

  let html = ""
  for (let pos = 0; pos < text.length; pos++) {
    if (closes[pos]) html += closes[pos]
    if (opens[pos]) html += opens[pos]
    if (!hidden.has(pos)) html += escapeHtml(text[pos]!)
  }
  if (closes[text.length]) html += closes[text.length]

  return html
}

// ── Plugin ──────────────────────────────────────────────────────────

interface DecorationState {
  engine: MarkdownRuleEngine
  contentCaches: Map<string, BlockTokenCache>
  hasFocus: boolean
  markerMode: MarkerVisibilityMode
  decorationVersion: number
  resolvedRefs: Set<string>
  resolvedRefsVersion: number
}

const markdownDecorationsKey = new PluginKey<DecorationState>(
  "markdownDecorations",
)

/**
 * Invalidate caches when document content changes.
 * Cache is keyed by the combined text of all paragraphs.
 */
function invalidateChangedCaches(
  newDoc: ProsemirrorNode,
  _oldDoc: ProsemirrorNode,
  oldCaches: Map<string, BlockTokenCache>,
  engine: MarkdownRuleEngine,
): Map<string, BlockTokenCache> {
  const newCaches = new Map<string, BlockTokenCache>()
  let hitCount = 0
  let missCount = 0

  const paragraphs = collectParagraphs(newDoc)
  const combined = buildCombinedText(paragraphs)
  const combinedText = combined.text
  const boundaries = combined.boundaries

  if (!combinedText) {
    // Empty block — no paragraphs
    return newCaches
  }

  const oldCache = oldCaches.get(combinedText)
  if (oldCache) {
    hitCount++
    newCaches.set(combinedText, oldCache)
  } else {
    missCount++
    // Force full re-parse (no incremental) — incremental Lezer reuses old tree
    // fragments, which prevents GFM table detection when a Paragraph was
    // classified before the delimiter line existed.
    const parsed = parseBlockTokens(combinedText, boundaries, engine, undefined)
    newCaches.set(combinedText, parsed)
  }

  log.debug("cache invalidate", {
    hitCount,
    missCount,
    total: hitCount + missCount,
  })
  return newCaches
}

export function createMarkdownDecorationsPlugin(
  initialMarkerMode: MarkerVisibilityMode = defaultMarkerMode,
) {
  let cachedSet: DecorationSet | null = null
  let cachedVersion = -1

  const plugin = new Plugin<DecorationState>({
    key: markdownDecorationsKey,
    state: {
      init(_, state) {
        const engine = new MarkdownRuleEngine()
        const contentCaches = new Map<string, BlockTokenCache>()

        const paragraphs = collectParagraphs(state.doc)
        const combined = buildCombinedText(paragraphs)
        const combinedText = combined.text
        const boundaries = combined.boundaries
        if (combinedText) {
          contentCaches.set(
            combinedText,
            parseBlockTokens(combinedText, boundaries, engine),
          )
        }

        return {
          engine,
          contentCaches,
          hasFocus: false,
          markerMode: initialMarkerMode,
          decorationVersion: 0,
          resolvedRefs: new Set<string>(),
          resolvedRefsVersion: 0,
        }
      },

      apply(tr, prevState, _oldState, newState) {
        let hasFocus = prevState.hasFocus
        let markerMode = prevState.markerMode
        let decorationVersion = prevState.decorationVersion
        let contentCaches = prevState.contentCaches
        let resolvedRefs = prevState.resolvedRefs
        let resolvedRefsVersion = prevState.resolvedRefsVersion
        let docChanged = false

        const focusMeta = tr.getMeta("decorations:focus")
        if (focusMeta !== undefined && focusMeta !== prevState.hasFocus) {
          hasFocus = focusMeta as boolean
          decorationVersion++
        }

        const modeMeta = tr.getMeta("markerMode")
        if (modeMeta !== undefined) {
          markerMode = modeMeta as MarkerVisibilityMode
          decorationVersion++
        }

        const refsMeta = tr.getMeta("decorations:resolvedRefs")
        if (refsMeta !== undefined) {
          const payload = refsMeta as {
            refs: Set<string>
            version: number
          }
          resolvedRefs = payload.refs
          resolvedRefsVersion = payload.version
          decorationVersion++
        }

        if (tr.docChanged) {
          contentCaches = invalidateChangedCaches(
            newState.doc,
            _oldState.doc,
            prevState.contentCaches,
            prevState.engine,
          )
          decorationVersion++
          docChanged = true
        }

        if (
          docChanged ||
          hasFocus !== prevState.hasFocus ||
          markerMode !== prevState.markerMode ||
          decorationVersion !== prevState.decorationVersion
        ) {
          return {
            engine: prevState.engine,
            contentCaches,
            hasFocus,
            markerMode,
            decorationVersion,
            resolvedRefs,
            resolvedRefsVersion,
          }
        }

        return prevState
      },
    },

    props: {
      decorations(state) {
        const pluginState = markdownDecorationsKey.getState(state)
        if (!pluginState) return DecorationSet.empty

        if (cachedSet && cachedVersion === pluginState.decorationVersion) {
          return cachedSet
        }

        cachedSet = buildDecorationsFromCache(
          state.doc,
          pluginState.contentCaches,
          pluginState.hasFocus,
          pluginState.markerMode,
          pluginState.resolvedRefs,
        )
        cachedVersion = pluginState.decorationVersion

        return cachedSet
      },
    },
  })

  return { plugin }
}

export type { DecorationSet }
