import type { Node as PMNode } from "@tiptap/pm/model"
import { clsx, type ClassValue } from "clsx"
import {
  AllSelection,
  NodeSelection,
  Selection,
  TextSelection,
} from "@tiptap/pm/state"
import type { Editor } from "@tiptap/react"

import { toKhmerDigits } from "@/lib/number-utils"

/**
 * Every word the rich text editor shows the user, in both app languages.
 *
 * The editor was added from a third-party template, so each of its buttons had
 * its English label written inside it. They all read from this one list now, so
 * the toolbar follows the language switch in the header like the rest of the app.
 */
export const editorLabels = {
  en: {
    toolbar: "Toolbar",
    undo: "Undo",
    redo: "Redo",
    heading: "Heading",
    headingAria: "Format text as heading",
    // {number} is replaced with the heading level, 1 to 4.
    headingLevel: "Heading {number}",
    list: "List",
    listAria: "List options",
    bulletList: "Bullet List",
    orderedList: "Ordered List",
    taskList: "Task List",
    blockquote: "Blockquote",
    bold: "Bold",
    italic: "Italic",
    strike: "Strike",
    underline: "Underline",
    highlight: "Highlight",
    highlightAria: "Highlight text",
    highlightColors: "Highlight colors",
    removeHighlight: "Remove highlight",
    link: "Link",
    pasteLink: "Paste a link...",
    applyLink: "Apply link",
    openLink: "Open in new window",
    removeLink: "Remove link",
    alignLeft: "Align left",
    alignCenter: "Align center",
    alignRight: "Align right",
    alignJustify: "Align justify",
    colorDefault: "Default background",
    colorGray: "Gray background",
    colorBrown: "Brown background",
    colorOrange: "Orange background",
    colorYellow: "Yellow background",
    colorGreen: "Green background",
    colorBlue: "Blue background",
    colorPurple: "Purple background",
    colorPink: "Pink background",
    colorRed: "Red background",
  },
  kh: {
    toolbar: "របារឧបករណ៍",
    undo: "ត្រឡប់ក្រោយ",
    redo: "ធ្វើឡើងវិញ",
    heading: "ចំណងជើង",
    headingAria: "ធ្វើទ្រង់ទ្រាយអត្ថបទជាចំណងជើង",
    headingLevel: "ចំណងជើងទី{number}",
    list: "បញ្ជី",
    listAria: "ជម្រើសបញ្ជី",
    bulletList: "បញ្ជីចំណុច",
    orderedList: "បញ្ជីលេខរៀង",
    taskList: "បញ្ជីកិច្ចការ",
    blockquote: "អត្ថបទដកស្រង់",
    bold: "អក្សរដិត",
    italic: "អក្សរទ្រេត",
    strike: "អក្សរឆូត",
    underline: "អក្សរគូសបន្ទាត់ក្រោម",
    highlight: "បន្លិចអត្ថបទ",
    highlightAria: "បន្លិចអត្ថបទ",
    highlightColors: "ពណ៌បន្លិច",
    removeHighlight: "លុបការបន្លិច",
    link: "តំណ",
    pasteLink: "បិទភ្ជាប់តំណ...",
    applyLink: "អនុវត្តតំណ",
    openLink: "បើកក្នុងផ្ទាំងថ្មី",
    removeLink: "លុបតំណ",
    alignLeft: "តម្រឹមឆ្វេង",
    alignCenter: "តម្រឹមកណ្តាល",
    alignRight: "តម្រឹមស្តាំ",
    alignJustify: "តម្រឹមសងខាង",
    colorDefault: "ផ្ទៃខាងក្រោយលំនាំដើម",
    colorGray: "ផ្ទៃខាងក្រោយពណ៌ប្រផេះ",
    colorBrown: "ផ្ទៃខាងក្រោយពណ៌ត្នោត",
    colorOrange: "ផ្ទៃខាងក្រោយពណ៌ទឹកក្រូច",
    colorYellow: "ផ្ទៃខាងក្រោយពណ៌លឿង",
    colorGreen: "ផ្ទៃខាងក្រោយពណ៌បៃតង",
    colorBlue: "ផ្ទៃខាងក្រោយពណ៌ខៀវ",
    colorPurple: "ផ្ទៃខាងក្រោយពណ៌ស្វាយ",
    colorPink: "ផ្ទៃខាងក្រោយពណ៌ផ្កាឈូក",
    colorRed: "ផ្ទៃខាងក្រោយពណ៌ក្រហម",
  },
} as const

export type EditorLabelKey = keyof (typeof editorLabels)["en"]

/** True for both spellings of Khmer the app uses ("kh" in the UI, "km" in the API). */
function isKhmer(language: string): boolean {
  return language === "kh" || language === "km"
}

/** One editor word, in the language the user picked in the header. */
export function tEditor(language: string, key: EditorLabelKey): string {
  return isKhmer(language) ? editorLabels.kh[key] : editorLabels.en[key]
}

/**
 * "Heading 2" in English, "ចំណងជើងទី២" in Khmer -- Khmer spells the number with
 * its own digits, the same way dates and tables do elsewhere in the app.
 */
export function tEditorHeading(language: string, level: number): string {
  return tEditor(language, "headingLevel").replace(
    "{number}",
    isKhmer(language) ? toKhmerDigits(level) : String(level)
  )
}

export const MAC_SYMBOLS: Record<string, string> = {
  mod: "⌘",
  command: "⌘",
  meta: "⌘",
  ctrl: "⌃",
  control: "⌃",
  alt: "⌥",
  option: "⌥",
  shift: "⇧",
  backspace: "Del",
  delete: "⌦",
  enter: "⏎",
  escape: "⎋",
  capslock: "⇪",
} as const

export function cn(...inputs: ClassValue[]): string {
  return clsx(inputs)
}

/**
 * Determines if the current platform is macOS
 * @returns boolean indicating if the current platform is Mac
 */
export function isMac(): boolean {
  return (
    typeof navigator !== "undefined" &&
    navigator.platform.toLowerCase().includes("mac")
  )
}

/**
 * Formats a shortcut key based on the platform (Mac or non-Mac)
 * @param key - The key to format (e.g., "ctrl", "alt", "shift")
 * @param isMac - Boolean indicating if the platform is Mac
 * @param capitalize - Whether to capitalize the key (default: true)
 * @returns Formatted shortcut key symbol
 */
export const formatShortcutKey = (
  key: string,
  isMac: boolean,
  capitalize: boolean = true
) => {
  if (isMac) {
    const lowerKey = key.toLowerCase()
    return MAC_SYMBOLS[lowerKey] || (capitalize ? key.toUpperCase() : key)
  }

  return capitalize ? key.charAt(0).toUpperCase() + key.slice(1) : key
}

/**
 * Parses a shortcut key string into an array of formatted key symbols
 * @param shortcutKeys - The string of shortcut keys (e.g., "ctrl-alt-shift")
 * @param delimiter - The delimiter used to split the keys (default: "-")
 * @param capitalize - Whether to capitalize the keys (default: true)
 * @returns Array of formatted shortcut key symbols
 */
export const parseShortcutKeys = (props: {
  shortcutKeys: string | undefined
  delimiter?: string
  capitalize?: boolean
}) => {
  const { shortcutKeys, delimiter = "+", capitalize = true } = props

  if (!shortcutKeys) return []

  return shortcutKeys
    .split(delimiter)
    .map((key) => key.trim())
    .map((key) => formatShortcutKey(key, isMac(), capitalize))
}

/**
 * Checks if a mark exists in the editor schema
 * @param markName - The name of the mark to check
 * @param editor - The editor instance
 * @returns boolean indicating if the mark exists in the schema
 */
export const isMarkInSchema = (
  markName: string,
  editor: Editor | null
): boolean => {
  if (!editor?.schema) return false
  return editor.schema.spec.marks.get(markName) !== undefined
}

/**
 * Checks if a node exists in the editor schema
 * @param nodeName - The name of the node to check
 * @param editor - The editor instance
 * @returns boolean indicating if the node exists in the schema
 */
export const isNodeInSchema = (
  nodeName: string,
  editor: Editor | null
): boolean => {
  if (!editor?.schema) return false
  return editor.schema.spec.nodes.get(nodeName) !== undefined
}

/**
 * Moves the focus to the next node in the editor
 * @param editor - The editor instance
 * @returns boolean indicating if the focus was moved
 */
export function focusNextNode(editor: Editor) {
  const { state, view } = editor
  const { doc, selection } = state

  const nextSel = Selection.findFrom(selection.$to, 1, true)
  if (nextSel) {
    view.dispatch(state.tr.setSelection(nextSel).scrollIntoView())
    return true
  }

  const paragraphType = state.schema.nodes.paragraph
  if (!paragraphType) {
    console.warn("No paragraph node type found in schema.")
    return false
  }

  const end = doc.content.size
  const para = paragraphType.create()
  let tr = state.tr.insert(end, para)

  // Place the selection inside the new paragraph
  const $inside = tr.doc.resolve(end + 1)
  tr = tr.setSelection(TextSelection.near($inside)).scrollIntoView()
  view.dispatch(tr)
  return true
}

/**
 * Checks if a value is a valid number (not null, undefined, or NaN)
 * @param value - The value to check
 * @returns boolean indicating if the value is a valid number
 */
export function isValidPosition(pos: number | null | undefined): pos is number {
  return typeof pos === "number" && pos >= 0
}

/**
 * Checks if one or more extensions are registered in the Tiptap editor.
 * @param editor - The Tiptap editor instance
 * @param extensionNames - A single extension name or an array of names to check
 * @returns True if at least one of the extensions is available, false otherwise
 */
// This check runs from render/selection paths, so an absent extension would
// otherwise repeat the hint on every update — remember what was already
// reported and say it once.
const warnedMissingExtensions = new Set<string>()

export function isExtensionAvailable(
  editor: Editor | null,
  extensionNames: string | string[]
): boolean {
  if (!editor || editor.isDestroyed) return false

  const names = Array.isArray(extensionNames)
    ? extensionNames
    : [extensionNames]

  const extensions = editor.extensionManager?.extensions
  if (!extensions) return false

  const found = names.some((name) =>
    extensions.some((ext) => ext.name === name)
  )

  if (!found) {
    const key = names.join(", ")

    if (!warnedMissingExtensions.has(key)) {
      warnedMissingExtensions.add(key)
      console.warn(
        `None of the extensions [${key}] were found in the editor schema. Ensure they are included in the editor configuration.`
      )
    }
  }

  return found
}

/**
 * Finds a node at the specified position with error handling
 * @param editor The Tiptap editor instance
 * @param position The position in the document to find the node
 * @returns The node at the specified position, or null if not found
 */
export function findNodeAtPosition(editor: Editor, position: number) {
  try {
    const node = editor.state.doc.nodeAt(position)
    if (!node) {
      console.warn(`No node found at position ${position}`)
      return null
    }
    return node
  } catch (error) {
    console.error(`Error getting node at position ${position}:`, error)
    return null
  }
}

/**
 * Finds the position and instance of a node in the document
 * @param props Object containing editor, node (optional), and nodePos (optional)
 * @param props.editor The Tiptap editor instance
 * @param props.node The node to find (optional if nodePos is provided)
 * @param props.nodePos The position of the node to find (optional if node is provided)
 * @returns An object with the position and node, or null if not found
 */
export function findNodePosition(props: {
  editor: Editor | null
  node?: PMNode | null
  nodePos?: number | null
}): { pos: number; node: PMNode } | null {
  const { editor, node, nodePos } = props

  if (!editor || !editor.state?.doc) return null

  // Zero is valid position
  const hasValidNode = node !== undefined && node !== null
  const hasValidPos = isValidPosition(nodePos)

  if (!hasValidNode && !hasValidPos) {
    return null
  }

  // First search for the node in the document if we have a node
  if (hasValidNode) {
    let foundPos = -1
    let foundNode: PMNode | null = null

    editor.state.doc.descendants((currentNode, pos) => {
      // TODO: Needed?
      // if (currentNode.type && currentNode.type.name === node!.type.name) {
      if (currentNode === node) {
        foundPos = pos
        foundNode = currentNode
        return false
      }
      return true
    })

    if (foundPos !== -1 && foundNode !== null) {
      return { pos: foundPos, node: foundNode }
    }
  }

  // If we have a valid position, use findNodeAtPosition
  if (hasValidPos) {
    const nodeAtPos = findNodeAtPosition(editor, nodePos!)
    if (nodeAtPos) {
      return { pos: nodePos!, node: nodeAtPos }
    }
  }

  return null
}

/**
 * Determines whether the current selection contains a node whose type matches
 * any of the provided node type names.
 * @param editor Tiptap editor instance
 * @param nodeTypeNames List of node type names to match against
 * @param checkAncestorNodes Whether to check ancestor node types up the depth chain
 */
export function isNodeTypeSelected(
  editor: Editor | null,
  nodeTypeNames: string[] = [],
  checkAncestorNodes: boolean = false
): boolean {
  if (!editor || !editor.state.selection) return false

  const { selection } = editor.state
  if (selection.empty) return false

  // Direct node selection check
  if (selection instanceof NodeSelection) {
    const selectedNode = selection.node
    return selectedNode ? nodeTypeNames.includes(selectedNode.type.name) : false
  }

  // Depth-based ancestor node check
  if (checkAncestorNodes) {
    const { $from } = selection
    for (let depth = $from.depth; depth > 0; depth--) {
      const ancestorNode = $from.node(depth)
      if (nodeTypeNames.includes(ancestorNode.type.name)) {
        return true
      }
    }
  }

  return false
}

/**
 * Check whether the current selection is fully within nodes
 * whose type names are in the provided `types` list.
 *
 * - NodeSelection → checks the selected node.
 * - Text/AllSelection → ensures all textblocks within [from, to) are allowed.
 */
export function selectionWithinConvertibleTypes(
  editor: Editor,
  types: string[] = []
): boolean {
  if (!editor || types.length === 0) return false

  const { state } = editor
  const { selection } = state
  const allowed = new Set(types)

  if (selection instanceof NodeSelection) {
    const nodeType = selection.node?.type?.name
    return !!nodeType && allowed.has(nodeType)
  }

  if (selection instanceof TextSelection || selection instanceof AllSelection) {
    let valid = true
    state.doc.nodesBetween(selection.from, selection.to, (node) => {
      if (node.isTextblock && !allowed.has(node.type.name)) {
        valid = false
        return false // stop early
      }
      return valid
    })
    return valid
  }

  return false
}

type ProtocolOptions = {
  /**
   * The protocol scheme to be registered.
   * @default '''
   * @example 'ftp'
   * @example 'git'
   */
  scheme: string

  /**
   * If enabled, it allows optional slashes after the protocol.
   * @default false
   * @example true
   */
  optionalSlashes?: boolean
}

type ProtocolConfig = Array<ProtocolOptions | string>

const ATTR_WHITESPACE =
  /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g

export function isAllowedUri(
  uri: string | undefined,
  protocols?: ProtocolConfig
) {
  const allowedProtocols: string[] = [
    "http",
    "https",
    "ftp",
    "ftps",
    "mailto",
    "tel",
    "callto",
    "sms",
    "cid",
    "xmpp",
  ]

  if (protocols) {
    protocols.forEach((protocol) => {
      const nextProtocol =
        typeof protocol === "string" ? protocol : protocol.scheme

      if (nextProtocol) {
        allowedProtocols.push(nextProtocol)
      }
    })
  }

  return (
    !uri ||
    uri.replace(ATTR_WHITESPACE, "").match(
      new RegExp(
        `^(?:(?:${allowedProtocols.join("|")}):|[^a-z]|[a-z0-9+.\-]+(?:[^a-z+.\-:]|$))`,
        "i"
      )
    )
  )
}

export function sanitizeUrl(
  inputUrl: string,
  baseUrl: string,
  protocols?: ProtocolConfig
): string {
  try {
    const url = new URL(inputUrl, baseUrl)

    if (isAllowedUri(url.href, protocols)) {
      return url.href
    }
  } catch {
    // If URL creation fails, it's considered invalid
  }
  return "#"
}

/**
 * Clamps a value between min and max bounds
 */
export function clamp(value: number, min: number, max: number): number {
  return Math.max(min, Math.min(value, max))
}

export function getSelectedBlockNodes(editor: Editor): PMNode[] {
  const { doc } = editor.state
  const { from, to } = editor.state.selection

  const blocks: PMNode[] = []
  const seen = new Set<number>()

  doc.nodesBetween(from, to, (node, pos) => {
    if (!node.isBlock) return

    if (!seen.has(pos)) {
      seen.add(pos)
      blocks.push(node)
    }

    return false
  })

  return blocks
}
