/**
 * Server-side HTML sanitization for task message comments — tasks-detail-communication.
 *
 * `sanitizeCommentHtml` removes all tags not in the allowlist and all attributes
 * not explicitly permitted. This runs before storage; DOMPurify runs client-side
 * as defense-in-depth before render.
 *
 * `validateTaskDescriptionJson` validates Tiptap JSON node types for task description.
 */

// ── 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',
])

// Tags that are self-closing / void in HTML
const VOID_TAGS = new Set(['br', 'img'])

// Allowed schemes for <a href>
const ALLOWED_HREF_SCHEMES = new Set(['http:', 'https:', 'mailto:'])

// ── R2 host validation ────────────────────────────────────────────────────────

/**
 * Returns true if the src hostname is an R2 domain we allow.
 * Matches: *.r2.dev and the optional configured public R2 domain.
 */
function isAllowedImgSrc(src: string, r2PublicDomain?: string): boolean {
  try {
    const url = new URL(src)
    if (!url.protocol.startsWith('http')) return false
    const host = url.hostname
    // *.r2.dev wildcard
    if (host.endsWith('.r2.dev')) return true
    // Configured R2 public domain (exact or *.domain.tld)
    if (r2PublicDomain) {
      if (host === r2PublicDomain) return true
      if (host.endsWith('.' + r2PublicDomain)) return true
    }
    return false
  } catch {
    return false
  }
}

// ── Minimal HTML parser / sanitizer ──────────────────────────────────────────

/**
 * Tokenizer state machine for the sanitizer.
 * We implement a simple recursive descent over the HTML string.
 * We do NOT eval any scripts; this is used server-side in a Worker context
 * where DOM APIs may not be available.
 */

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)) {
        // Comment — skip to -->
        const end = html.indexOf('-->', i + 4)
        tokens.push({ kind: 'comment' })
        i = end >= 0 ? end + 3 : html.length
        continue
      }

      if (html[i + 1] === '/') {
        // Closing tag
        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
      }

      // Opening / self-closing tag
      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

      // Parse tag name and attributes
      const attrRegex = /([a-z][a-z0-9-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/gi
      const parts = content.trim().split(/\s+/)
      const tagName = (parts[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
    }

    // Text node
    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 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 malformed href
      }
    }
    // rel=noopener for external links
    parts.push('rel="noopener noreferrer"')
  }

  if (tag === 'img') {
    const src = attrs['src']
    if (src && isAllowedImgSrc(src, r2PublicDomain)) {
      parts.push(`src="${escapeAttr(src)}"`)
    }
    // alt is harmless — keep if present
    if (attrs['alt'] !== undefined) {
      parts.push(`alt="${escapeAttr(attrs['alt'] ?? '')}"`)
    }
  }

  return parts.length > 0 ? ' ' + parts.join(' ') : ''
}

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;')
}

/**
 * Sanitize HTML for storage as a task message comment.
 *
 * Removes: script/style/all disallowed tags; all attributes not in the allowlist.
 * Allows only: p, br, strong, em, u, s, h1-h3, ul, ol, li, blockquote, code, pre, a, img.
 * Strips on-* handlers, javascript: hrefs, non-R2 img srcs.
 *
 * @param html - Raw HTML from Tiptap
 * @param r2PublicDomain - Optional custom R2 domain to allow alongside *.r2.dev
 */
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)

      if (selfClose) {
        out.push(`<${tag}${allowedAttrs}>`)
      } else {
        out.push(`<${tag}${allowedAttrs}>`)
        openStack.push(tag)
      }
      continue
    }

    if (token.kind === 'close') {
      const { tag } = token
      if (!ALLOWED_TAGS.has(tag)) continue
      // Only close if we opened it
      const idx = openStack.lastIndexOf(tag)
      if (idx >= 0) {
        // Close all tags opened after this one (implicitly)
        for (let j = openStack.length - 1; j >= idx; j--) {
          out.push(`</${openStack[j]}>`)
        }
        openStack.splice(idx)
      }
    }
  }

  // Close any unclosed tags
  for (let j = openStack.length - 1; j >= 0; j--) {
    out.push(`</${openStack[j]}>`)
  }

  return out.join('')
}

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

/**
 * Tiptap node types and mark types that are allowed in task descriptions.
 * Any document containing an unlisted type is rejected.
 */
const ALLOWED_NODE_TYPES = new Set([
  // Block nodes
  'doc',
  'paragraph',
  'heading',
  'bulletList',
  'orderedList',
  'listItem',
  'blockquote',
  'codeBlock',
  'horizontalRule',
  'hardBreak',
  'image',
  // Inline nodes
  'text',
  'mention',
])

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

/**
 * Allowed attributes on paragraph/heading nodes (for RTL direction persistence).
 * All other attributes are ignored; the validator only checks `type`.
 */

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

  // The top-level doc node wraps everything
  if (node.type === 'doc') {
    return (node.content ?? []).every(validateNode)
  }

  if (!ALLOWED_NODE_TYPES.has(node.type)) return false

  // Validate marks on this node
  if (node.marks) {
    for (const mark of node.marks) {
      if (typeof mark.type !== 'string' || !ALLOWED_MARK_TYPES.has(mark.type)) return false
    }
  }

  // Validate children
  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 the document only contains allowed node/mark types.
 * Returns false on any disallowed type or malformed structure.
 *
 * @param doc - The Tiptap JSON document (unknown)
 */
export function validateTaskDescriptionJson(doc: unknown): boolean {
  if (doc === null || doc === undefined) return false
  if (typeof doc !== 'object' || Array.isArray(doc)) return false

  const node = doc as TiptapNode
  return validateNode(node)
}
