import express from 'express'
import rateLimit from 'express-rate-limit'
import { mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'
import { createHash, randomUUID } from 'node:crypto'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { Resend } from 'resend'
import qrcode from 'qrcode'
import {
  sanitizeSiteContent,
  escapeHtml,
  escapeXml,
  buildAbsoluteUrl,
  injectSeoIntoHtml,
  normalizeAssetBaseName,
  inferImageExtensionFromDataUrl,
  getClientIp,
  hasVisitorConsent,
  setConsentCookie,
  splitName,
  parseCookies,
} from './server/helpers.js'
import {
  isAdminPasswordConfigured,
  isValidAdminSession,
  requireAdminAuth,
  setAdminSessionCookie,
  clearAdminSessionCookie,
  createAdminSession,
  deleteAdminSession,
  validateAdminPasswordSetup,
  isAdminPasswordValid,
  isTotpEnabled,
  loadTotpState,
  saveTotpState,
  generateTotpSecret,
  getTotpUri,
  verifyTotpCode,
  generateRecoveryCodes,
  hashRecoveryCode,
  consumeRecoveryCode,
  createPendingSession,
  consumePendingSession,
} from './server/auth.js'

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const DATA_DIR = path.join(__dirname, 'data')
const DATA_FILE = path.join(DATA_DIR, 'admin-content.json')
const DRAFT_DATA_FILE = path.join(DATA_DIR, 'admin-content-draft.json')
const HIT_STATS_FILE = path.join(DATA_DIR, 'hit-stats.json')
const VISITOR_STATS_FILE = path.join(DATA_DIR, 'visitor-stats.json')
const CONTACT_SUBMISSIONS_FILE = path.join(DATA_DIR, 'contact-submissions.json')
const QUESTIONS_FILE = path.join(DATA_DIR, 'questions.json')
const DRAFT_QUESTIONS_FILE = path.join(DATA_DIR, 'questions-draft.json')
const REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json')
const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json')
const BACKUP_DIR = path.join(DATA_DIR, 'backups')
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json')
const DIST_DIR = path.join(__dirname, 'dist')
const INDEX_FILE = path.join(DIST_DIR, 'index.html')
const DIST_IMAGES_DIR = path.join(DIST_DIR, 'images')
const PUBLIC_IMAGES_DIR = path.join(__dirname, 'public', 'images')
validateAdminPasswordSetup()
const TITUS_STUDY_FILE = process.env.TITUS_STUDY_FILE
  ? path.resolve(__dirname, process.env.TITUS_STUDY_FILE)
  : path.join(__dirname, 'A_Study_of_Titus.pdf')
const TITUS_STUDY_DOWNLOAD_NAME = process.env.TITUS_STUDY_DOWNLOAD_NAME ?? 'A_Study_of_Titus.pdf'
const DEFAULT_REDIRECT_RULES = [
  {
    id: 'spotify',
    path: '/spotify',
    target: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl',
    statusCode: 301,
  },
  {
    id: 'apple',
    path: '/apple',
    target: 'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate',
    statusCode: 301,
  },
  {
    id: 'amazon',
    path: '/amazon',
    target: 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate',
    statusCode: 301,
  },
]

function normalizeRedirectPath(value) {
  if (typeof value !== 'string') return ''
  const trimmed = value.trim()
  if (!trimmed) return ''
  const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
  const normalized = withSlash.replace(/\/+/g, '/')
  if (normalized === '/') return ''
  if (normalized.startsWith('/api/') || normalized.startsWith('/admin')) return ''
  return normalized
}

function sanitizeUrl(value) {
  if (typeof value !== 'string') return ''
  const trimmed = value.trim()
  if (!trimmed) return ''
  if (trimmed.startsWith('/')) return trimmed
  if (/^https?:\/\//i.test(trimmed)) return trimmed
  return ''
}

function sanitizeRedirectRules(value) {
  const source = Array.isArray(value) ? value : []
  const seen = new Set()
  const out = []

  for (const item of source) {
    const pathValue = normalizeRedirectPath(item?.path)
    const target = sanitizeUrl(item?.target)
    const statusCode = Number(item?.statusCode) === 302 ? 302 : 301
    if (!pathValue || !target) continue
    if (seen.has(pathValue)) continue
    seen.add(pathValue)
    out.push({
      id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
      path: pathValue,
      target,
      statusCode,
    })
  }

  return out.length > 0 ? out : DEFAULT_REDIRECT_RULES
}

const DEFAULT_SEO = {
  title: 'Verse by Verse with Nate',
  description: 'Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.',
  ogTitle: 'Verse by Verse with Nate',
  ogDescription: 'A Journey Through Scripture - verse by verse, nugget by nugget.',
  ogImage: '/images/podcast-art.jpeg',
  canonicalUrl: 'https://versebyversewithnate.us/',
  robotsPolicy: 'index,follow',
  sitemapPaths: ['/', '/start-here', '/questions', '/privacy', '/terms'],
}
const DEFAULT_LEGAL = {
  privacyTitle: 'Privacy Policy',
  privacyBody: [
    'We respect your privacy and collect limited data to operate and improve this site.',
    'If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.',
    'Contact form details are used only to respond to your message and ministry communication requests.',
  ],
  termsTitle: 'Terms',
  termsBody: [
    'Content on this site is for informational and ministry purposes.',
    'External links are provided for convenience and are subject to third-party policies.',
    'By using this site, you agree to lawful use and respectful communication.',
  ],
}
const DEFAULT_PODCAST_FEATURED_LINKS = []
const DEFAULT_PUBLISH_STATE = {
  draftUpdatedAt: null,
  publishedAt: null,
}

const DEFAULT_REPLY_TEMPLATES = [
  {
    id: 'thanks-for-reaching-out',
    label: 'Thank You Reply',
    subject: 'Thanks for reaching out to Verse by Verse with Nate',
    message: 'Thank you for reaching out.\n\nI appreciate your message and wanted to follow up personally.',
  },
  {
    id: 'question-received',
    label: 'Question Received',
    subject: 'Your Bible question was received',
    message: 'Thank you for sending your Bible question.\n\nI have received it, and I appreciate you taking the time to write in.',
  },
  {
    id: 'testimony-thank-you',
    label: 'Testimony Thank You',
    subject: 'Thank you for sharing your testimony',
    message: 'Thank you for sharing what the Lord is doing in your life.\n\nYour message was an encouragement to read.',
  },
]

let cachedSiteContent = null
let cachedDraftSiteContent = null
let publishState = { ...DEFAULT_PUBLISH_STATE }
let draftQuestions = null
let draftQuestionsWritePromise = Promise.resolve()
let replyTemplates = [...DEFAULT_REPLY_TEMPLATES]
let replyTemplatesWritePromise = Promise.resolve()
let replyHistory = []
let replyHistoryWritePromise = Promise.resolve()

async function loadSiteContentFile(filePath) {
  const raw = await readFile(filePath, 'utf8')
  const parsed = JSON.parse(raw)
  const safeSiteContent = sanitizeSiteContent(parsed?.siteContent)
  return {
    ...parsed,
    siteContent: safeSiteContent,
  }
}

async function refreshContentCaches() {
  try {
    const published = await loadSiteContentFile(DATA_FILE)
    cachedSiteContent = published.siteContent
    if (typeof published?.updatedAt === 'string') {
      publishState.publishedAt = published.updatedAt
    }
  } catch {
    cachedSiteContent = null
  }

  try {
    const draft = await loadSiteContentFile(DRAFT_DATA_FILE)
    cachedDraftSiteContent = draft.siteContent
    if (typeof draft?.updatedAt === 'string') {
      publishState.draftUpdatedAt = draft.updatedAt
    }
  } catch {
    cachedDraftSiteContent = null
  }
}

async function loadDraftQuestionsFromDisk() {
  return readFile(DRAFT_QUESTIONS_FILE, 'utf8')
    .then(raw => {
      const parsed = JSON.parse(raw)
      if (Array.isArray(parsed)) {
        draftQuestions = parsed.slice(0, MAX_QUESTIONS)
      } else if (Array.isArray(parsed?.questions)) {
        draftQuestions = parsed.questions.slice(0, MAX_QUESTIONS)
      } else {
        draftQuestions = null
      }
      if (typeof parsed?.updatedAt === 'string') {
        publishState.draftUpdatedAt = parsed.updatedAt
      }
    })
    .catch(() => {
      draftQuestions = null
    })
}

function ensureDraftQuestions() {
  if (draftQuestions !== null) return
  draftQuestions = questions.slice(0, MAX_QUESTIONS)
}

async function readUploadsMetadata() {
  try {
    const raw = await readFile(UPLOADS_META_FILE, 'utf8')
    return JSON.parse(raw)
  } catch {
    return {}
  }
}

async function writeUploadsMetadata(metadata) {
  await mkdir(DATA_DIR, { recursive: true })
  await writeFile(UPLOADS_META_FILE, JSON.stringify(metadata, null, 2), 'utf8')
}

async function listUploadedAssets() {
  await mkdir(UPLOADS_DIR, { recursive: true })
  const files = await readdir(UPLOADS_DIR)
  const imageFiles = files.filter(name => /\.(png|jpe?g|webp|gif|pdf|docx?)$/i.test(name)).sort()
  const metadata = await readUploadsMetadata()

  const withStats = await Promise.all(imageFiles.map(async filename => {
    const info = await stat(path.join(UPLOADS_DIR, filename))
    return {
      filename,
      url: `/uploads/${filename}`,
      sizeBytes: info.size,
      updatedAt: info.mtime.toISOString(),
      tags: Array.isArray(metadata[filename]) ? metadata[filename].filter(tag => typeof tag === 'string') : [],
    }
  }))

  return withStats
}

async function invokeWebhook(url, action) {
  if (!url) {
    return { ok: false, message: `${action} webhook URL is not configured.` }
  }

  try {
    const response = await fetch(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        action,
        at: new Date().toISOString(),
        source: 'siteforge-admin',
      }),
    })

    if (!response.ok) {
      return { ok: false, message: `${action} webhook failed with ${response.status}.` }
    }

    return { ok: true, message: `${action} webhook triggered.` }
  } catch (err) {
    return { ok: false, message: err instanceof Error ? err.message : `${action} webhook failed.` }
  }
}

const EMPTY_HIT_STATS = {
  totalHits: 0,
  realHits: 0,
  botHits: 0,
  firstHitAt: null,
  lastHitAt: null,
  byPath: {},
  byPathReal: {},
  byPathBot: {},
  byDay: {},
  byDayReal: {},
  byDayBot: {},
  botReasons: {},
}

let hitStats = { ...EMPTY_HIT_STATS }
let hitStatsWritePromise = Promise.resolve()

const VISITOR_COOKIE = 'vbn_vid'
const CONSENT_COOKIE = 'vbn_analytics_consent'
const MAX_RECENT_VISITS = 1000
const VISITOR_RETENTION_DAYS_DEFAULT = 180
const BACKUP_RETENTION_DAYS = 30
const BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000
const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000

const EMPTY_VISITOR_STATS = {
  totalVisits: 0,
  uniqueVisitors: 0,
  returningVisits: 0,
  firstVisitAt: null,
  lastVisitAt: null,
  visitors: {},
  recentVisits: [],
  geoCacheByIp: {},
}

const MAX_CONTACT_SUBMISSIONS = 5000
const DOWNLOAD_TOKEN_TTL_MS = 10 * 60 * 1000
const titusDownloadTokens = new Map()

const MAX_QUESTIONS = 1000
let visitorStats = { ...EMPTY_VISITOR_STATS }
let visitorStatsWritePromise = Promise.resolve()
let contactSubmissions = []
let contactSubmissionsWritePromise = Promise.resolve()
let questions = []
let questionsWritePromise = Promise.resolve()
let lastVisitorStatsWrite = { ok: true, at: null, error: null }
let lastHitStatsWrite = { ok: true, at: null, error: null }
let lastBackupStatus = { ok: true, at: null, error: null, file: null }
let lastCachePurgeStatus = { ok: true, at: null, error: null }
let lastDeployHookStatus = { ok: true, at: null, error: null }

function sanitizeReplyTemplates(value) {
  if (!Array.isArray(value)) return [...DEFAULT_REPLY_TEMPLATES]
  const out = value
    .filter(item => item && typeof item === 'object')
    .map(item => ({
      id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
      label: typeof item.label === 'string' ? item.label.trim().slice(0, 80) : '',
      subject: typeof item.subject === 'string' ? item.subject.trim().slice(0, 180) : '',
      message: typeof item.message === 'string' ? item.message.trim().slice(0, 6000) : '',
    }))
    .filter(item => item.label && item.subject && item.message)

  return out.length > 0 ? out : [...DEFAULT_REPLY_TEMPLATES]
}

function sanitizeReplyHistory(value) {
  if (!Array.isArray(value)) return []
  return value
    .filter(item => item && typeof item === 'object')
    .slice(0, 500)
    .map(item => ({
      id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
      submissionId: typeof item.submissionId === 'string' ? item.submissionId : '',
      toEmail: typeof item.toEmail === 'string' ? item.toEmail.trim().slice(0, 320) : '',
      toName: typeof item.toName === 'string' ? item.toName.trim().slice(0, 200) : '',
      fromEmail: typeof item.fromEmail === 'string' ? item.fromEmail.trim().slice(0, 320) : 'hello@versebyversewithnate.us',
      subject: typeof item.subject === 'string' ? item.subject.trim().slice(0, 180) : '',
      preview: typeof item.preview === 'string' ? item.preview.trim().slice(0, 500) : '',
      sentAt: typeof item.sentAt === 'string' ? item.sentAt : new Date().toISOString(),
    }))
}

function normalizeIp(rawIp) {
  if (!rawIp) return 'unknown'

  let ip = String(rawIp).trim()

  if (ip.includes(',')) {
    ip = ip.split(',')[0].trim()
  }

  if (ip.startsWith('::ffff:')) {
    ip = ip.slice(7)
  }

  if (ip === '::1') {
    ip = '127.0.0.1'
  }

  return ip || 'unknown'
}

function isPrivateOrLocalIp(ip) {
  return (
    ip === '127.0.0.1'
    || ip === 'localhost'
    || ip.startsWith('10.')
    || ip.startsWith('192.168.')
    || /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip)
    || ip.startsWith('fc')
    || ip.startsWith('fd')
    || ip.startsWith('fe80:')
    || ip === 'unknown'
  )
}

function queueVisitorStatsWrite() {
  visitorStatsWritePromise = visitorStatsWritePromise
    .then(async () => {
      await mkdir(DATA_DIR, { recursive: true })
      await writeFile(
        VISITOR_STATS_FILE,
        JSON.stringify({
          ...visitorStats,
          updatedAt: new Date().toISOString(),
        }, null, 2),
        'utf8',
      )
      lastVisitorStatsWrite = { ok: true, at: new Date().toISOString(), error: null }
    })
    .catch(err => {
      console.error('[visitor-stats] failed to write visitor stats:', err)
      lastVisitorStatsWrite = { ok: false, at: new Date().toISOString(), error: String(err) }
    })
}

function queueContactSubmissionsWrite() {
  contactSubmissionsWritePromise = contactSubmissionsWritePromise
    .then(async () => {
      await mkdir(DATA_DIR, { recursive: true })
      await writeFile(
        CONTACT_SUBMISSIONS_FILE,
        JSON.stringify({
          submissions: contactSubmissions,
          updatedAt: new Date().toISOString(),
        }, null, 2),
        'utf8',
      )
    })
    .catch(err => {
      console.error('[contact] failed to write submissions:', err)
    })
}

function queueReplyTemplatesWrite() {
  replyTemplatesWritePromise = replyTemplatesWritePromise
    .then(async () => {
      await mkdir(DATA_DIR, { recursive: true })
      await writeFile(
        REPLY_TEMPLATES_FILE,
        JSON.stringify({ templates: replyTemplates, updatedAt: new Date().toISOString() }, null, 2),
        'utf8',
      )
    })
    .catch(err => {
      console.error('[reply-templates] failed to write templates:', err)
    })
}

function queueReplyHistoryWrite() {
  replyHistoryWritePromise = replyHistoryWritePromise
    .then(async () => {
      await mkdir(DATA_DIR, { recursive: true })
      await writeFile(
        REPLY_HISTORY_FILE,
        JSON.stringify({ items: replyHistory, updatedAt: new Date().toISOString() }, null, 2),
        'utf8',
      )
    })
    .catch(err => {
      console.error('[reply-history] failed to write history:', err)
    })
}

function loadContactSubmissionsFromDisk() {
  return readFile(CONTACT_SUBMISSIONS_FILE, 'utf8')
    .then(raw => {
      const parsed = JSON.parse(raw)
      contactSubmissions = Array.isArray(parsed?.submissions)
        ? parsed.submissions.slice(0, MAX_CONTACT_SUBMISSIONS)
        : []
    })
    .catch(() => {
      contactSubmissions = []
    })
}

function loadReplyTemplatesFromDisk() {
  return readFile(REPLY_TEMPLATES_FILE, 'utf8')
    .then(raw => {
      const parsed = JSON.parse(raw)
      replyTemplates = sanitizeReplyTemplates(parsed?.templates)
    })
    .catch(() => {
      replyTemplates = [...DEFAULT_REPLY_TEMPLATES]
    })
}

function loadReplyHistoryFromDisk() {
  return readFile(REPLY_HISTORY_FILE, 'utf8')
    .then(raw => {
      const parsed = JSON.parse(raw)
      replyHistory = sanitizeReplyHistory(parsed?.items)
    })
    .catch(() => {
      replyHistory = []
    })
}

function normalizeMessageType(value) {
  if (value === 'question' || value === 'testimony' || value === 'topic') return value
  return 'general'
}

const USE_RESEND_AUTOMATION_WELCOME = process.env.RESEND_AUTOMATION_WELCOME === 'true'
const ADMIN_REPLY_FROM = 'Verse by Verse with Nate <hello@versebyversewithnate.us>'

function shouldSendWelcomeEmail({ subscribe }) {
  return subscribe === true
}

function buildAdminReplyTemplate({ recipientName, message }) {
  const safeRecipientName = escapeHtml(recipientName || 'friend')
  const safeMessage = escapeHtml(message).replace(/\n/g, '<br/>')

  return `
    <div style="margin:0;padding:0;background-color:#f5f1e8;font-family:Georgia,serif;color:#201a10;">
      <table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#f5f1e8;">
        <tr>
          <td align="center" style="padding:28px 16px;">
            <table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:680px;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">
              <tr>
                <td style="background:#111111;padding:20px 24px;border-bottom:3px solid #c8860a;">
                  <div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.32em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>
                  <h1 style="margin:10px 0 0;color:#f4ead5;font-size:26px;line-height:1.2;">A Personal Reply</h1>
                </td>
              </tr>
              <tr>
                <td style="padding:26px 24px 18px;">
                  <p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:16px;line-height:1.6;color:#201a10;">Hi ${safeRecipientName},</p>
                  <div style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">${safeMessage}</div>
                  <p style="margin:0;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">Grace and peace,<br/>Verse by Verse with Nate</p>
                </td>
              </tr>
              <tr>
                <td style="background:#f7f2e5;border-top:1px solid #e8dcc1;padding:14px 24px;">
                  <p style="margin:0;font-family:Arial,sans-serif;font-size:12px;line-height:1.5;color:#735a2b;">From: hello@versebyversewithnate.us</p>
                </td>
              </tr>
            </table>
          </td>
        </tr>
      </table>
    </div>
  `
}

function addContactSubmission({ name, email, message, messageType, subscribe }) {
  const submission = {
    id: randomUUID(),
    submittedAt: new Date().toISOString(),
    name,
    email,
    message,
    messageType: normalizeMessageType(messageType),
    subscribe: subscribe === true,
    archived: false,
  }

  contactSubmissions.unshift(submission)
  contactSubmissions = contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
  queueContactSubmissionsWrite()
  return submission
}

async function syncContactToResend(name, email) {
  if (!process.env.RESEND_API_KEY) return

  const { firstName, lastName } = splitName(name)
  const contactResend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY)

  try {
    const { error: contactError } = await contactResend.contacts.create({
      email,
      firstName,
      lastName,
      unsubscribed: false,
      ...(process.env.RESEND_SEGMENT_ID
        ? { segments: [{ id: process.env.RESEND_SEGMENT_ID }] }
        : {}),
    })

    if (contactError) {
      const { error: updateError } = await contactResend.contacts.update({
        email,
        firstName,
        lastName,
        unsubscribed: false,
      })

      if (updateError) {
        console.error('[resend] contact sync error:', updateError)
      }
    }
  } catch (err) {
    console.error('[resend] contact sync exception:', err)
  }
}

function createTitusDownloadToken(email) {
  const token = randomUUID()
  titusDownloadTokens.set(token, {
    email,
    expiresAt: Date.now() + DOWNLOAD_TOKEN_TTL_MS,
  })
  return token
}

function consumeTitusDownloadToken(token) {
  const entry = titusDownloadTokens.get(token)
  if (!entry) return false
  titusDownloadTokens.delete(token)
  if (entry.expiresAt <= Date.now()) return false
  return true
}

function sanitizeUserAgent(userAgent) {
  if (!userAgent || typeof userAgent !== 'string') return 'unknown'
  return userAgent.trim().slice(0, 300) || 'unknown'
}

function detectBot(userAgent, pathInfo = {}) {
  if (!userAgent || typeof userAgent !== 'string') {
    return { isBot: true, reason: 'missing-user-agent' }
  }

  const ua = userAgent.toLowerCase()

  // Search engine crawlers
  if (/googlebot|bingbot|yandexbot|baiduspider|slurp|duckduckbot|sluplicate|googlebot-mobile/.test(ua)) {
    return { isBot: true, reason: 'search-crawler' }
  }

  // Social media crawlers
  if (/facebookexternalhit|twitterbot|linkedinbot|pinterest|whatsapp|slack|discord|telegram|reddit|mastodon/.test(ua)) {
    return { isBot: true, reason: 'social-crawler' }
  }

  // Headless browsers and automation
  if (/headless|phantomjs|puppeteer|playwright|selenium|nightmarebot|watir|webdriver|wdio|nightmare/.test(ua)) {
    return { isBot: true, reason: 'headless-browser' }
  }

  // Monitoring and uptime checkers
  if (/uptimerobot|pingdom|statuspage|pagerduty|sentry|datadog|grafana|prometheus|newrelic|appdynamics/.test(ua)) {
    return { isBot: true, reason: 'monitoring-tool' }
  }

  // Security scanners and tools
  if (/nmap|nikto|masscan|metasploit|nessus|openvas|qualys|burpsuite|zap|acunetix|sqlmap/.test(ua)) {
    return { isBot: true, reason: 'security-scanner' }
  }

  // HTTP clients and frameworks
  if (/^(curl|wget|python|java|go|node|ruby|php|perl|lua|rust)[\/-]/.test(ua)) {
    return { isBot: true, reason: 'http-client' }
  }

  // Common crawler keywords
  if (/bot|crawler|spider|scraper|indexer|reader|fetcher|loader|agent|spyware|tracking|monitor/.test(ua)) {
    // But allow some common real user agents that might contain these words
    if (!/chrome|firefox|safari|opera|edge|msie|trident|like gecko/.test(ua)) {
      return { isBot: true, reason: 'bot-keyword' }
    }
  }

  return { isBot: false, reason: null }
}

async function resolveGeo(ip) {
  if (!ip || isPrivateOrLocalIp(ip)) {
    return {
      country: 'Local/Unknown',
      state: 'Local/Unknown',
      county: 'Local/Unknown',
      city: 'Local/Unknown',
    }
  }

  const cached = visitorStats.geoCacheByIp[ip]
  if (cached) {
    return cached
  }

  const providers = [
    async () => {
      const controller = new AbortController()
      const timeout = setTimeout(() => controller.abort(), 2500)
      try {
        const response = await fetch(
          `http://ip-api.com/json/${encodeURIComponent(ip)}?fields=status,country,regionName,city,district`,
          { signal: controller.signal },
        )
        if (!response.ok) return null
        const data = await response.json()
        if (data?.status !== 'success') return null
        return {
          country: data?.country || 'Unknown',
          state: data?.regionName || 'Unknown',
          county: data?.district || 'Unknown',
          city: data?.city || 'Unknown',
        }
      } finally {
        clearTimeout(timeout)
      }
    },
    async () => {
      const controller = new AbortController()
      const timeout = setTimeout(() => controller.abort(), 2500)
      try {
        const response = await fetch(`https://ipwho.is/${encodeURIComponent(ip)}`, { signal: controller.signal })
        if (!response.ok) return null
        const data = await response.json()
        if (!data?.success) return null
        return {
          country: data?.country || 'Unknown',
          state: data?.region || 'Unknown',
          county: data?.region || 'Unknown',
          city: data?.city || 'Unknown',
        }
      } finally {
        clearTimeout(timeout)
      }
    },
  ]

  for (const provider of providers) {
    try {
      const geo = await provider()
      if (geo) {
        visitorStats.geoCacheByIp[ip] = geo
        queueVisitorStatsWrite()
        return geo
      }
    } catch {
      // Try next provider.
    }
  }

  const fallback = {
    country: 'Unknown',
    state: 'Unknown',
    county: 'Unknown',
    city: 'Unknown',
  }
  visitorStats.geoCacheByIp[ip] = fallback
  queueVisitorStatsWrite()
  return fallback
}

async function recordVisitor(req, res) {
  const cookies = parseCookies(req.headers.cookie)
  let visitorId = cookies[VISITOR_COOKIE]
  if (!visitorId) {
    visitorId = randomUUID()
    res.append('Set-Cookie', `${VISITOR_COOKIE}=${encodeURIComponent(visitorId)}; Max-Age=31536000; Path=/; SameSite=Lax`)
  }

  const nowIso = new Date().toISOString()
  const pathKey = normalizeHitPath(req.path)
  const ip = getClientIp(req)
  const ua = sanitizeUserAgent(req.get('user-agent'))

  const existingVisitor = visitorStats.visitors[visitorId]
  const isReturning = Boolean(existingVisitor)
  const geo = await resolveGeo(ip)

  if (!existingVisitor) {
    visitorStats.uniqueVisitors += 1
  } else {
    visitorStats.returningVisits += 1
  }

  const ipHash = createHash('sha256').update(ip).digest('hex')
  const nextVisitCount = (existingVisitor?.visitCount ?? 0) + 1
  const userAgents = Array.from(new Set([...(existingVisitor?.userAgents ?? []), ua])).slice(0, 5)

  visitorStats.visitors[visitorId] = {
    visitorId,
    ip,
    ipHash,
    firstSeenAt: existingVisitor?.firstSeenAt ?? nowIso,
    lastSeenAt: nowIso,
    visitCount: nextVisitCount,
    lastPath: pathKey,
    returningVisitor: isReturning,
    location: geo,
    userAgents,
  }

  visitorStats.totalVisits += 1
  visitorStats.firstVisitAt = visitorStats.firstVisitAt ?? nowIso
  visitorStats.lastVisitAt = nowIso
  visitorStats.recentVisits.unshift({
    at: nowIso,
    visitorId,
    ip,
    path: pathKey,
    country: geo.country,
    state: geo.state,
    county: geo.county,
    city: geo.city,
    returningVisitor: isReturning,
    visitCount: nextVisitCount,
  })
  visitorStats.recentVisits = visitorStats.recentVisits.slice(0, MAX_RECENT_VISITS)

  queueVisitorStatsWrite()
}

function loadVisitorStatsFromDisk() {
  return readFile(VISITOR_STATS_FILE, 'utf8')
    .then(raw => {
      const parsed = JSON.parse(raw)
      visitorStats = {
        totalVisits: Number(parsed?.totalVisits) || 0,
        uniqueVisitors: Number(parsed?.uniqueVisitors) || 0,
        returningVisits: Number(parsed?.returningVisits) || 0,
        firstVisitAt: typeof parsed?.firstVisitAt === 'string' ? parsed.firstVisitAt : null,
        lastVisitAt: typeof parsed?.lastVisitAt === 'string' ? parsed.lastVisitAt : null,
        visitors: parsed?.visitors && typeof parsed.visitors === 'object' ? parsed.visitors : {},
        recentVisits: Array.isArray(parsed?.recentVisits) ? parsed.recentVisits.slice(0, MAX_RECENT_VISITS) : [],
        geoCacheByIp: parsed?.geoCacheByIp && typeof parsed.geoCacheByIp === 'object' ? parsed.geoCacheByIp : {},
      }
    })
    .catch(() => {
      visitorStats = { ...EMPTY_VISITOR_STATS }
    })
}

function buildTopLocations(list, key) {
  const counts = {}
  for (const row of list) {
    const val = row?.[key] || 'Unknown'
    counts[val] = (counts[val] ?? 0) + 1
  }
  return Object.entries(counts)
    .sort((a, b) => b[1] - a[1])
    .slice(0, 10)
    .map(([name, hits]) => ({ name, hits }))
}

function pruneStatsByDays(daysRaw) {
  const days = Number(daysRaw)
  const retentionDays = Number.isFinite(days) && days > 0 ? Math.floor(days) : VISITOR_RETENTION_DAYS_DEFAULT
  const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000

  const keepRecent = visitorStats.recentVisits.filter(v => {
    const ts = new Date(v.at).getTime()
    return Number.isFinite(ts) && ts >= cutoff
  })

  const allowedVisitorIds = new Set(keepRecent.map(v => v.visitorId))
  const nextVisitors = {}
  for (const [id, data] of Object.entries(visitorStats.visitors)) {
    const lastSeen = new Date(data.lastSeenAt ?? 0).getTime()
    if (allowedVisitorIds.has(id) || (Number.isFinite(lastSeen) && lastSeen >= cutoff)) {
      nextVisitors[id] = data
    }
  }

  const nextByDay = {}
  const nextByDayReal = {}
  const nextByDayBot = {}
  for (const [day, count] of Object.entries(hitStats.byDay)) {
    const ts = new Date(`${day}T00:00:00.000Z`).getTime()
    if (Number.isFinite(ts) && ts >= cutoff) {
      nextByDay[day] = count
      nextByDayReal[day] = hitStats.byDayReal?.[day] ?? 0
      nextByDayBot[day] = hitStats.byDayBot?.[day] ?? 0
    }
  }

  visitorStats.recentVisits = keepRecent
  visitorStats.visitors = nextVisitors
  visitorStats.uniqueVisitors = Object.keys(nextVisitors).length
  visitorStats.totalVisits = keepRecent.length
  visitorStats.returningVisits = keepRecent.filter(v => v.returningVisitor).length
  visitorStats.firstVisitAt = keepRecent.length > 0 ? keepRecent[keepRecent.length - 1].at : null
  visitorStats.lastVisitAt = keepRecent.length > 0 ? keepRecent[0].at : null

  hitStats.byDay = nextByDay
  hitStats.byDayReal = nextByDayReal
  hitStats.byDayBot = nextByDayBot

  queueHitStatsWrite()
  queueVisitorStatsWrite()

  return {
    retentionDays,
    remainingVisits: visitorStats.totalVisits,
    remainingVisitors: visitorStats.uniqueVisitors,
  }
}

async function createBackupSnapshot(reason = 'scheduled') {
  try {
    await mkdir(BACKUP_DIR, { recursive: true })
    const stamp = new Date().toISOString().replace(/[:.]/g, '-')
    const backupPath = path.join(BACKUP_DIR, `snapshot-${stamp}-${reason}.json`)

    const payload = {
      createdAt: new Date().toISOString(),
      reason,
      adminContent: null,
      draftContent: null,
      publishState,
      hitStats,
      visitorStats,
      contactSubmissions,
      replyTemplates,
      replyHistory,
    }

    try {
      const contentRaw = await readFile(DATA_FILE, 'utf8')
      payload.adminContent = JSON.parse(contentRaw)
    } catch {
      payload.adminContent = null
    }

    try {
      const draftRaw = await readFile(DRAFT_DATA_FILE, 'utf8')
      payload.draftContent = JSON.parse(draftRaw)
    } catch {
      payload.draftContent = null
    }

    await writeFile(backupPath, JSON.stringify(payload, null, 2), 'utf8')

    const files = (await readdir(BACKUP_DIR)).filter(name => name.endsWith('.json')).sort()
    const maxFiles = BACKUP_RETENTION_DAYS
    if (files.length > maxFiles) {
      const toDelete = files.slice(0, files.length - maxFiles)
      await Promise.all(toDelete.map(name => unlink(path.join(BACKUP_DIR, name)).catch(() => {})))
    }

    lastBackupStatus = { ok: true, at: new Date().toISOString(), error: null, file: path.basename(backupPath) }
  } catch (err) {
    lastBackupStatus = { ok: false, at: new Date().toISOString(), error: String(err), file: null }
    console.error('[backup] failed to create snapshot:', err)
  }
}

async function listBackupFiles() {
  await mkdir(BACKUP_DIR, { recursive: true })
  const files = (await readdir(BACKUP_DIR)).filter(name => name.endsWith('.json')).sort().reverse()
  return files
}

async function readBackupPreview(filename) {
  if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..') || !filename.endsWith('.json')) {
    throw new Error('Invalid backup filename')
  }

  const fullPath = path.join(BACKUP_DIR, filename)
  const [fileInfo, raw] = await Promise.all([
    stat(fullPath),
    readFile(fullPath, 'utf8'),
  ])
  const parsed = JSON.parse(raw)

  return {
    filename,
    sizeBytes: fileInfo.size,
    createdAt: typeof parsed?.createdAt === 'string' ? parsed.createdAt : null,
    reason: typeof parsed?.reason === 'string' ? parsed.reason : 'unknown',
    adminUpdatedAt: typeof parsed?.adminContent?.updatedAt === 'string' ? parsed.adminContent.updatedAt : null,
    totalHits: Number(parsed?.hitStats?.totalHits) || 0,
    totalVisits: Number(parsed?.visitorStats?.totalVisits) || 0,
  }
}

async function listBackupPreviews() {
  const files = await listBackupFiles()
  const previews = await Promise.all(files.map(async filename => {
    try {
      return await readBackupPreview(filename)
    } catch {
      return {
        filename,
        sizeBytes: 0,
        createdAt: null,
        reason: 'unknown',
        adminUpdatedAt: null,
        totalHits: 0,
        totalVisits: 0,
      }
    }
  }))
  return previews
}

function sanitizeLoadedHitStats(value) {
  return {
    totalHits: Number(value?.totalHits) || 0,
    firstHitAt: typeof value?.firstHitAt === 'string' ? value.firstHitAt : null,
    lastHitAt: typeof value?.lastHitAt === 'string' ? value.lastHitAt : null,
    byPath: value?.byPath && typeof value.byPath === 'object' ? value.byPath : {},
    byDay: value?.byDay && typeof value.byDay === 'object' ? value.byDay : {},
  }
}

function sanitizeLoadedVisitorStats(value) {
  return {
    totalVisits: Number(value?.totalVisits) || 0,
    uniqueVisitors: Number(value?.uniqueVisitors) || 0,
    returningVisits: Number(value?.returningVisits) || 0,
    firstVisitAt: typeof value?.firstVisitAt === 'string' ? value.firstVisitAt : null,
    lastVisitAt: typeof value?.lastVisitAt === 'string' ? value.lastVisitAt : null,
    visitors: value?.visitors && typeof value.visitors === 'object' ? value.visitors : {},
    recentVisits: Array.isArray(value?.recentVisits) ? value.recentVisits.slice(0, MAX_RECENT_VISITS) : [],
    geoCacheByIp: value?.geoCacheByIp && typeof value.geoCacheByIp === 'object' ? value.geoCacheByIp : {},
  }
}

function sanitizeLoadedContactSubmissions(value) {
  if (!Array.isArray(value)) return []
  return value
    .slice(0, MAX_CONTACT_SUBMISSIONS)
    .filter(entry => entry && typeof entry === 'object')
    .map(entry => ({
      id: typeof entry.id === 'string' && entry.id.trim() ? entry.id.trim() : randomUUID(),
      submittedAt: typeof entry.submittedAt === 'string' ? entry.submittedAt : new Date().toISOString(),
      name: typeof entry.name === 'string' ? entry.name.trim().slice(0, 200) : '',
      email: typeof entry.email === 'string' ? entry.email.trim().slice(0, 320) : '',
      message: typeof entry.message === 'string' ? entry.message.trim().slice(0, 3000) : '',
      messageType: normalizeMessageType(entry.messageType),
      subscribe: entry.subscribe === true,
      archived: entry.archived === true,
    }))
}

async function restoreFromBackup(filename) {
  if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..') || !filename.endsWith('.json')) {
    throw new Error('Invalid backup filename')
  }

  const fullPath = path.join(BACKUP_DIR, filename)
  const raw = await readFile(fullPath, 'utf8')
  const parsed = JSON.parse(raw)

  await createBackupSnapshot('pre-restore')

  if (parsed?.adminContent && typeof parsed.adminContent === 'object') {
    await mkdir(DATA_DIR, { recursive: true })
    await writeFile(DATA_FILE, JSON.stringify(parsed.adminContent, null, 2), 'utf8')
  }

  if (parsed?.draftContent && typeof parsed.draftContent === 'object') {
    await mkdir(DATA_DIR, { recursive: true })
    await writeFile(DRAFT_DATA_FILE, JSON.stringify(parsed.draftContent, null, 2), 'utf8')
  }

  if (parsed?.publishState && typeof parsed.publishState === 'object') {
    publishState = {
      draftUpdatedAt: typeof parsed.publishState.draftUpdatedAt === 'string' ? parsed.publishState.draftUpdatedAt : null,
      publishedAt: typeof parsed.publishState.publishedAt === 'string' ? parsed.publishState.publishedAt : null,
    }
  }

  hitStats = sanitizeLoadedHitStats(parsed?.hitStats)
  visitorStats = sanitizeLoadedVisitorStats(parsed?.visitorStats)
  contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions)
  replyTemplates = sanitizeReplyTemplates(parsed?.replyTemplates)
  replyHistory = sanitizeReplyHistory(parsed?.replyHistory)

  queueHitStatsWrite()
  queueVisitorStatsWrite()
  queueContactSubmissionsWrite()
  queueReplyTemplatesWrite()
  queueReplyHistoryWrite()

  await Promise.all([hitStatsWritePromise, visitorStatsWritePromise, contactSubmissionsWritePromise, replyTemplatesWritePromise, replyHistoryWritePromise])
  await refreshContentCaches()
  await createBackupSnapshot('post-restore')
}

function normalizeHitPath(pathname) {
  if (!pathname || pathname === '') return '/'
  if (pathname.length > 1 && pathname.endsWith('/')) {
    return pathname.slice(0, -1)
  }
  return pathname
}

function shouldCountHit(req) {
  if (req.method !== 'GET') return false
  if (req.path.startsWith('/api/')) return false
  if (req.path === '/admin' || req.path.startsWith('/admin/')) return false
  if (req.path === '/favicon.ico') return false

  // Ignore direct asset requests and only count document-like requests.
  const hasFileExt = path.extname(req.path) !== ''
  if (hasFileExt) return false

  const accept = req.get('accept') ?? ''
  return accept.includes('text/html') || accept === '*/*' || accept === ''
}

function queueHitStatsWrite() {
  hitStatsWritePromise = hitStatsWritePromise
    .then(async () => {
      await mkdir(DATA_DIR, { recursive: true })
      await writeFile(
        HIT_STATS_FILE,
        JSON.stringify({
          ...hitStats,
          updatedAt: new Date().toISOString(),
        }, null, 2),
        'utf8',
      )
      lastHitStatsWrite = { ok: true, at: new Date().toISOString(), error: null }
    })
    .catch(err => {
      console.error('[stats] failed to write hit stats:', err)
      lastHitStatsWrite = { ok: false, at: new Date().toISOString(), error: String(err) }
    })
}

function recordHit(pathname, isBot = false, botReason = null) {
  const nowIso = new Date().toISOString()
  const dayKey = nowIso.slice(0, 10)
  const safePath = normalizeHitPath(pathname)

  hitStats.totalHits += 1
  hitStats.lastHitAt = nowIso
  hitStats.firstHitAt = hitStats.firstHitAt ?? nowIso

  if (isBot) {
    hitStats.botHits += 1
    hitStats.byPathBot[safePath] = (hitStats.byPathBot[safePath] ?? 0) + 1
    hitStats.byDayBot[dayKey] = (hitStats.byDayBot[dayKey] ?? 0) + 1
    if (botReason) {
      hitStats.botReasons[botReason] = (hitStats.botReasons[botReason] ?? 0) + 1
    }
  } else {
    hitStats.realHits += 1
    hitStats.byPathReal[safePath] = (hitStats.byPathReal[safePath] ?? 0) + 1
    hitStats.byDayReal[dayKey] = (hitStats.byDayReal[dayKey] ?? 0) + 1
  }

  // Keep legacy byPath and byDay for backward compatibility
  hitStats.byPath[safePath] = (hitStats.byPath[safePath] ?? 0) + 1
  hitStats.byDay[dayKey] = (hitStats.byDay[dayKey] ?? 0) + 1

  queueHitStatsWrite()
}

function buildLastNDaysStats(days) {
  const out = []
  const today = new Date()

  for (let i = days - 1; i >= 0; i -= 1) {
    const d = new Date(today)
    d.setDate(today.getDate() - i)
    const dayKey = d.toISOString().slice(0, 10)
    out.push({ day: dayKey, hits: hitStats.byDay[dayKey] ?? 0 })
  }

  return out
}

function loadHitStatsFromDisk() {
  return readFile(HIT_STATS_FILE, 'utf8')
    .then(raw => {
      const parsed = JSON.parse(raw)
      hitStats = {
        totalHits: Number(parsed?.totalHits) || 0,
        realHits: Number(parsed?.realHits) || 0,
        botHits: Number(parsed?.botHits) || 0,
        firstHitAt: typeof parsed?.firstHitAt === 'string' ? parsed.firstHitAt : null,
        lastHitAt: typeof parsed?.lastHitAt === 'string' ? parsed.lastHitAt : null,
        byPath: parsed?.byPath && typeof parsed.byPath === 'object' ? parsed.byPath : {},
        byPathReal: parsed?.byPathReal && typeof parsed.byPathReal === 'object' ? parsed.byPathReal : {},
        byPathBot: parsed?.byPathBot && typeof parsed.byPathBot === 'object' ? parsed.byPathBot : {},
        byDay: parsed?.byDay && typeof parsed.byDay === 'object' ? parsed.byDay : {},
        byDayReal: parsed?.byDayReal && typeof parsed.byDayReal === 'object' ? parsed.byDayReal : {},
        byDayBot: parsed?.byDayBot && typeof parsed.byDayBot === 'object' ? parsed.byDayBot : {},
        botReasons: parsed?.botReasons && typeof parsed.botReasons === 'object' ? parsed.botReasons : {},
      }
    })
    .catch(() => {
      hitStats = { ...EMPTY_HIT_STATS }
    })
}

const app = express()
app.use(express.json({ limit: '10mb' }))
const trustProxyHops = Number(process.env.TRUST_PROXY_HOPS ?? 1)
app.set('trust proxy', Number.isFinite(trustProxyHops) && trustProxyHops >= 0 ? trustProxyHops : 1)

app.get('/api/admin-content', async (req, res) => {
  const source = req.query?.source === 'draft' ? 'draft' : 'published'
  if (source === 'draft' && !isValidAdminSession(req)) {
    res.status(401).json({ message: 'Unauthorized' })
    return
  }

  try {
    const parsed = await loadSiteContentFile(source === 'draft' ? DRAFT_DATA_FILE : DATA_FILE)
    res.json(parsed)
  } catch {
    if (source === 'draft') {
      res.status(404).json({ message: 'No saved draft content file yet.' })
      return
    }
    res.status(404).json({ message: 'No saved admin content file yet.' })
  }
})

app.get('/api/admin-content-state', requireAdminAuth, (_req, res) => {
  res.json({
    publishState,
    hasDraft: Boolean(cachedDraftSiteContent),
    hasPublished: Boolean(cachedSiteContent),
  })
})

app.get('/api/site-config', async (_req, res) => {
  try {
    const parsed = await loadSiteContentFile(DATA_FILE)
    const siteContent = parsed.siteContent ?? {}

    res.json({
      seo: siteContent.seo ?? DEFAULT_SEO,
      legal: siteContent.legal ?? DEFAULT_LEGAL,
      redirects: siteContent.redirects ?? DEFAULT_REDIRECT_RULES,
      podcastFeaturedLinks: siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS,
      publishState,
      updatedAt: parsed.updatedAt ?? null,
    })
  } catch {
    res.json({
      seo: DEFAULT_SEO,
      legal: DEFAULT_LEGAL,
      redirects: DEFAULT_REDIRECT_RULES,
      podcastFeaturedLinks: DEFAULT_PODCAST_FEATURED_LINKS,
      publishState,
      updatedAt: null,
    })
  }
})

app.put('/api/admin-content-draft', requireAdminAuth, async (req, res) => {
  try {
    const { siteContent } = req.body ?? {}

    if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) {
      res.status(400).json({ message: 'Invalid payload: siteContent must be an object.' })
      return
    }

    const safeSiteContent = sanitizeSiteContent(siteContent)
    const updatedAt = new Date().toISOString()

    await mkdir(DATA_DIR, { recursive: true })
    await writeFile(
      DRAFT_DATA_FILE,
      JSON.stringify({ siteContent: safeSiteContent, updatedAt }, null, 2),
      'utf8',
    )

    cachedDraftSiteContent = safeSiteContent
    publishState.draftUpdatedAt = updatedAt

    res.json({ ok: true, updatedAt })
  } catch {
    res.status(500).json({ message: 'Failed to persist admin draft content.' })
  }
})

app.post('/api/admin-content/publish', requireAdminAuth, async (_req, res) => {
  try {
    const source = cachedDraftSiteContent
      ? { siteContent: cachedDraftSiteContent, updatedAt: publishState.draftUpdatedAt ?? new Date().toISOString() }
      : await loadSiteContentFile(DRAFT_DATA_FILE)

    const publishedAt = new Date().toISOString()

    await mkdir(DATA_DIR, { recursive: true })
    await writeFile(
      DATA_FILE,
      JSON.stringify({ siteContent: source.siteContent, updatedAt: publishedAt }, null, 2),
      'utf8',
    )

    cachedSiteContent = source.siteContent
    publishState.publishedAt = publishedAt

    if (draftQuestions !== null) {
      questions = draftQuestions.slice(0, MAX_QUESTIONS)
      await mkdir(DATA_DIR, { recursive: true })
      await writeFile(
        QUESTIONS_FILE,
        JSON.stringify({ questions, updatedAt: publishedAt }, null, 2),
        'utf8',
      )
    }

    await createBackupSnapshot('post-publish')

    res.json({ ok: true, publishedAt })
  } catch {
    res.status(500).json({ message: 'Failed to publish draft content.' })
  }
})

app.get('/api/admin-assets', requireAdminAuth, async (_req, res) => {
  try {
    const assets = await listUploadedAssets()
    res.json({ assets })
  } catch {
    res.status(500).json({ message: 'Could not list uploaded assets.' })
  }
})

app.post('/api/admin-assets', requireAdminAuth, async (req, res) => {
  try {
    const filename = typeof req.body?.filename === 'string' ? req.body.filename : ''
    const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
    const ext = inferImageExtensionFromDataUrl(dataUrl)

    if (!ext) {
      res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, GIF, PDF, DOC, or DOCX data URL.' })
      return
    }

    const base64 = dataUrl.split(',')[1] ?? ''
    const buffer = Buffer.from(base64, 'base64')
    if (buffer.length === 0 || buffer.length > (8 * 1024 * 1024)) {
      res.status(400).json({ message: 'Upload must be between 1 byte and 8MB.' })
      return
    }

    const baseName = normalizeAssetBaseName(filename.replace(/\.[a-z0-9]+$/i, ''))
    const finalName = `${baseName}-${Date.now()}${ext}`

    await mkdir(UPLOADS_DIR, { recursive: true })
    await writeFile(path.join(UPLOADS_DIR, finalName), buffer)
    const metadata = await readUploadsMetadata()
    metadata[finalName] = []
    await writeUploadsMetadata(metadata)

    res.json({ ok: true, asset: { filename: finalName, url: `/uploads/${finalName}` } })
  } catch {
    res.status(500).json({ message: 'Upload failed.' })
  }
})

app.patch('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => {
  try {
    const { filename } = req.params
    if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..')) {
      res.status(400).json({ message: 'Invalid filename.' })
      return
    }

    const tags = Array.isArray(req.body?.tags)
      ? req.body.tags.filter(tag => typeof tag === 'string').map(tag => tag.trim()).filter(Boolean)
      : []

    const filePath = path.join(UPLOADS_DIR, filename)
    await stat(filePath)

    const metadata = await readUploadsMetadata()
    metadata[filename] = tags
    await writeUploadsMetadata(metadata)

    res.json({ ok: true, tags })
  } catch {
    res.status(404).json({ message: 'Asset not found.' })
  }
})

app.delete('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => {
  try {
    const { filename } = req.params
    if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..')) {
      res.status(400).json({ message: 'Invalid filename.' })
      return
    }

    await unlink(path.join(UPLOADS_DIR, filename))
    const metadata = await readUploadsMetadata()
    delete metadata[filename]
    await writeUploadsMetadata(metadata)
    res.json({ ok: true })
  } catch {
    res.status(404).json({ message: 'Asset not found.' })
  }
})

app.get('/api/admin-ops/status', requireAdminAuth, (_req, res) => {
  res.json({
    buildCommit: process.env.BUILD_COMMIT ?? null,
    buildNumber: process.env.BUILD_NUMBER ?? null,
    deployedAt: process.env.DEPLOYED_AT ?? null,
    cachePurge: lastCachePurgeStatus,
    deployHook: lastDeployHookStatus,
  })
})

app.post('/api/admin-ops/purge-cache', requireAdminAuth, async (_req, res) => {
  const result = await invokeWebhook(process.env.CACHE_PURGE_WEBHOOK_URL ?? '', 'cache-purge')
  lastCachePurgeStatus = { ok: result.ok, at: new Date().toISOString(), error: result.ok ? null : result.message }

  if (!result.ok) {
    res.status(400).json({ message: result.message })
    return
  }

  res.json({ ok: true, message: result.message })
})

app.post('/api/admin-ops/deploy', requireAdminAuth, async (_req, res) => {
  const result = await invokeWebhook(process.env.DEPLOY_WEBHOOK_URL ?? '', 'deploy')
  lastDeployHookStatus = { ok: result.ok, at: new Date().toISOString(), error: result.ok ? null : result.message }

  if (!result.ok) {
    res.status(400).json({ message: result.message })
    return
  }

  res.json({ ok: true, message: result.message })
})

function queueQuestionsWrite() {
  questionsWritePromise = questionsWritePromise
    .then(async () => {
      await mkdir(DATA_DIR, { recursive: true })
      await writeFile(
        QUESTIONS_FILE,
        JSON.stringify({
          questions,
          updatedAt: new Date().toISOString(),
        }, null, 2),
        'utf8',
      )
    })
    .catch(err => {
      console.error('[questions] failed to write questions:', err)
    })
}

function queueDraftQuestionsWrite() {
  if (draftQuestions === null) return
  draftQuestionsWritePromise = draftQuestionsWritePromise
    .then(async () => {
      await mkdir(DATA_DIR, { recursive: true })
      await writeFile(
        DRAFT_QUESTIONS_FILE,
        JSON.stringify({ questions: draftQuestions, updatedAt: new Date().toISOString() }, null, 2),
        'utf8',
      )
      publishState.draftUpdatedAt = new Date().toISOString()
    })
    .catch(err => {
      console.error('[draft-questions] failed to write draft questions:', err)
    })
}

function loadQuestionsFromDisk() {
  return readFile(QUESTIONS_FILE, 'utf8')
    .then(raw => {
      const parsed = JSON.parse(raw)
      if (Array.isArray(parsed)) {
        questions = parsed.slice(0, MAX_QUESTIONS)
      } else if (Array.isArray(parsed?.questions)) {
        questions = parsed.questions.slice(0, MAX_QUESTIONS)
      } else {
        questions = []
      }
    })
    .catch(() => {
      questions = []
    })
}
// Rate limiter: max 10 attempts per 15 minutes per IP on the login endpoint
const loginRateLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10,
  standardHeaders: true,
  legacyHeaders: false,
  message: { message: 'Too many login attempts. Please wait 15 minutes and try again.' },
  skipSuccessfulRequests: true,
})

app.get('/api/admin-auth/status', async (req, res) => {
  res.json({
    authenticated: isValidAdminSession(req),
    configured: isAdminPasswordConfigured(),
    totpEnabled: await isTotpEnabled(),
  })
})

// Step 1: verify password. If TOTP is enabled, returns a short-lived pending token.
// If TOTP is not yet configured, logs straight in (backwards compatible).
app.post('/api/admin-auth/login', loginRateLimiter, async (req, res) => {
  const password = typeof req.body?.password === 'string' ? req.body.password : ''

  if (!isAdminPasswordConfigured()) {
    res.status(503).json({ message: 'ADMIN_PASSWORD is not configured on the server.' })
    return
  }

  if (!isAdminPasswordValid(password)) {
    res.status(401).json({ message: 'Invalid password.' })
    return
  }

  const totpOn = await isTotpEnabled()
  if (totpOn) {
    const pendingToken = createPendingSession()
    res.json({ totpRequired: true, pendingToken })
    return
  }

  const sessionToken = createAdminSession()
  setAdminSessionCookie(res, sessionToken)
  res.json({ ok: true })
})

// Step 2a: verify TOTP code (or recovery code) after password was accepted
app.post('/api/admin-auth/totp-verify', loginRateLimiter, async (req, res) => {
  const { pendingToken, code } = req.body ?? {}

  if (!consumePendingSession(pendingToken)) {
    res.status(401).json({ message: 'Session expired or invalid. Please sign in again.' })
    return
  }

  const state = await loadTotpState()
  if (!state?.secret || !state?.verified) {
    res.status(400).json({ message: 'TOTP is not configured.' })
    return
  }

  const codeStr = typeof code === 'string' ? code.trim() : ''

  // Try TOTP first
  if (verifyTotpCode(state.secret, codeStr)) {
    const sessionToken = createAdminSession()
    setAdminSessionCookie(res, sessionToken)
    res.json({ ok: true })
    return
  }

  // Try recovery code
  if (consumeRecoveryCode(state, codeStr)) {
    await saveTotpState(state)
    const sessionToken = createAdminSession()
    setAdminSessionCookie(res, sessionToken)
    res.json({ ok: true, usedRecoveryCode: true, remainingRecoveryCodes: state.hashedRecoveryCodes.length })
    return
  }

  res.status(401).json({ message: 'Invalid code. Try again or use a recovery code.' })
})

// TOTP setup: generate a new secret and QR code (admin must be authenticated OR provide valid password)
app.post('/api/admin-auth/totp-setup-init', requireAdminAuth, async (req, res) => {
  const secret = generateTotpSecret()
  const uri = getTotpUri(secret)
  const qrDataUrl = await qrcode.toDataURL(uri)
  // Store unverified secret temporarily
  const existing = await loadTotpState()
  await saveTotpState({ ...existing, secret, verified: false })
  res.json({ qrDataUrl, secret })
})

// TOTP setup: confirm the code to mark TOTP as verified and generate recovery codes
app.post('/api/admin-auth/totp-setup-confirm', requireAdminAuth, async (req, res) => {
  const { code } = req.body ?? {}
  const state = await loadTotpState()

  if (!state?.secret) {
    res.status(400).json({ message: 'No TOTP setup in progress. Call /totp-setup-init first.' })
    return
  }

  if (!verifyTotpCode(state.secret, typeof code === 'string' ? code.trim() : '')) {
    res.status(401).json({ message: 'Code incorrect. Scan the QR code again and try once more.' })
    return
  }

  const recoveryCodes = generateRecoveryCodes()
  await saveTotpState({
    secret: state.secret,
    verified: true,
    hashedRecoveryCodes: recoveryCodes.map(hashRecoveryCode),
    enabledAt: new Date().toISOString(),
  })

  res.json({ ok: true, recoveryCodes })
})

// Disable TOTP (requires active admin session)
app.post('/api/admin-auth/totp-disable', requireAdminAuth, async (req, res) => {
  await saveTotpState({ secret: null, verified: false, hashedRecoveryCodes: [], disabledAt: new Date().toISOString() })
  res.json({ ok: true })
})

// Regenerate recovery codes (requires active admin session)
app.post('/api/admin-auth/totp-regen-recovery', requireAdminAuth, async (req, res) => {
  const state = await loadTotpState()
  if (!state?.secret || !state?.verified) {
    res.status(400).json({ message: 'TOTP is not enabled.' })
    return
  }
  const recoveryCodes = generateRecoveryCodes()
  await saveTotpState({ ...state, hashedRecoveryCodes: recoveryCodes.map(hashRecoveryCode) })
  res.json({ ok: true, recoveryCodes })
})

app.post('/api/admin-auth/logout', (req, res) => {
  const cookies = parseCookies(req.headers.cookie)
  const sessionToken = cookies[ADMIN_SESSION_COOKIE]
  deleteAdminSession(sessionToken)
  clearAdminSessionCookie(res)
  res.json({ ok: true })
})

app.put('/api/admin-content', requireAdminAuth, async (req, res) => {
  try {
    const { siteContent } = req.body ?? {}

    if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) {
      res.status(400).json({ message: 'Invalid payload: siteContent must be an object.' })
      return
    }

    const safeSiteContent = sanitizeSiteContent(siteContent)
    const updatedAt = new Date().toISOString()

    await mkdir(DATA_DIR, { recursive: true })
    await writeFile(
      DATA_FILE,
      JSON.stringify({ siteContent: safeSiteContent, updatedAt }, null, 2),
      'utf8',
    )

    cachedSiteContent = safeSiteContent
    publishState.publishedAt = updatedAt

    res.json({ ok: true })
  } catch {
    res.status(500).json({ message: 'Failed to persist admin content.' })
  }
})

app.post('/api/analytics-consent', (req, res) => {
  const consent = req.body?.consent === true
  setConsentCookie(res, consent)
  res.json({ ok: true, consent })
})

app.get('/api/admin-stats', requireAdminAuth, (_req, res) => {
  const topPaths = Object.entries(hitStats.byPath)
    .sort((a, b) => b[1] - a[1])
    .slice(0, 10)
    .map(([pathKey, hits]) => ({ path: pathKey, hits }))

  const topPathsReal = Object.entries(hitStats.byPathReal)
    .sort((a, b) => b[1] - a[1])
    .slice(0, 10)
    .map(([pathKey, hits]) => ({ path: pathKey, hits }))

  const topPathsBot = Object.entries(hitStats.byPathBot)
    .sort((a, b) => b[1] - a[1])
    .slice(0, 10)
    .map(([pathKey, hits]) => ({ path: pathKey, hits }))

  const last7Days = buildLastNDaysStats(7)
  const last7DaysReal = last7Days.map(item => ({ 
    day: item.day, 
    hits: hitStats.byDayReal?.[item.day] ?? 0 
  }))
  const last7DaysBot = last7Days.map(item => ({ 
    day: item.day, 
    hits: hitStats.byDayBot?.[item.day] ?? 0 
  }))

  const last30Days = buildLastNDaysStats(30)
  const last30DaysTotal = last30Days.reduce((sum, item) => sum + item.hits, 0)
  const last30DaysRealTotal = last30Days.reduce((sum, item) => sum + (hitStats.byDayReal?.[item.day] ?? 0), 0)
  const last30DaysBotTotal = last30Days.reduce((sum, item) => sum + (hitStats.byDayBot?.[item.day] ?? 0), 0)

  const botReasons = Object.entries(hitStats.botReasons ?? {})
    .sort((a, b) => b[1] - a[1])
    .slice(0, 10)
    .map(([reason, count]) => ({ reason, count }))

  const recentVisitorRows = visitorStats.recentVisits.slice(0, 100)

  res.json({
    totalHits: hitStats.totalHits,
    realHits: hitStats.realHits ?? 0,
    botHits: hitStats.botHits ?? 0,
    firstHitAt: hitStats.firstHitAt,
    lastHitAt: hitStats.lastHitAt,
    topPaths,
    topPathsReal,
    topPathsBot,
    last7Days,
    last7DaysReal,
    last7DaysBot,
    last30DaysTotal,
    last30DaysRealTotal,
    last30DaysBotTotal,
    botReasons,
    visitors: {
      totalVisits: visitorStats.totalVisits,
      uniqueVisitors: visitorStats.uniqueVisitors,
      returningVisits: visitorStats.returningVisits,
      firstVisitAt: visitorStats.firstVisitAt,
      lastVisitAt: visitorStats.lastVisitAt,
      topCountries: buildTopLocations(recentVisitorRows, 'country'),
      topStates: buildTopLocations(recentVisitorRows, 'state'),
      topCounties: buildTopLocations(recentVisitorRows, 'county'),
      topCities: buildTopLocations(recentVisitorRows, 'city'),
      recentVisits: recentVisitorRows,
    },
    writeStatus: {
      hitStats: lastHitStatsWrite,
      visitorStats: lastVisitorStatsWrite,
      backups: lastBackupStatus,
      cachePurge: lastCachePurgeStatus,
      deployHook: lastDeployHookStatus,
    },
    contactTotals: {
      totalSubmissions: contactSubmissions.length,
      totalQuestions: contactSubmissions.filter(entry => normalizeMessageType(entry?.messageType) === 'question').length,
    },
  })
})

app.get('/api/admin-contact-submissions', requireAdminAuth, (_req, res) => {
  res.json({ submissions: contactSubmissions.slice(0, 300) })
})

app.patch('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
  const { id } = req.params
  if (typeof id !== 'string' || !id.trim()) {
    res.status(400).json({ message: 'Invalid submission id.' })
    return
  }

  const archived = req.body?.archived === true
  let found = false
  contactSubmissions = contactSubmissions.map(item => {
    if (item.id !== id) return item
    found = true
    return { ...item, archived }
  })

  if (!found) {
    res.status(404).json({ message: 'Submission not found.' })
    return
  }

  queueContactSubmissionsWrite()
  res.json({ ok: true, archived })
})

app.get('/api/admin-reply-config', requireAdminAuth, (_req, res) => {
  res.json({
    fromEmail: 'hello@versebyversewithnate.us',
    fromIdentity: ADMIN_REPLY_FROM,
    resendApiConfigured: Boolean(process.env.RESEND_API_KEY),
    canSendReplies: Boolean(process.env.RESEND_API_KEY),
    note: process.env.RESEND_API_KEY
      ? 'App is configured to attempt sends through Resend. Delivery still depends on Resend sender/domain verification.'
      : 'RESEND_API_KEY is missing, so admin replies cannot be sent yet.',
  })
})

app.get('/api/admin-contact-reply-templates', requireAdminAuth, (_req, res) => {
  res.json({ templates: replyTemplates })
})

app.put('/api/admin-contact-reply-templates', requireAdminAuth, (req, res) => {
  const nextTemplates = sanitizeReplyTemplates(req.body?.templates)
  replyTemplates = nextTemplates
  queueReplyTemplatesWrite()
  res.json({ ok: true, templates: replyTemplates })
})

app.get('/api/admin-contact-reply-history', requireAdminAuth, (_req, res) => {
  res.json({ items: replyHistory.slice(0, 100) })
})

app.delete('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
  const { id } = req.params
  if (typeof id !== 'string' || !id.trim()) {
    res.status(400).json({ message: 'Invalid submission id.' })
    return
  }

  const startLength = contactSubmissions.length
  contactSubmissions = contactSubmissions.filter(item => item.id !== id)
  if (contactSubmissions.length === startLength) {
    res.status(404).json({ message: 'Submission not found.' })
    return
  }

  queueContactSubmissionsWrite()
  res.json({ ok: true })
})

app.post('/api/admin-contact-submissions/:id/reply', requireAdminAuth, async (req, res) => {
  try {
    if (!process.env.RESEND_API_KEY) {
      res.status(503).json({ message: 'RESEND_API_KEY is not configured on the server.' })
      return
    }

    const { id } = req.params
    const subject = typeof req.body?.subject === 'string' ? req.body.subject.trim() : ''
    const message = typeof req.body?.message === 'string' ? req.body.message.trim() : ''

    if (!id || typeof id !== 'string') {
      res.status(400).json({ message: 'Invalid submission id.' })
      return
    }

    if (!subject || subject.length > 180) {
      res.status(400).json({ message: 'Subject is required and must be 180 characters or fewer.' })
      return
    }

    if (!message || message.length > 6000) {
      res.status(400).json({ message: 'Message is required and must be 6000 characters or fewer.' })
      return
    }

    const submission = contactSubmissions.find(entry => entry.id === id)
    if (!submission) {
      res.status(404).json({ message: 'Submission not found.' })
      return
    }

    if (!submission.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(submission.email)) {
      res.status(400).json({ message: 'Submission does not have a valid email address.' })
      return
    }

    const recipientName = splitName(submission.name).firstName || submission.name || 'friend'
    const html = buildAdminReplyTemplate({ recipientName, message })
    const text = `Hi ${recipientName},\n\n${message}\n\nGrace and peace,\nVerse by Verse with Nate\nhello@versebyversewithnate.us`
    const resend = new Resend(process.env.RESEND_API_KEY)

    const { error } = await resend.emails.send({
      from: ADMIN_REPLY_FROM,
      to: [submission.email],
      subject,
      replyTo: 'hello@versebyversewithnate.us',
      text,
      html,
    })

    if (error) throw error

    replyHistory.unshift({
      id: randomUUID(),
      submissionId: submission.id,
      toEmail: submission.email,
      toName: submission.name,
      fromEmail: 'hello@versebyversewithnate.us',
      subject,
      preview: message.slice(0, 500),
      sentAt: new Date().toISOString(),
    })
    replyHistory = replyHistory.slice(0, 500)
    queueReplyHistoryWrite()

    res.json({ ok: true })
  } catch (err) {
    console.error('[admin-reply] send error:', err)
    res.status(500).json({ message: 'Failed to send reply email.' })
  }
})

app.get('/api/admin-stats/export', requireAdminAuth, async (_req, res) => {
  let adminContent = null
  let draftContent = null
  try {
    const raw = await readFile(DATA_FILE, 'utf8')
    adminContent = JSON.parse(raw)
  } catch {
    adminContent = null
  }

  try {
    const rawDraft = await readFile(DRAFT_DATA_FILE, 'utf8')
    draftContent = JSON.parse(rawDraft)
  } catch {
    draftContent = null
  }

  res.json({
    exportedAt: new Date().toISOString(),
    adminContent,
    draftContent,
    publishState,
    hitStats,
    visitorStats,
    contactSubmissions,
    replyTemplates,
    replyHistory,
  })
})

app.post('/api/admin-stats/clear', requireAdminAuth, (_req, res) => {
  hitStats = { ...EMPTY_HIT_STATS }
  visitorStats = { ...EMPTY_VISITOR_STATS }
  queueHitStatsWrite()
  queueVisitorStatsWrite()
  createBackupSnapshot('post-clear').catch(() => {})
  res.json({ ok: true })
})

app.post('/api/admin-stats/prune', requireAdminAuth, (req, res) => {
  const result = pruneStatsByDays(req.body?.days)
  createBackupSnapshot('post-prune').catch(() => {})
  res.json({ ok: true, ...result })
})

app.post('/api/admin-stats/backup', requireAdminAuth, async (_req, res) => {
  await createBackupSnapshot('manual')
  res.json({ ok: true, backup: lastBackupStatus })
})

app.get('/api/admin-stats/backups', requireAdminAuth, async (_req, res) => {
  try {
    const backups = await listBackupPreviews()
    res.json({ backups })
  } catch {
    res.status(500).json({ message: 'Could not list backups.' })
  }
})

app.post('/api/admin-stats/backup-preview', requireAdminAuth, async (req, res) => {
  try {
    const { filename } = req.body ?? {}
    const preview = await readBackupPreview(filename)
    res.json({ preview })
  } catch (err) {
    res.status(400).json({ message: err instanceof Error ? err.message : 'Could not load backup preview.' })
  }
})

app.post('/api/admin-stats/restore', requireAdminAuth, async (req, res) => {
  try {
    const { filename } = req.body ?? {}
    await restoreFromBackup(filename)
    const backups = await listBackupPreviews()
    res.json({ ok: true, restored: filename, backups })
  } catch (err) {
    res.status(400).json({ message: err instanceof Error ? err.message : 'Restore failed.' })
  }
})

app.use((req, res, next) => {
  if (shouldCountHit(req)) {
    const ua = sanitizeUserAgent(req.get('user-agent'))
    const botDetection = detectBot(ua)
    recordHit(req.path, botDetection.isBot, botDetection.reason)
    if (hasVisitorConsent(req) && !botDetection.isBot) {
      recordVisitor(req, res).catch(err => {
        console.error('[visitor-stats] failed to record visitor:', err)
      })
    }
  }
  next()
})

// Rate-limit contact submissions: max 5 per IP per 10 minutes
const contactHits = new Map()
const downloadHits = new Map()
function contactRateLimit(req, res, next) {
  const ip = req.ip ?? 'unknown'
  const now = Date.now()
  const windowMs = 10 * 60 * 1000
  const entry = contactHits.get(ip) ?? { count: 0, start: now }
  if (now - entry.start > windowMs) {
    entry.count = 0
    entry.start = now
  }
  entry.count += 1
  contactHits.set(ip, entry)
  if (entry.count > 5) {
    res.status(429).json({ message: 'Too many messages. Please wait a few minutes.' })
    return
  }
  next()
}

function studyDownloadRateLimit(req, res, next) {
  const ip = req.ip ?? 'unknown'
  const now = Date.now()
  const windowMs = 10 * 60 * 1000
  const entry = downloadHits.get(ip) ?? { count: 0, start: now }
  if (now - entry.start > windowMs) {
    entry.count = 0
    entry.start = now
  }
  entry.count += 1
  downloadHits.set(ip, entry)
  if (entry.count > 10) {
    res.status(429).json({ message: 'Too many download requests. Please wait a few minutes.' })
    return
  }
  next()
}

app.post('/api/study-downloads/titus', studyDownloadRateLimit, async (req, res) => {
  try {
    const { firstName, lastName, email, subscribe, _honey } = req.body ?? {}

    if (_honey) {
      res.json({ ok: true })
      return
    }

    if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) {
      res.status(400).json({ message: 'First name is required.' })
      return
    }

    if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) {
      res.status(400).json({ message: 'Last name is required.' })
      return
    }

    if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
      res.status(400).json({ message: 'A valid email address is required.' })
      return
    }

    const published = await loadSiteContentFile(DATA_FILE)
    const configuredDownloadUrl = sanitizeUrl(published?.siteContent?.studyGuideDownloadUrl)

    if (!configuredDownloadUrl) {
      try {
        await stat(TITUS_STUDY_FILE)
      } catch {
        res.status(503).json({ message: 'The primary study guide download URL is not configured yet.' })
        return
      }
    }

    const trimmedFirstName = firstName.trim()
    const trimmedLastName = lastName.trim()
    const trimmedName = `${trimmedFirstName} ${trimmedLastName}`.trim()
    const trimmedEmail = email.trim()
    const wantsSubscribe = subscribe !== false

    addContactSubmission({
      name: trimmedName,
      email: trimmedEmail,
      message: 'Requested Titus study download.',
      messageType: 'general',
      subscribe: wantsSubscribe,
    })

    if (wantsSubscribe) {
      await syncContactToResend(trimmedName, trimmedEmail)
    }

    if (configuredDownloadUrl) {
      res.json({ ok: true, downloadUrl: configuredDownloadUrl })
      return
    }

    const token = createTitusDownloadToken(trimmedEmail)
    res.json({ ok: true, downloadUrl: `/api/study-downloads/titus/file?token=${encodeURIComponent(token)}` })
  } catch (err) {
    console.error('[study-download] request error:', err)
    res.status(500).json({ message: 'Failed to process your request. Please try again.' })
  }
})

app.post('/api/resource-download', studyDownloadRateLimit, async (req, res) => {
  try {
    const { resourceId, firstName, lastName, email, subscribe, _honey } = req.body ?? {}

    if (_honey) {
      res.json({ ok: true })
      return
    }

    if (!resourceId || typeof resourceId !== 'string') {
      res.status(400).json({ message: 'Resource ID is required.' })
      return
    }

    const published = await loadSiteContentFile(DATA_FILE)
    const siteContent = published?.siteContent

    function resolveResourceFromId(id) {
      if (!siteContent || typeof siteContent !== 'object') return null

      const customResources = Array.isArray(siteContent.customLinks)
        ? siteContent.customLinks.filter(link => link?.placement === 'resources')
        : []

      if (id.startsWith('custom:')) {
        const customId = id.slice('custom:'.length)
        const match = customResources.find(link => link.id === customId)
        return match ? { label: match.label, url: match.url } : null
      }

      if (id.startsWith('archived:')) {
        const [, seriesId, ...linkIdParts] = id.split(':')
        const linkId = linkIdParts.join(':')
        const archivedSeries = Array.isArray(siteContent.archivedSeries) ? siteContent.archivedSeries : []
        const series = archivedSeries.find(item => item.id === seriesId)
        const link = Array.isArray(series?.resourceLinks)
          ? series.resourceLinks.find(item => item.id === linkId)
          : null
        return link ? { label: link.label || series?.title, url: link.url } : null
      }

      const customMatch = customResources.find(link => link.id === id)
      if (customMatch) return { label: customMatch.label, url: customMatch.url }

      const archivedSeries = Array.isArray(siteContent.archivedSeries) ? siteContent.archivedSeries : []
      for (const series of archivedSeries) {
        if (!Array.isArray(series?.resourceLinks)) continue
        const link = series.resourceLinks.find(item => item.id === id)
        if (link) return { label: link.label || series?.title, url: link.url }
      }

      return null
    }

    const resource = resolveResourceFromId(resourceId)

    if (!resource || typeof resource.url !== 'string' || !resource.url.trim()) {
      res.status(400).json({ message: 'Resource not found.' })
      return
    }

    if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) {
      res.status(400).json({ message: 'First name is required.' })
      return
    }

    if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) {
      res.status(400).json({ message: 'Last name is required.' })
      return
    }

    if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
      res.status(400).json({ message: 'A valid email address is required.' })
      return
    }

    const trimmedFirstName = firstName.trim()
    const trimmedLastName = lastName.trim()
    const trimmedName = `${trimmedFirstName} ${trimmedLastName}`.trim()
    const trimmedEmail = email.trim()
    const wantsSubscribe = subscribe !== false

    addContactSubmission({
      name: trimmedName,
      email: trimmedEmail,
      message: `Requested resource download: ${resource.label ?? resource.url}`,
      messageType: 'general',
      subscribe: wantsSubscribe,
    })

    if (wantsSubscribe) {
      await syncContactToResend(trimmedName, trimmedEmail)
    }

    res.json({ ok: true, downloadUrl: resource.url.trim() })
  } catch (err) {
    console.error('[resource-download] request error:', err)
    res.status(500).json({ message: 'Failed to process your request. Please try again.' })
  }
})

app.get('/api/study-downloads/titus/file', async (req, res) => {
  const token = typeof req.query?.token === 'string' ? req.query.token : ''
  if (!token || !consumeTitusDownloadToken(token)) {
    res.status(403).json({ message: 'Invalid or expired download link. Submit the form again.' })
    return
  }

  try {
    await stat(TITUS_STUDY_FILE)
    res.download(TITUS_STUDY_FILE, TITUS_STUDY_DOWNLOAD_NAME)
  } catch {
    res.status(503).json({ message: 'The Titus study file is not configured yet.' })
  }
})

app.post('/api/contact', contactRateLimit, async (req, res) => {
  try {
    const { firstName, lastName, email, message, messageType, subscribe, _honey } = req.body ?? {}

    // Honeypot — silently discard if filled by a bot
    if (_honey) {
      res.json({ ok: true })
      return
    }

    if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) {
      res.status(400).json({ message: 'First name is required.' })
      return
    }
    if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) {
      res.status(400).json({ message: 'Last name is required.' })
      return
    }
    if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
      res.status(400).json({ message: 'A valid email address is required.' })
      return
    }
    if (!message || typeof message !== 'string' || message.trim().length < 5 || message.trim().length > 3000) {
      res.status(400).json({ message: 'Message must be between 5 and 3000 characters.' })
      return
    }

    if (!process.env.RESEND_API_KEY) {
      console.error('[contact] RESEND_API_KEY env var not set')
      res.status(503).json({ message: 'The contact form is not yet configured on the server.' })
      return
    }

    const trimmedName = `${firstName.trim()} ${lastName.trim()}`.trim()
    const trimmedEmail = email.trim()
    const trimmedMessage = message.trim()
    const normalizedMessageType = normalizeMessageType(messageType)
    const submittedAt = new Date().toLocaleString('en-US', {
      dateStyle: 'medium',
      timeStyle: 'short',
    })
    const shouldSendWelcome = shouldSendWelcomeEmail({
      subscribe,
    })

    addContactSubmission({
      name: trimmedName,
      email: trimmedEmail,
      message: trimmedMessage,
      messageType: normalizedMessageType,
      subscribe,
    })

    // If this is a question, also add to questions array for public Q&A section
    if (normalizedMessageType === 'question') {
      const question = {
        id: randomUUID(),
        submittedAt: new Date().toISOString(),
        firstName: splitName(trimmedName).firstName,
        email: trimmedEmail,
        question: trimmedMessage,
        answer: '',
        answeredAt: null,
        isApproved: false,
        approvedAt: null,
      }
      questions.unshift(question)
      questions = questions.slice(0, MAX_QUESTIONS)
      if (draftQuestions !== null) {
        draftQuestions.unshift(question)
        draftQuestions = draftQuestions.slice(0, MAX_QUESTIONS)
        queueDraftQuestionsWrite()
      }
      queueQuestionsWrite()
    }
    const resend = new Resend(process.env.RESEND_API_KEY)

    if (subscribe === true) {
      await syncContactToResend(trimmedName, trimmedEmail)
    }

    if (shouldSendWelcome && !USE_RESEND_AUTOMATION_WELCOME) {
      const greetingName = splitName(trimmedName).firstName?.trim() ?? ''
      let publishedSiteContent = cachedSiteContent
      if (!publishedSiteContent) {
        try {
          const published = await loadSiteContentFile(DATA_FILE)
          publishedSiteContent = published?.siteContent ?? null
        } catch {
          publishedSiteContent = null
        }
      }

      const emailConfig = publishedSiteContent ?? {}
      const welcomeBaseUrl = typeof emailConfig?.seo?.canonicalUrl === 'string' && emailConfig.seo.canonicalUrl.trim()
        ? emailConfig.seo.canonicalUrl.trim()
        : DEFAULT_SEO.canonicalUrl
      const welcomeSubject = process.env.RESEND_WELCOME_SUBJECT ?? emailConfig.welcomeEmailSubject ?? 'Welcome to Verse by Verse with Nate'
      const welcomeGreetingPrefix = emailConfig.welcomeEmailGreetingPrefix?.trim() || "Glad you're here"
      const welcomeHeading = greetingName
        ? `${escapeHtml(welcomeGreetingPrefix)}, ${escapeHtml(greetingName)}.`
        : `${escapeHtml(welcomeGreetingPrefix)}.`
      const welcomeIntro = emailConfig.welcomeEmailIntro?.trim()
        || 'Thanks for subscribing to Verse by Verse with Nate - a Bible teaching podcast where we slow down, dig into the text, and pull out the nuggets God has for us word by word.'
      const welcomeCurrentSeries = emailConfig.welcomeEmailCurrentSeries?.trim()
        || 'Right now we\'re working through the book of Titus - a short letter packed with practical wisdom about grace, godliness, and what the Christian life looks like when it\'s rooted in sound doctrine.'
      const welcomeStartHereTitle = emailConfig.welcomeEmailStartHereTitle?.trim() || 'Episode 1 - Introduction to Titus'
      const welcomeStartHereSummary = emailConfig.welcomeEmailStartHereSummary?.trim() || 'Who wrote it, who received it, and why it still matters.'
      const welcomeExpect1 = emailConfig.welcomeEmailWhatToExpect1?.trim() || 'Verse-by-verse teaching - we go slow and let the text speak for itself.'
      const welcomeExpect2 = emailConfig.welcomeEmailWhatToExpect2?.trim() || 'Greek word studies - the kind that open up meaning without being a lecture.'
      const welcomeExpect3 = emailConfig.welcomeEmailWhatToExpect3?.trim() || 'New episodes + study notes delivered right to your inbox.'
      const welcomeScripture = emailConfig.welcomeEmailScripture?.trim() || 'For the grace of God has appeared, bringing salvation to all people.'
      const welcomeScriptureRef = emailConfig.welcomeEmailScriptureRef?.trim() || 'Titus 2:11 - BSB'
      const welcomeSignoff = emailConfig.welcomeEmailSignoff?.trim() || 'Grace and peace,\nNate'
      const welcomeSignoffHtml = escapeHtml(welcomeSignoff).replace(/\n/g, '<br/>')
      const welcomeSpotifyUrl = buildAbsoluteUrl(
        welcomeBaseUrl,
        process.env.RESEND_WELCOME_SPOTIFY_URL ?? emailConfig.welcomeEmailSpotifyUrl ?? '/spotify',
      )
      const welcomeAppleUrl = buildAbsoluteUrl(
        welcomeBaseUrl,
        process.env.RESEND_WELCOME_APPLE_URL ?? emailConfig.welcomeEmailAppleUrl ?? '/apple',
      )
      const welcomeAmazonUrl = buildAbsoluteUrl(
        welcomeBaseUrl,
        process.env.RESEND_WELCOME_AMAZON_URL ?? emailConfig.welcomeEmailAmazonUrl ?? '/amazon',
      )
      const welcomeWebsiteUrl = buildAbsoluteUrl(
        welcomeBaseUrl,
        process.env.RESEND_WELCOME_WEBSITE_URL ?? emailConfig.welcomeEmailWebsiteUrl ?? '/',
      )
      const welcomeEpisodeUrl = buildAbsoluteUrl(
        welcomeBaseUrl,
        process.env.RESEND_WELCOME_EPISODE_URL ?? emailConfig.welcomeEmailStartHereUrl ?? '/start-here',
      )
      const welcomeImageUrl = buildAbsoluteUrl(
        welcomeBaseUrl,
        process.env.RESEND_WELCOME_IMAGE_URL ?? emailConfig.welcomeEmailImageUrl ?? '/images/podcast-art.jpeg',
      )

      const { error: welcomeError } = await resend.emails.send({
        from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
        to: [trimmedEmail],
        subject: welcomeSubject,
        text:
          `Welcome to Verse by Verse with Nate!\n\n` +
          `${greetingName ? `Glad you're here, ${greetingName}.` : "Glad you're here."}\n\n` +
          `${welcomeIntro}\n\n` +
          `${welcomeCurrentSeries}\n\n` +
          `Start here: ${welcomeEpisodeUrl}\n` +
          `${welcomeStartHereTitle}\n` +
          `${welcomeStartHereSummary}\n` +
          `Spotify: ${welcomeSpotifyUrl}\n` +
          `Apple Podcasts: ${welcomeAppleUrl}\n` +
          `Amazon Music: ${welcomeAmazonUrl}\n` +
          `Website: ${welcomeWebsiteUrl}\n\n` +
          `What to expect:\n` +
          `- ${welcomeExpect1}\n` +
          `- ${welcomeExpect2}\n` +
          `- ${welcomeExpect3}\n\n` +
          `${welcomeScripture}\n${welcomeScriptureRef}\n\n` +
          `${welcomeSignoff}`,
        html:
          `<div style="margin:0;padding:0;background-color:#0a0a08;font-family:Georgia,serif;">` +
          `<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#0a0a08;">` +
          `<tr><td align="center" style="padding:40px 20px;">` +
          `<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:580px;margin:0 auto;background-color:#0f0f0c;border:1px solid #2a2518;">` +
          `<tr><td align="center" style="background-color:#0d0d0a;padding:36px 40px 28px;border-bottom:1px solid #2a2518;">` +
          `<img src="${escapeHtml(welcomeImageUrl)}" alt="Verse by Verse with Nate" width="110" height="110" style="display:block;margin:0 auto 20px;border-radius:12px;border:2px solid #2a2518;" />` +
          `<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:22px;font-weight:600;color:#c9a84c;letter-spacing:0.04em;">Verse by Verse with Nate</p>` +
          `<p style="margin:0;font-family:Georgia,serif;font-size:14px;font-weight:300;color:#7a7060;letter-spacing:0.08em;text-transform:uppercase;">Verse by verse. Nugget by nugget.</p>` +
          `</td></tr>` +
          `<tr><td style="padding:40px 40px 0;">` +
          `<p style="margin:0 0 8px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">Welcome</p>` +
          `<h1 style="margin:0 0 20px;font-family:Georgia,serif;font-size:30px;font-weight:600;color:#f0ead8;line-height:1.2;">${welcomeHeading}</h1>` +
          `<div style="width:40px;height:2px;background-color:#c9a84c;margin-bottom:28px;"></div>` +
          `</td></tr>` +
          `<tr><td style="padding:0 40px 32px;">` +
          `<p style="margin:0 0 18px;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">${escapeHtml(welcomeIntro)}</p>` +
          `<p style="margin:0 0 18px;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">${escapeHtml(welcomeCurrentSeries)}</p>` +
          `<p style="margin:0;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">If you&rsquo;re just joining us, the best place to start is Episode 1. It sets the table for everything that follows.</p>` +
          `</td></tr>` +
          `<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
          `<tr><td style="padding:0 40px 32px;">` +
          `<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">Start here</p>` +
          `<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:22px;font-weight:600;color:#f0ead8;">${escapeHtml(welcomeStartHereTitle)}</p>` +
          `<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:16px;font-weight:300;color:#7a7060;line-height:1.6;">${escapeHtml(welcomeStartHereSummary)}</p>` +
          `<table cellpadding="0" cellspacing="0" border="0" role="presentation"><tr>` +
          `<td style="padding-right:12px;"><a href="${escapeHtml(welcomeSpotifyUrl)}" target="_blank" style="display:inline-block;padding:11px 22px;background-color:#c9a84c;color:#0d0d0a;font-family:Georgia,serif;font-size:14px;font-weight:600;letter-spacing:0.06em;text-decoration:none;border-radius:3px;">Listen on Spotify</a></td>` +
          `<td><a href="${escapeHtml(welcomeAppleUrl)}" target="_blank" style="display:inline-block;padding:11px 22px;background-color:transparent;color:#c9a84c;font-family:Georgia,serif;font-size:14px;font-weight:600;letter-spacing:0.06em;text-decoration:none;border-radius:3px;border:1px solid #c9a84c;">Apple Podcasts</a></td>` +
          `</tr></table>` +
          `<p style="margin:18px 0 0;"><a href="${escapeHtml(welcomeEpisodeUrl)}" target="_blank" style="color:#c9a84c;text-decoration:underline;font-family:Georgia,serif;font-size:14px;">Open Start Here page</a></p>` +
          `</td></tr>` +
          `<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
          `<tr><td style="padding:0 40px 32px;">` +
          `<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">What to expect</p>` +
          `<p style="margin:0 0 12px;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect1)}</p>` +
          `<p style="margin:0 0 12px;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect2)}</p>` +
          `<p style="margin:0;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect3)}</p>` +
          `</td></tr>` +
          `<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
          `<tr><td style="padding:0 40px 40px;">` +
          `<table cellpadding="0" cellspacing="0" border="0" role="presentation" style="width:100%;border-left:2px solid #c9a84c;"><tr><td style="padding:4px 0 4px 20px;">` +
          `<p style="margin:0 0 8px;font-family:Georgia,serif;font-size:19px;font-style:italic;font-weight:400;color:#e0c070;line-height:1.6;">&ldquo;${escapeHtml(welcomeScripture)}&rdquo;</p>` +
          `<p style="margin:0;font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;letter-spacing:0.06em;text-transform:uppercase;">${escapeHtml(welcomeScriptureRef)}</p>` +
          `</td></tr></table>` +
          `</td></tr>` +
          `<tr><td style="background-color:#0a0a08;border-top:1px solid #2a2518;padding:28px 40px;text-align:center;">` +
          `<p style="margin:0 0 14px;font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;letter-spacing:0.06em;">Find the podcast on</p>` +
          `<table cellpadding="0" cellspacing="0" border="0" role="presentation" style="margin:0 auto 24px;"><tr>` +
          `<td style="padding:0 10px;"><a href="${escapeHtml(welcomeSpotifyUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Spotify</a></td>` +
          `<td style="color:#2a2518;font-size:12px;">&middot;</td>` +
          `<td style="padding:0 10px;"><a href="${escapeHtml(welcomeAppleUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Apple Podcasts</a></td>` +
          `<td style="color:#2a2518;font-size:12px;">&middot;</td>` +
          `<td style="padding:0 10px;"><a href="${escapeHtml(welcomeAmazonUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Amazon Music</a></td>` +
          `<td style="color:#2a2518;font-size:12px;">&middot;</td>` +
          `<td style="padding:0 10px;"><a href="${escapeHtml(welcomeWebsiteUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Website</a></td>` +
          `</tr></table>` +
          `<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#4a4438;line-height:1.6;">You&rsquo;re receiving this because you subscribed to <strong style="color:#5a5035;font-weight:400;">Verse by Verse with Nate</strong>.</p>` +
          `<p style="margin:10px 0 0;font-family:Georgia,serif;font-size:14px;font-weight:400;color:#c8c0ac;line-height:1.5;">${welcomeSignoffHtml}</p>` +
          `</td></tr>` +
          `</table>` +
          `</td></tr></table>` +
          `</div>`,
      })

      if (welcomeError) throw welcomeError
    }

    if (shouldSendWelcome) {
      res.json({ ok: true })
      return
    }

    const { error } = await resend.emails.send({
      from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
      to: [process.env.RESEND_TO ?? 'hello@versebyversewithnate.us'],
      replyTo: trimmedEmail,
      subject: `Verse by Verse contact form: ${trimmedName}`,
      text:
        `New contact form submission\n\n` +
        `Message Type: ${normalizedMessageType}\n` +
        `Name: ${trimmedName}\n` +
        `Email: ${trimmedEmail}\n` +
        `Submitted: ${submittedAt}\n\n` +
        `Message:\n${trimmedMessage}`,
      html:
        `<div style="background:#f5f1e8;padding:24px;font-family:Georgia,serif;color:#201a10;">` +
        `<div style="max-width:680px;margin:0 auto;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">` +
        `<div style="background:#111111;padding:20px 24px;border-bottom:3px solid #c8860a;">` +
        `<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.32em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>` +
        `<h1 style="margin:10px 0 0;color:#f4ead5;font-size:28px;line-height:1.2;">New Contact Form Submission</h1>` +
        `</div>` +
        `<div style="padding:24px;">` +
        `<p style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.6;color:#57452b;">A new message was sent from the website contact form. Reply directly to this email to respond to <strong>${escapeHtml(trimmedName)}</strong>.</p>` +
        `<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;margin-bottom:20px;">` +
        `<tr>` +
        `<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Type</td>` +
        `<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(normalizedMessageType)}</td>` +
        `</tr>` +
        `<tr>` +
        `<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Name</td>` +
        `<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(trimmedName)}</td>` +
        `</tr>` +
        `<tr>` +
        `<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Email</td>` +
        `<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;"><a href="mailto:${escapeHtml(trimmedEmail)}" style="color:#8f5f05;text-decoration:none;">${escapeHtml(trimmedEmail)}</a></td>` +
        `</tr>` +
        `<tr>` +
        `<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;">Submitted</td>` +
        `<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;">${escapeHtml(submittedAt)}</td>` +
        `</tr>` +
        `</table>` +
        `<div style="background:#fbf7ef;border:1px solid #efe4cc;border-radius:12px;padding:18px 20px;">` +
        `<div style="margin:0 0 10px;font-family:Arial,sans-serif;font-size:13px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:#8a6d35;">Message</div>` +
        `<div style="font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;white-space:pre-wrap;">${escapeHtml(trimmedMessage)}</div>` +
        `</div>` +
        `</div>` +
        `</div>` +
        `</div>`,
    })
    if (error) throw error

    res.json({ ok: true })
  } catch (err) {
    console.error('[contact] send error:', err)
    res.status(500).json({ message: 'Failed to send your message. Please try again or email us directly.' })
  }
})

// Get all questions (for admin)
app.get('/api/admin-questions', requireAdminAuth, (_req, res) => {
  res.json({ questions: draftQuestions ?? questions })
})

// Get only approved public questions (for homepage)
app.get('/api/questions', (_req, res) => {
  const publicQuestions = questions.filter(q => q.isApproved === true && q.answer && q.answer.trim().length > 0)
  res.json({ questions: publicQuestions })
})

// Create a manual question (admin)
app.post('/api/admin-questions', requireAdminAuth, (req, res) => {
  const firstName = typeof req.body?.firstName === 'string' ? req.body.firstName.trim() : ''
  const email = typeof req.body?.email === 'string' ? req.body.email.trim() : ''
  const questionText = typeof req.body?.question === 'string' ? req.body.question.trim() : ''
  const answerText = typeof req.body?.answer === 'string' ? req.body.answer.trim() : ''
  const approveNow = req.body?.approve === true

  if (!firstName || firstName.length > 100) {
    res.status(400).json({ message: 'First name is required and must be 100 characters or fewer.' })
    return
  }

  if (!questionText || questionText.length < 5 || questionText.length > 3000) {
    res.status(400).json({ message: 'Question must be between 5 and 3000 characters.' })
    return
  }

  if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    res.status(400).json({ message: 'If provided, email must be a valid email address.' })
    return
  }

  if (answerText.length > 5000) {
    res.status(400).json({ message: 'Answer must be 5000 characters or fewer.' })
    return
  }

  ensureDraftQuestions()
  const now = new Date().toISOString()
  const created = {
    id: randomUUID(),
    submittedAt: now,
    firstName,
    email,
    question: questionText,
    answer: answerText,
    answeredAt: answerText ? now : null,
    isApproved: approveNow,
    approvedAt: approveNow ? now : null,
  }

  draftQuestions.unshift(created)
  draftQuestions = draftQuestions.slice(0, MAX_QUESTIONS)
  queueDraftQuestionsWrite()

  res.status(201).json({ ok: true, question: created })
})

// Answer a question (admin)
app.post('/api/admin-questions/:id/answer', requireAdminAuth, (req, res) => {
  const { id } = req.params
  const { answer } = req.body ?? {}

  if (!answer || typeof answer !== 'string' || answer.trim().length < 1 || answer.trim().length > 5000) {
    res.status(400).json({ message: 'Answer must be between 1 and 5000 characters.' })
    return
  }

  ensureDraftQuestions()
  const question = draftQuestions.find(q => q.id === id)
  if (!question) {
    res.status(404).json({ message: 'Question not found.' })
    return
  }

  question.answer = answer.trim()
  question.answeredAt = new Date().toISOString()
  queueDraftQuestionsWrite()

  res.json({ ok: true, question })
})

// Approve/unapprove a question (admin)
app.post('/api/admin-questions/:id/approve', requireAdminAuth, (req, res) => {
  const { id } = req.params
  const { approved } = req.body ?? {}

  ensureDraftQuestions()
  const question = draftQuestions.find(q => q.id === id)
  if (!question) {
    res.status(404).json({ message: 'Question not found.' })
    return
  }

  question.isApproved = approved === true
  question.approvedAt = approved === true ? new Date().toISOString() : null
  queueDraftQuestionsWrite()

  res.json({ ok: true, question })
})

// Delete a question (admin)
app.delete('/api/admin-questions/:id', requireAdminAuth, (req, res) => {
  const { id } = req.params
  ensureDraftQuestions()
  const index = draftQuestions.findIndex(q => q.id === id)

  if (index === -1) {
    res.status(404).json({ message: 'Question not found.' })
    return
  }

  draftQuestions.splice(index, 1)
  queueDraftQuestionsWrite()

  res.json({ ok: true })
})
// ── Episodes (RSS feed proxy) ──────────────────────────────────────────────
const RSS_FEED_URL = 'https://anchor.fm/nmemmert/podcast/rss'
let episodesCache = null
let episodesCacheAt = 0
const EPISODES_CACHE_TTL = 30 * 60 * 1000 // 30 minutes

function extractCdata(raw) {
  const cdata = /^<!\[CDATA\[([\s\S]*?)\]\]>$/.exec(raw.trim())
  return cdata ? cdata[1].trim() : raw.trim()
}

function parseRssItems(xml, limit = Infinity) {
  const items = []
  const itemRegex = /<item>([\s\S]*?)<\/item>/g
  let match
  while ((match = itemRegex.exec(xml)) !== null && items.length < limit) {
    const block = match[1]
    const titleRaw = /<title>([\s\S]*?)<\/title>/.exec(block)?.[1] ?? ''
    const title = extractCdata(titleRaw)
    if (!title) continue

    const pubDate = (/<pubDate>([\s\S]*?)<\/pubDate>/.exec(block)?.[1] ?? '').trim()
    const guidRaw = /<guid[^>]*>([\s\S]*?)<\/guid>/.exec(block)?.[1] ?? ''
    const guid = extractCdata(guidRaw)
    const enclosureUrl = /<enclosure[^>]+url="([^"]+)"/.exec(block)?.[1] ?? ''
    const link = guid.startsWith('http') ? guid : enclosureUrl
    const descRaw = /<description>([\s\S]*?)<\/description>/.exec(block)?.[1] ?? ''
    const descText = extractCdata(descRaw).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
    const duration = (/<itunes:duration>([\s\S]*?)<\/itunes:duration>/.exec(block)?.[1] ?? '').trim()
    const episode = (/<itunes:episode>([\s\S]*?)<\/itunes:episode>/.exec(block)?.[1] ?? '').trim()
    items.push({
      title,
      pubDate,
      link,
      description: descText.slice(0, 220) + (descText.length > 220 ? '…' : ''),
      duration,
      episode,
    })
  }
  return items
}

async function fetchAllEpisodes() {
  const now = Date.now()
  if (episodesCache && (now - episodesCacheAt) < EPISODES_CACHE_TTL) {
    return episodesCache
  }
  const controller = new AbortController()
  const timeout = setTimeout(() => controller.abort(), 8000)
  const response = await fetch(RSS_FEED_URL, { signal: controller.signal })
  clearTimeout(timeout)
  if (!response.ok) throw new Error(`RSS fetch failed: ${response.status}`)
  const xml = await response.text()
  const episodes = parseRssItems(xml)
  episodesCache = episodes
  episodesCacheAt = now
  return episodes
}

app.get('/api/episodes', async (_req, res) => {
  try {
    const episodes = await fetchAllEpisodes()
    res.json({ episodes: episodes.slice(0, 6) })
  } catch (err) {
    console.error('[episodes] RSS fetch error:', err.message)
    res.json({ episodes: (episodesCache ?? []).slice(0, 6) })
  }
})

app.get('/api/episodes/all', async (_req, res) => {
  try {
    const episodes = await fetchAllEpisodes()
    res.json({ episodes })
  } catch (err) {
    console.error('[episodes/all] RSS fetch error:', err.message)
    res.json({ episodes: episodesCache ?? [] })
  }
})

app.get('/robots.txt', async (_req, res) => {
  let content = cachedSiteContent
  if (!content) {
    try {
      const parsed = await loadSiteContentFile(DATA_FILE)
      content = parsed.siteContent
    } catch {
      content = {}
    }
  }

  const seo = content?.seo ?? DEFAULT_SEO
  const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
  const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical

  res.type('text/plain').send(
    [
      'User-agent: *',
      'Allow: /',
      `Sitemap: ${root}/sitemap.xml`,
    ].join('\n'),
  )
})

app.get('/sitemap.xml', async (_req, res) => {
  let content = cachedSiteContent
  if (!content) {
    try {
      const parsed = await loadSiteContentFile(DATA_FILE)
      content = parsed.siteContent
    } catch {
      content = {}
    }
  }

  const seo = content?.seo ?? DEFAULT_SEO
  const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
  const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
  const paths = Array.isArray(seo.sitemapPaths) && seo.sitemapPaths.length > 0
    ? seo.sitemapPaths
    : DEFAULT_SEO.sitemapPaths

  const urls = paths
    .map(item => normalizeSitemapPath(item))
    .filter(Boolean)
    .map(item => `${root}${item}`)

  const xml = [
    '<?xml version="1.0" encoding="UTF-8"?>',
    '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
    ...urls.map(url => `  <url><loc>${escapeXml(url)}</loc></url>`),
    '</urlset>',
  ].join('\n')

  res.type('application/xml').send(xml)
})

app.use((req, res, next) => {
  const rules = sanitizeRedirectRules(cachedSiteContent?.redirects)
  const match = rules.find(rule => rule.path === req.path)
  if (!match) {
    next()
    return
  }
  res.redirect(match.statusCode === 302 ? 302 : 301, match.target)
})

app.use('/images', express.static(DIST_IMAGES_DIR))
app.use('/images', express.static(PUBLIC_IMAGES_DIR))
app.use('/uploads', express.static(UPLOADS_DIR))

app.use(express.static(DIST_DIR))

app.use(async (_req, res) => {
  try {
    const html = await readFile(INDEX_FILE, 'utf8')
    res.type('html').send(injectSeoIntoHtml(html, cachedSiteContent))
  } catch {
    res.status(503).send('Frontend build not found. Run "npm run build" first.')
  }
})

const PORT = Number(process.env.PORT ?? 4173)
Promise.all([
  loadHitStatsFromDisk(),
  loadVisitorStatsFromDisk(),
  loadContactSubmissionsFromDisk(),
  loadReplyTemplatesFromDisk(),
  loadReplyHistoryFromDisk(),
  loadQuestionsFromDisk(),
  loadDraftQuestionsFromDisk(),
  refreshContentCaches(),
])
  .catch(err => {
    console.error('[stats] failed to load persisted stats:', err)
  })
  .finally(() => {
    createBackupSnapshot('startup').catch(() => {})
    setInterval(() => {
      createBackupSnapshot('scheduled').catch(() => {})
    }, BACKUP_INTERVAL_MS)

    app.listen(PORT, () => {
      console.log(`Portfolio app listening on http://localhost:${PORT}`)
    })
  })
