import { Server } from "@hocuspocus/server"
import { TiptapTransformer } from "@hocuspocus/transformer"
import * as Y from "yjs"
import { createRequire } from "node:module"

const require = createRequire(import.meta.url)

const { Node } = require("@tiptap/core")
const StarterKit = require("@tiptap/starter-kit").default
const Underline = require("@tiptap/extension-underline").default
const Link = require("@tiptap/extension-link").default
const { TextStyle } = require("@tiptap/extension-text-style")
const Highlight = require("@tiptap/extension-highlight").default
const TextAlign = require("@tiptap/extension-text-align").default
const { Table, TableRow, TableCell, TableHeader } = require("@tiptap/extension-table")
const Image = require("@tiptap/extension-image").default

/** Keep in sync with lib/drive/extensions/docs-graphic.ts graphicAttributes */
const graphicAttributes = {
  graphicType: { default: "image" },
  src: { default: null },
  alt: { default: "" },
  shapeType: { default: "rect" },
  fill: { default: "#4285f4" },
  stroke: { default: "#1a73e8" },
  strokeWidth: { default: 2 },
  gradientCss: { default: "" },
  gradientAngle: { default: 180 },
  gradientColor1: { default: "#4285f4" },
  gradientColor2: { default: "#34a853" },
  width: { default: 240 },
  height: { default: 160 },
  placement: { default: "block" },
  wrap: { default: "square" },
  floatSide: { default: "left" },
  x: { default: 0 },
  y: { default: 0 },
  positionMode: { default: "move-with-text" },
  anchorPos: { default: -1 },
  pageIndex: { default: 0 },
  pageX: { default: 0 },
  pageY: { default: 0 },
  wrapMarginMm: { default: 3 },
  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 },
  opacity: { default: 1 },
  shadow: { default: "" },
  brightness: { default: 0 },
  contrast: { default: 0 },
  recolor: { default: "" },
  altTitle: { default: "" },
  drawScene: { default: null },
}

const DocsGraphic = Node.create({
  name: "docsGraphic",
  group: "block",
  atom: true,
  addAttributes() {
    return graphicAttributes
  },
})

const DocsInlineGraphic = Node.create({
  name: "docsInlineGraphic",
  group: "inline",
  inline: true,
  atom: true,
  addAttributes() {
    return graphicAttributes
  },
})

/** Match TipTap editor extensions so imported DOCX content survives Yjs seeding. */
const transformerExtensions = [
  StarterKit.configure({ undoRedo: false, link: false, underline: false }),
  Underline,
  Link.configure({ openOnClick: false }),
  TextStyle,
  Highlight.configure({ multicolor: true }),
  TextAlign.configure({ types: ["heading", "paragraph"] }),
  Table.configure({ resizable: true }),
  TableRow,
  TableCell,
  TableHeader,
  Image.configure({ inline: true, allowBase64: true }),
  DocsGraphic,
  DocsInlineGraphic,
]

const PORT = Number(process.env.HOCUSPOCUS_PORT || 1234)
const SECRET = process.env.HOCUSPOCUS_SECRET || ""
const ULTID_URL = (process.env.ULTID_INTERNAL_URL || "http://127.0.0.1").replace(/\/$/, "")

function decodeJwtPayload(token) {
  const parts = token.split(".")
  if (parts.length < 2) return null
  try {
    const json = Buffer.from(parts[1], "base64url").toString("utf8")
    return JSON.parse(json)
  } catch {
    return null
  }
}

function hookContext(payload) {
  return payload?.lastContext ?? payload?.context ?? {}
}

function tipTapContentHasText(content) {
  if (!content || typeof content !== "object") return false
  const walk = (node) => {
    if (!node || typeof node !== "object") return false
    if (typeof node.text === "string" && node.text.trim()) return true
    if (Array.isArray(node.content)) {
      return node.content.some(walk)
    }
    return false
  }
  return walk(content)
}

function tipTapContentHasBody(content) {
  if (!content || typeof content !== "object") return false
  if (tipTapContentHasText(content)) return true
  const walk = (node) => {
    if (!node || typeof node !== "object") return false
    const type = node.type
    if (type && type !== "doc" && type !== "paragraph") return true
    if (Array.isArray(node.content)) {
      if (node.content.length === 0) return false
      if (node.content.length > 1) return true
      return walk(node.content[0])
    }
    return false
  }
  return walk(content)
}

function yjsToExcalidrawElements(yArray) {
  if (!yArray || yArray.length === 0) return []
  return yArray
    .toArray()
    .sort((a, b) => {
      const key1 = a.get("pos")
      const key2 = b.get("pos")
      return key1 > key2 ? 1 : key1 < key2 ? -1 : 0
    })
    .map((x) => x.get("el"))
    .filter((el) => el && typeof el.id === "string" && typeof el.type === "string")
}

function exportUltidrawScene(ydoc) {
  const yElements = ydoc.getArray("elements")
  const yAssets = ydoc.getMap("assets")
  const elements = yjsToExcalidrawElements(yElements)
  const files = {}
  yAssets.forEach((value, key) => {
    files[key] = value
  })
  return {
    elements,
    appState: { gridSize: null, viewBackgroundColor: "#ffffff" },
    files,
  }
}

function seedYdocFromJson(ydoc, elements, files, generateNKeysBetween) {
  const yElements = ydoc.getArray("elements")
  const yAssets = ydoc.getMap("assets")
  if (!Array.isArray(elements) || elements.length === 0 || yElements.length > 0) return
  const keys = generateNKeysBetween(null, null, elements.length)
  ydoc.transact(() => {
    for (let i = 0; i < elements.length; i++) {
      const el = elements[i]
      if (!el || typeof el.id !== "string") continue
      yElements.push([
        new Y.Map(Object.entries({ pos: keys[i], el: { ...el } })),
      ])
    }
    if (files && typeof files === "object") {
      for (const [id, asset] of Object.entries(files)) {
        yAssets.set(id, asset)
      }
    }
  })
}

async function loadFromUltidraw(context) {
  if (!context?.path || !context?.user) return null
  const params = new URLSearchParams({ user: context.user, path: context.path })
  const res = await fetch(`${ULTID_URL}/api/v1/ultidraw/internal/document?${params}`, {
    headers: SECRET ? { "X-Hocuspocus-Secret": SECRET } : {},
  })
  if (res.status === 404) return null
  if (!res.ok) throw new Error(`ultidraw load failed: ${res.status}`)
  const raw = await res.text()
  if (!raw.trim()) return null
  try {
    const doc = JSON.parse(raw)
    const ydoc = new Y.Doc()
    if (doc.yjsState) {
      Y.applyUpdate(ydoc, Buffer.from(doc.yjsState, "base64"))
    }
    const { generateNKeysBetween } = await import("fractional-indexing")
    seedYdocFromJson(ydoc, doc.elements, doc.files, generateNKeysBetween)
    if (ydoc.getArray("elements").length === 0) {
      return null
    }
    return Buffer.from(Y.encodeStateAsUpdate(ydoc))
  } catch (err) {
    console.error("[onLoadDocument] ultidraw parse", err)
  }
  return null
}

async function storeToUltidraw(context, document) {
  if (!context?.path || !context?.user) {
    throw new Error("ultidraw store missing path or user in context")
  }
  const state = Buffer.from(Y.encodeStateAsUpdate(document)).toString("base64")
  const scene = exportUltidrawScene(document)
  const body = {
    room: context.room ?? context.path,
    path: context.path,
    user: context.user,
    sub: context.sub ?? "",
    yjsState: state,
    document: scene,
  }
  const res = await fetch(`${ULTID_URL}/api/v1/ultidraw/hooks/store`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      ...(SECRET ? { "X-Hocuspocus-Secret": SECRET } : {}),
    },
    body: JSON.stringify(body),
  })
  if (!res.ok) {
    const detail = await res.text().catch(() => "")
    throw new Error(`ultidraw store failed: ${res.status}${detail ? ` ${detail}` : ""}`)
  }
}

function isDrawRoom(name) {
  return typeof name === "string" && name.startsWith("draw:")
}

async function loadFromUltid(context) {
  if (!context?.path || !context?.user) return null
  const params = new URLSearchParams({ user: context.user, path: context.path })
  const res = await fetch(`${ULTID_URL}/api/v1/richtext/internal/document?${params}`, {
    headers: SECRET ? { "X-Hocuspocus-Secret": SECRET } : {},
  })
  if (res.status === 404) return null
  if (!res.ok) throw new Error(`load failed: ${res.status}`)
  const raw = await res.text()
  if (!raw.trim()) return null
  try {
    const doc = JSON.parse(raw)
    const hasStoredBody = tipTapContentHasBody(doc.content)
    if (doc.content && hasStoredBody) {
      const ydoc = TiptapTransformer.toYdoc(doc.content, "default", transformerExtensions)
      return Buffer.from(Y.encodeStateAsUpdate(ydoc))
    }
    if (doc.yjsState && !hasStoredBody) {
      return Buffer.from(doc.yjsState, "base64")
    }
  } catch (err) {
    console.error("[onLoadDocument] parse", err)
  }
  return null
}

async function storeToUltid(context, document) {
  if (!context?.path || !context?.user) {
    throw new Error("store missing path or user in context")
  }
  const state = Buffer.from(Y.encodeStateAsUpdate(document)).toString("base64")
  let tiptapJson = null
  try {
    tiptapJson = TiptapTransformer.fromYdoc(document, "default", transformerExtensions)
  } catch {
    /* optional */
  }
  const body = {
    room: context.room ?? context.path,
    path: context.path,
    user: context.user,
    sub: context.sub ?? "",
    yjsState: state,
    document: tiptapJson,
  }
  const res = await fetch(`${ULTID_URL}/api/v1/richtext/hooks/store`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      ...(SECRET ? { "X-Hocuspocus-Secret": SECRET } : {}),
    },
    body: JSON.stringify(body),
  })
  if (!res.ok) {
    const detail = await res.text().catch(() => "")
    throw new Error(`store failed: ${res.status}${detail ? ` ${detail}` : ""}`)
  }
}

const server = new Server({
  port: PORT,
  debounce: 3000,
  maxDebounce: 10000,

  async onAuthenticate(data) {
    const { token, documentName } = data
    if (!SECRET) return {}
    const payload = decodeJwtPayload(token)
    if (!payload?.room || payload.room !== documentName) {
      console.error("[onAuthenticate] forbidden", { documentName, room: payload?.room })
      throw new Error("forbidden")
    }
    if (payload.exp && payload.exp * 1000 < Date.now()) {
      console.error("[onAuthenticate] token expired", { documentName })
      throw new Error("token expired")
    }
    return {
      room: payload.room,
      path: payload.path,
      user: payload.user,
      sub: payload.sub,
      name: payload.name,
      mode: payload.mode,
    }
  },

  async onLoadDocument(data) {
    const ctx = hookContext(data)
    if (isDrawRoom(data.documentName) || isDrawRoom(ctx.room)) {
      return await loadFromUltidraw(ctx)
    }
    return await loadFromUltid(ctx)
  },

  async onStoreDocument(data) {
    const ctx = hookContext(data)
    if (ctx.mode === "view") return
    try {
      if (isDrawRoom(data.documentName) || isDrawRoom(ctx.room)) {
        await storeToUltidraw(ctx, data.document)
      } else {
        await storeToUltid(ctx, data.document)
      }
    } catch (err) {
      console.error("[onStoreDocument]", err)
    }
  },
})

server.listen()
console.log(`Hocuspocus listening on :${PORT}`)
