/**
 * Cloudflare Email Worker — versebyversewithnate.us
 *
 * Receives inbound emails to hello@ and nate@, forwards to Gmail,
 * and POSTs a parsed copy to the site admin inbox.
 *
 * CLOUDFLARE SETUP:
 *   1. Go to Email > Email Routing > Routes in your Cloudflare dashboard
 *   2. Add two "Custom address" rules:
 *        hello@versebyversewithnate.us  →  Send to Worker  →  this worker
 *        nate@versebyversewithnate.us   →  Send to Worker  →  this worker
 *   3. Make sure nmemmert@gmail.com is listed as a verified destination address
 *      (Email Routing > Destination addresses)
 *
 * WORKER ENVIRONMENT VARIABLES (Workers & Pages > this worker > Settings > Variables):
 *   GMAIL_FORWARD_ADDRESS  =  nmemmert@gmail.com
 *   WEBHOOK_URL            =  https://versebyversewithnate.us/api/inbound-email
 *   WEBHOOK_SECRET         =  <a long random string — must match INBOUND_EMAIL_SECRET on the server>
 *
 * SERVER ENVIRONMENT VARIABLE (set in your server host / .env):
 *   INBOUND_EMAIL_SECRET   =  <same long random string as WEBHOOK_SECRET above>
 */

export default {
  async email(message, env, ctx) {
    const forwardPromise = message.forward(env.GMAIL_FORWARD_ADDRESS)

    const rawEmail = await streamToText(message.raw)

    const parsed = parseEmail(rawEmail, message)

    const webhookPromise = fetch(env.WEBHOOK_URL, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Webhook-Secret': env.WEBHOOK_SECRET,
      },
      body: JSON.stringify(parsed),
    }).catch(err => console.error('[email-worker] webhook failed:', err.message))

    await Promise.all([forwardPromise, webhookPromise])
  },
}

async function streamToText(stream) {
  const reader = stream.getReader()
  const chunks = []
  while (true) {
    const { done, value } = await reader.read()
    if (done) break
    chunks.push(value)
  }
  const bytes = new Uint8Array(chunks.reduce((acc, c) => acc + c.length, 0))
  let offset = 0
  for (const chunk of chunks) {
    bytes.set(chunk, offset)
    offset += chunk.length
  }
  return new TextDecoder().decode(bytes)
}

function parseEmail(raw, message) {
  const { headers, body } = splitHeadersAndBody(raw)

  const subject = decodeHeaderValue(headers['subject'] ?? '(no subject)')
  const from = message.from ?? headers['from'] ?? ''
  const to = message.to ?? headers['to'] ?? ''
  const date = headers['date'] ?? new Date().toISOString()
  const messageId = headers['message-id'] ?? ''

  const { plainText, htmlBody } = extractBodies(headers['content-type'] ?? '', body)

  return {
    from,
    to,
    subject,
    body: plainText,
    htmlBody: htmlBody || null,
    date,
    messageId,
    source: 'inbound-email',
  }
}

// Splits an RFC 5322 message (or MIME part) into its unfolded header map and raw body string.
function splitHeadersAndBody(raw) {
  const match = /\r?\n\r?\n/.exec(raw)
  const headerBlock = match ? raw.slice(0, match.index) : raw
  const body = match ? raw.slice(match.index + match[0].length) : ''
  // RFC 2822 header folding: a line starting with SP/TAB continues the previous header line.
  const unfolded = headerBlock.replace(/\r?\n[ \t]+/g, ' ')
  const headers = {}
  for (const line of unfolded.split(/\r?\n/)) {
    const colon = line.indexOf(':')
    if (colon <= 0) continue
    const key = line.slice(0, colon).trim().toLowerCase()
    const val = line.slice(colon + 1).trim()
    if (!headers[key]) headers[key] = val
  }
  return { headers, body }
}

// Recursively walks a (possibly multipart) MIME body and returns the first text/plain and text/html parts found.
function extractBodies(topContentType, topBody) {
  let plainText = null
  let htmlBody = null

  function visit(contentType, body, transferEncoding) {
    const type = (contentType.split(';')[0] || 'text/plain').trim().toLowerCase()

    if (type.startsWith('multipart/')) {
      const boundary = getBoundary(contentType)
      if (!boundary) return
      for (const part of splitOnBoundary(body, boundary)) {
        const { headers, body: partBody } = splitHeadersAndBody(part)
        visit(headers['content-type'] ?? 'text/plain', partBody, headers['content-transfer-encoding'] ?? '')
      }
      return
    }

    const decoded = decodeBody(body, transferEncoding)
    if (type === 'text/html' && htmlBody === null) htmlBody = decoded.trim()
    if (type === 'text/plain' && plainText === null) plainText = decoded.trim()
  }

  visit(topContentType || 'text/plain', topBody, '')

  if (plainText === null && htmlBody !== null) {
    plainText = htmlBody.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/\s{3,}/g, '\n\n').trim()
  }
  if (plainText === null) plainText = ''

  return { plainText, htmlBody }
}

function getBoundary(contentType) {
  const match = /boundary\s*=\s*"([^"]+)"|boundary\s*=\s*([^;\s]+)/i.exec(contentType)
  if (!match) return null
  return match[1] ?? match[2]
}

// Splits a multipart body on its boundary markers, ignoring the preamble/epilogue.
function splitOnBoundary(body, boundary) {
  const escaped = boundary.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  const re = new RegExp(`(?:^|\\r?\\n)--${escaped}(--)?(?:\\r?\\n|$)`, 'g')
  const parts = []
  let lastIndex = 0
  let started = false
  let match
  while ((match = re.exec(body)) !== null) {
    if (started) parts.push(body.slice(lastIndex, match.index))
    started = true
    lastIndex = match.index + match[0].length
    if (match[1]) break // final boundary: "--boundary--"
  }
  return parts
}

function decodeBody(text, transferEncoding) {
  const enc = (transferEncoding || '').trim().toLowerCase()
  if (enc === 'base64') {
    try {
      const binary = atob(text.replace(/\s+/g, ''))
      return new TextDecoder('utf-8').decode(Uint8Array.from(binary, c => c.charCodeAt(0)))
    } catch {
      return ''
    }
  }
  if (enc === 'quoted-printable') {
    return decodeEmailBody(text)
  }
  return text
}

function decodeEmailBody(text) {
  // Handle quoted-printable encoding (=XX hex sequences and soft line breaks).
  // Decode into raw bytes first, then run through TextDecoder so multi-byte
  // UTF-8 sequences split across multiple =XX escapes recombine correctly.
  const unfolded = text.replace(/=\r?\n/g, '')
  const bytes = []
  for (let i = 0; i < unfolded.length; i++) {
    if (unfolded[i] === '=' && /^[0-9A-Fa-f]{2}$/.test(unfolded.slice(i + 1, i + 3))) {
      bytes.push(parseInt(unfolded.slice(i + 1, i + 3), 16))
      i += 2
    } else {
      bytes.push(unfolded.charCodeAt(i))
    }
  }
  return new TextDecoder('utf-8').decode(Uint8Array.from(bytes))
}

function decodeHeaderValue(value) {
  // Handle encoded words: =?UTF-8?B?...?= or =?UTF-8?Q?...?=
  return value.replace(/=\?([^?]+)\?([BQ])\?([^?]*)\?=/gi, (_, charset, encoding, encoded) => {
    try {
      if (encoding.toUpperCase() === 'B') {
        const binary = atob(encoded)
        return new TextDecoder(charset).decode(Uint8Array.from(binary, c => c.charCodeAt(0)))
      }
      if (encoding.toUpperCase() === 'Q') {
        return encoded.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
      }
    } catch {
      return encoded
    }
    return encoded
  })
}
