/**
 * Server-side HTML sanitization for task message comments.
 *
 * Runs in the Cloudflare Worker before storage. DOMPurify (DOM-dependent)
 * runs client-side as defense-in-depth before render.
 *
 * Exports: sanitizeCommentHtml, validateTaskDescriptionJson
 * (Canonical source: packages/db/src/sanitize/comment-html.ts)
 */

// ── Allowed tag / attribute allowlist ─────────────────────────────────────────

const ALLOWED_TAGS = new Set([
  'p', 'br', 'strong', 'em', 'u', 's',
  'h1', 'h2', 'h3',
  'ul', 'ol', 'li',
  'blockquote', 'code', 'pre',
  'a', 'img',
])

const VOID_TAGS = new Set(['br', 'img'])

const ALLOWED_HREF_SCHEMES = new Set(['http:', 'https:', 'mailto:'])

function isAllowedImgSrc(src: string, r2PublicDomain?: string): boolean {
  try {
    const url = new URL(src)
    if (!url.protocol.startsWith('http')) return false
    const host = url.hostname
    if (host.endsWith('.r2.dev')) return true
    if (r2PublicDomain) {
      if (host === r2PublicDomain) return true
      if (host.endsWith('.' + r2PublicDomain)) return true
    }
    return false
  } catch {
    return false
  }
}

type Token =
  | { kind: 'text'; value: string }
  | { kind: 'open'; tag: string; attrs: Record<string, string>; selfClose: boolean }
  | { kind: 'close'; tag: string }
  | { kind: 'comment' }

function tokenize(html: string): Token[] {
  const tokens: Token[] = []
  let i = 0
  while (i < html.length) {
    if (html[i] === '<') {
      if (html.startsWith('<!--', i)) {
        const end = html.indexOf('-->', i + 4)
        tokens.push({ kind: 'comment' })
        i = end >= 0 ? end + 3 : html.length
        continue
      }
      if (html[i + 1] === '/') {
        const end = html.indexOf('>', i)
        if (end < 0) { i = html.length; continue }
        const inner = html.slice(i + 2, end).trim().toLowerCase()
        tokens.push({ kind: 'close', tag: inner })
        i = end + 1
        continue
      }
      const end = html.indexOf('>', i)
      if (end < 0) { i = html.length; continue }
      const inner = html.slice(i + 1, end)
      const selfClose = inner.endsWith('/')
      const content = selfClose ? inner.slice(0, -1) : inner
      const attrRegex = /([a-z][a-z0-9-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/gi
      const tagName = content.trim().split(/\s+/)[0]?.toLowerCase() ?? ''
      const attrStr = content.slice(tagName.length)
      const attrs: Record<string, string> = {}
      let m: RegExpExecArray | null
      while ((m = attrRegex.exec(attrStr)) !== null) {
        const key = m[1]!.toLowerCase()
        const val = m[2] ?? m[3] ?? m[4] ?? ''
        attrs[key] = val
      }
      tokens.push({ kind: 'open', tag: tagName, attrs, selfClose: selfClose || VOID_TAGS.has(tagName) })
      i = end + 1
      continue
    }
    const next = html.indexOf('<', i)
    const text = next >= 0 ? html.slice(i, next) : html.slice(i)
    if (text) tokens.push({ kind: 'text', value: text })
    i = next >= 0 ? next : html.length
  }
  return tokens
}

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

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

function buildAllowedAttrs(tag: string, attrs: Record<string, string>, r2PublicDomain?: string): string {
  const parts: string[] = []
  if (tag === 'a') {
    const href = attrs['href']
    if (href) {
      try {
        const url = new URL(href, 'https://example.com')
        if (ALLOWED_HREF_SCHEMES.has(url.protocol)) {
          parts.push(`href="${escapeAttr(href)}"`)
        }
      } catch { /* drop */ }
    }
    parts.push('rel="noopener noreferrer"')
  }
  if (tag === 'img') {
    const src = attrs['src']
    if (src && isAllowedImgSrc(src, r2PublicDomain)) {
      parts.push(`src="${escapeAttr(src)}"`)
    }
    if (attrs['alt'] !== undefined) parts.push(`alt="${escapeAttr(attrs['alt'] ?? '')}"`)
  }
  return parts.length > 0 ? ' ' + parts.join(' ') : ''
}

/**
 * Sanitize HTML before storage as a task message comment.
 * Allowed tags: p, br, strong, em, u, s, h1-h3, ul, ol, li, blockquote, code, pre, a, img.
 * Allowed attributes: a[href] (http/https/mailto only), img[src] (*.r2.dev only), img[alt].
 */
export function sanitizeCommentHtml(html: string, r2PublicDomain?: string): string {
  const tokens = tokenize(html)
  const out: string[] = []
  const openStack: string[] = []

  for (const token of tokens) {
    if (token.kind === 'comment') continue
    if (token.kind === 'text') { out.push(escapeText(token.value)); continue }
    if (token.kind === 'open') {
      const { tag, attrs, selfClose } = token
      if (!ALLOWED_TAGS.has(tag)) continue
      const allowedAttrs = buildAllowedAttrs(tag, attrs, r2PublicDomain)
      out.push(`<${tag}${allowedAttrs}>`)
      if (!selfClose) openStack.push(tag)
      continue
    }
    if (token.kind === 'close') {
      const { tag } = token
      if (!ALLOWED_TAGS.has(tag)) continue
      const idx = openStack.lastIndexOf(tag)
      if (idx >= 0) {
        for (let j = openStack.length - 1; j >= idx; j--) {
          out.push(`</${openStack[j]}>`)
        }
        openStack.splice(idx)
      }
    }
  }

  for (let j = openStack.length - 1; j >= 0; j--) {
    out.push(`</${openStack[j]}>`)
  }
  return out.join('')
}

// ── Tiptap JSON node-type whitelist ───────────────────────────────────────────

const ALLOWED_NODE_TYPES = new Set([
  'doc', 'paragraph', 'heading', 'bulletList', 'orderedList', 'listItem',
  'blockquote', 'codeBlock', 'horizontalRule', 'hardBreak', 'image', 'text', 'mention',
])

const ALLOWED_MARK_TYPES = new Set([
  'bold', 'italic', 'underline', 'strike', 'code', 'link', 'textStyle',
])

interface TiptapNode {
  type: string
  content?: TiptapNode[]
  marks?: Array<{ type: string }>
  attrs?: Record<string, unknown>
  text?: string
}

function validateNode(node: TiptapNode): boolean {
  if (typeof node.type !== 'string') return false
  if (node.type === 'doc') return (node.content ?? []).every(validateNode)
  if (!ALLOWED_NODE_TYPES.has(node.type)) return false
  if (node.marks) {
    for (const mark of node.marks) {
      if (typeof mark.type !== 'string' || !ALLOWED_MARK_TYPES.has(mark.type)) return false
    }
  }
  if (node.content) {
    if (!Array.isArray(node.content)) return false
    for (const child of node.content) {
      if (!validateNode(child)) return false
    }
  }
  return true
}

/**
 * Validate a Tiptap JSON document for task description storage.
 * Returns true iff only allowed node/mark types are present.
 */
export function validateTaskDescriptionJson(doc: unknown): boolean {
  if (doc === null || doc === undefined) return false
  if (typeof doc !== 'object' || Array.isArray(doc)) return false
  return validateNode(doc as TiptapNode)
}
