import { createHash, createHmac, randomUUID } from 'node:crypto'
import path from 'node:path'
import { parseCookies, cookieSecureFlag } from './helpers.js'
import {
  STUDY_SESSION_COOKIE,
  STUDY_SESSION_TTL_MS,
  STUDY_TOTP_PENDING_TTL_MS,
  EMAIL_OTP_TTL_MS,
  EMAIL_OTP_MAX_ATTEMPTS,
  CONTACT_EMAIL_COOLDOWN_MS,
  DEFAULT_REDIRECT_RULES,
} from './config.js'
import { state } from './state.js'
import { queueStudyRemindersWrite, queueStudyUsersWrite } from './data.js'

// ── Username / slug normalizers ────────────────────────────────────────────

export function normalizeStudyUsername(value) {
  if (typeof value !== 'string') return ''
  return value.trim().toLowerCase()
}

export function normalizeStudySlug(value) {
  if (typeof value !== 'string') return ''
  const trimmed = value.trim().toLowerCase()
  return /^[a-z0-9-]{1,80}$/.test(trimmed) ? trimmed : ''
}

export function isValidStudyUsername(value) {
  return /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(value) && value.length <= 254
}

export function normalizeLessonSectionId(value) {
  if (typeof value !== 'string') return ''
  const trimmed = value.trim().toLowerCase()
  return /^[a-z0-9-]{1,80}$/.test(trimmed) ? trimmed : ''
}

export function getStudySlugFromNoteId(sectionId) {
  if (typeof sectionId !== 'string') return ''
  const separatorIndex = sectionId.indexOf('--')
  if (separatorIndex <= 0) return ''
  return normalizeStudySlug(sectionId.slice(0, separatorIndex))
}

// ── Password / token hashing ───────────────────────────────────────────────

export function hashStudyPassword(password) {
  return createHash('sha256').update(`study-user:${String(password)}`).digest('hex')
}

export function hashEmailChangeToken(token) {
  return createHash('sha256').update(`study-email-change:${String(token)}`).digest('hex')
}

// ── Catalog / enrollment helpers ───────────────────────────────────────────

export function getStudyCatalog() {
  const fallback = [
    { slug: 'colossians', title: 'Colossians: Rooted in Christ', status: 'active' },
  ]
  const content = state.cachedSiteContent
  if (!content || typeof content !== 'object') return fallback

  if (Array.isArray(content.studies) && content.studies.length > 0) {
    const out = []
    const seen = new Set()
    for (const study of content.studies) {
      const slug = normalizeStudySlug(study?.slug)
      if (!slug || seen.has(slug)) continue
      seen.add(slug)
      out.push({
        slug,
        title: typeof study?.title === 'string' && study.title.trim() ? study.title.trim() : slug,
        status: study?.status === 'planned' ? 'planned' : 'active',
      })
    }
    if (out.length > 0) return out
  }

  return fallback
}

export function isEnrollableStudySlug(studySlug) {
  const normalized = normalizeStudySlug(studySlug)
  if (!normalized) return false
  return getStudyCatalog().some(study => study.slug === normalized)
}

export function getStudyTitleBySlug(studySlug) {
  const normalized = normalizeStudySlug(studySlug)
  if (!normalized) return ''
  const study = getStudyCatalog().find(item => item.slug === normalized)
  return study?.title ?? ''
}

export function isStudyUserEnrolled(user, studySlug) {
  const normalized = normalizeStudySlug(studySlug)
  if (!normalized || !user) return false
  return Array.isArray(user.enrolledStudySlugs) && user.enrolledStudySlugs.includes(normalized)
}

export function findStudyUserById(userId) {
  if (typeof userId !== 'string' || !userId.trim()) return undefined
  return state.studyUsers.find(user => user.id === userId)
}

export function findStudyUserByUsername(username) {
  return state.studyUsers.find(user => user.username === normalizeStudyUsername(username))
}

// ── Avatar ─────────────────────────────────────────────────────────────────

export function getStudyAvatarUrl(subject) {
  let customAvatar = ''
  let username = ''

  if (subject && typeof subject === 'object') {
    customAvatar = typeof subject.avatarUrl === 'string' ? subject.avatarUrl.trim() : ''
    username = normalizeStudyUsername(subject.username)
  } else if (typeof subject === 'string') {
    username = normalizeStudyUsername(subject)
  }

  if (customAvatar) return customAvatar
  if (!username) return ''
  const hash = createHash('md5').update(username).digest('hex')
  return `https://www.gravatar.com/avatar/${hash}?d=identicon&s=96`
}

// ── Session management ─────────────────────────────────────────────────────

export function cookieFlags() {
  return cookieSecureFlag()
}

export function createStudySession(userId) {
  const token = randomUUID()
  state.studySessions.set(token, { userId, expiresAt: Date.now() + STUDY_SESSION_TTL_MS })
  return token
}

export function setStudySessionCookie(res, token) {
  res.append(
    'Set-Cookie',
    `${STUDY_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(STUDY_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
  )
}

export function clearStudySessionCookie(res) {
  res.append(
    'Set-Cookie',
    `${STUDY_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
  )
}

export function getStudyUserFromRequest(req) {
  const cookies = parseCookies(req.headers.cookie)
  const token = cookies[STUDY_SESSION_COOKIE]
  if (!token) return null

  const session = state.studySessions.get(token)
  if (!session || session.expiresAt <= Date.now()) {
    state.studySessions.delete(token)
    return null
  }

  const user = state.studyUsers.find(item => item.id === session.userId)
  if (!user) {
    state.studySessions.delete(token)
    return null
  }

  session.expiresAt = Date.now() + STUDY_SESSION_TTL_MS
  state.studySessions.set(token, session)
  return user
}

export function requireStudyAuth(req, res, next) {
  const user = getStudyUserFromRequest(req)
  if (!user) {
    res.status(401).json({ message: 'Please sign in to save notes.' })
    return
  }
  req.studyUser = user
  next()
}

// ── TOTP pending tokens ────────────────────────────────────────────────────

export function createStudyTotpPendingToken(userId) {
  const token = randomUUID()
  state.studyTotpPendingTokens.set(token, { userId, expiresAt: Date.now() + STUDY_TOTP_PENDING_TTL_MS })
  return token
}

export function consumeStudyTotpPendingToken(token) {
  const entry = state.studyTotpPendingTokens.get(token)
  if (!entry) return null
  state.studyTotpPendingTokens.delete(token)
  if (Date.now() > entry.expiresAt) return null
  return entry.userId
}

// ── Email OTP ──────────────────────────────────────────────────────────────

export function generateEmailOtp() {
  return String(Math.floor(100000 + Math.random() * 900000))
}

function hashEmailOtp(code) {
  return createHash('sha256').update(String(code).trim()).digest('hex')
}

export function storeEmailOtp(userId, code) {
  state.emailOtpStore.set(userId, { codeHash: hashEmailOtp(code), expiresAt: Date.now() + EMAIL_OTP_TTL_MS, attempts: 0 })
}

export function verifyEmailOtp(userId, code) {
  const entry = state.emailOtpStore.get(userId)
  if (!entry) return 'no-code'
  if (Date.now() > entry.expiresAt) { state.emailOtpStore.delete(userId); return 'expired' }
  entry.attempts += 1
  if (entry.attempts > EMAIL_OTP_MAX_ATTEMPTS) { state.emailOtpStore.delete(userId); return 'too-many' }
  if (hashEmailOtp(String(code).trim()) !== entry.codeHash) return 'wrong'
  state.emailOtpStore.delete(userId)
  return 'ok'
}

// ── Titus download tokens ──────────────────────────────────────────────────

export function createTitusDownloadToken(email) {
  const token = randomUUID()
  state.titusDownloadTokens.set(token, {
    email,
    expiresAt: Date.now() + (10 * 60 * 1000),
  })
  return token
}

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

// ── Release date helpers ───────────────────────────────────────────────────

export function getSectionReleaseTime(section) {
  if (!section || typeof section !== 'object') return Number.NaN
  const candidateValues = [section.releasedAt, section.releaseDate, section.availableAt, section.publishAt]
  for (const candidate of candidateValues) {
    if (typeof candidate !== 'string' || !candidate.trim()) continue
    const releaseTime = Date.parse(candidate)
    if (Number.isFinite(releaseTime)) return releaseTime
  }
  return Number.NaN
}

export function getSectionReleaseDate(section) {
  const releaseTime = getSectionReleaseTime(section)
  if (!Number.isFinite(releaseTime)) return null
  return new Date(releaseTime)
}

export function isSectionReleased(section) {
  const releaseTime = getSectionReleaseTime(section)
  if (!Number.isFinite(releaseTime)) return false
  return releaseTime <= Date.now()
}

export function redactUnreleasedSection(section) {
  if (!section || typeof section !== 'object') return section
  if (isSectionReleased(section)) return section
  return {
    ...section,
    passageText: '',
    commentary: '',
    greekNotes: [],
    studyQuestions: [],
    audioEmbedUrl: '',
  }
}

export function filterSiteContentByReleaseDate(siteContent) {
  if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) return siteContent

  const filteredStudies = Array.isArray(siteContent.studies)
    ? siteContent.studies.map(study => {
      if (!study || typeof study !== 'object') return study
      const sections = Array.isArray(study.sections) ? study.sections.map(redactUnreleasedSection) : []
      return { ...study, sections }
    })
    : siteContent.studies

  const filteredLegacySections = Array.isArray(siteContent.colossiansStudySections)
    ? siteContent.colossiansStudySections.map(redactUnreleasedSection)
    : siteContent.colossiansStudySections

  return {
    ...siteContent,
    studies: filteredStudies,
    colossiansStudySections: filteredLegacySections,
  }
}

// ── Analytics helpers ──────────────────────────────────────────────────────

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

export 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
  const hasFileExt = path.extname(req.path) !== ''
  if (hasFileExt) return false
  const accept = req.get('accept') ?? ''
  return accept.includes('text/html') || accept === '*/*' || accept === ''
}

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

export function detectDevice(userAgent) {
  if (!userAgent || typeof userAgent !== 'string') return 'unknown'
  const ua = userAgent.toLowerCase()
  if (/tablet|ipad|playbook|silk|(android(?!.*mobile))/.test(ua)) return 'tablet'
  if (/mobile|iphone|ipod|android|blackberry|opera mini|opera mobi|iemobile|windows phone|palm|smartphone/.test(ua)) return 'mobile'
  return 'desktop'
}

export function sanitizeReferrer(referrer) {
  if (!referrer || typeof referrer !== 'string') return ''
  try {
    const parsed = new URL(referrer.trim())
    return `${parsed.hostname}${parsed.pathname}`.slice(0, 200)
  } catch {
    return ''
  }
}

export function detectBot(userAgent) {
  if (!userAgent || typeof userAgent !== 'string') {
    return { isBot: true, reason: 'missing-user-agent' }
  }
  const ua = userAgent.toLowerCase()
  if (/googlebot|bingbot|yandexbot|baiduspider|slurp|duckduckbot|sluplicate|googlebot-mobile/.test(ua)) {
    return { isBot: true, reason: 'search-crawler' }
  }
  if (/facebookexternalhit|twitterbot|linkedinbot|pinterest|whatsapp|slack|discord|telegram|reddit|mastodon/.test(ua)) {
    return { isBot: true, reason: 'social-crawler' }
  }
  if (/headless|phantomjs|puppeteer|playwright|selenium|nightmarebot|watir|webdriver|wdio|nightmare/.test(ua)) {
    return { isBot: true, reason: 'headless-browser' }
  }
  if (/uptimerobot|pingdom|statuspage|pagerduty|sentry|datadog|grafana|prometheus|newrelic|appdynamics/.test(ua)) {
    return { isBot: true, reason: 'monitoring-tool' }
  }
  if (/nmap|nikto|masscan|metasploit|nessus|openvas|qualys|burpsuite|zap|acunetix|sqlmap/.test(ua)) {
    return { isBot: true, reason: 'security-scanner' }
  }
  if (/^(curl|wget|python|java|go|node|ruby|php|perl|lua|rust)[\/-]/.test(ua)) {
    return { isBot: true, reason: 'http-client' }
  }
  if (/bot|crawler|spider|scraper|indexer|reader|fetcher|loader|agent|spyware|tracking|monitor/.test(ua)) {
    if (!/chrome|firefox|safari|opera|edge|msie|trident|like gecko/.test(ua)) {
      return { isBot: true, reason: 'bot-keyword' }
    }
  }
  return { isBot: false, reason: null }
}

export 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'
}

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

export 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 }))
}

export 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: state.hitStats.byDay[dayKey] ?? 0 })
  }
  return out
}

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

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

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

  state.hitStats.byPath[safePath] = (state.hitStats.byPath[safePath] ?? 0) + 1
  state.hitStats.byDay[dayKey] = (state.hitStats.byDay[dayKey] ?? 0) + 1
}

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

  const keepRecent = state.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(state.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(state.hitStats.byDay)) {
    const ts = new Date(`${day}T00:00:00.000Z`).getTime()
    if (Number.isFinite(ts) && ts >= cutoff) {
      nextByDay[day] = count
      nextByDayReal[day] = state.hitStats.byDayReal?.[day] ?? 0
      nextByDayBot[day] = state.hitStats.byDayBot?.[day] ?? 0
    }
  }

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

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

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

// ── Redirect / URL helpers ─────────────────────────────────────────────────

export 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
}

export 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 ''
}

export 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
}

// ── Contact / email tracking ───────────────────────────────────────────────

export function noteContactEmailCooldown(emailAddress) {
  const normalized = String(emailAddress || '').trim().toLowerCase()
  if (!normalized) return { ok: true, retryAfterMs: 0 }

  const now = Date.now()
  const lastAt = state.contactSubmitCooldownByEmail.get(normalized)
  if (typeof lastAt === 'number' && now - lastAt < CONTACT_EMAIL_COOLDOWN_MS) {
    return { ok: false, retryAfterMs: CONTACT_EMAIL_COOLDOWN_MS - (now - lastAt) }
  }

  state.contactSubmitCooldownByEmail.set(normalized, now)

  if (state.contactSubmitCooldownByEmail.size > 8000) {
    const cutoff = now - CONTACT_EMAIL_COOLDOWN_MS * 3
    for (const [email, timestamp] of state.contactSubmitCooldownByEmail.entries()) {
      if (timestamp < cutoff) state.contactSubmitCooldownByEmail.delete(email)
    }
  }

  return { ok: true, retryAfterMs: 0 }
}

export function extractTagValue(tags, name) {
  if (!Array.isArray(tags)) return ''
  const target = String(name || '').trim().toLowerCase()
  if (!target) return ''
  for (const tag of tags) {
    if (!tag || typeof tag !== 'object') continue
    const key = typeof tag.name === 'string' ? tag.name.trim().toLowerCase() : ''
    const value = typeof tag.value === 'string' ? tag.value.trim() : ''
    if (key === target && value) return value
  }
  return ''
}

export function mapResendEventToStatus(eventType) {
  const normalized = String(eventType || '').trim().toLowerCase()
  if (!normalized) return 'updated'
  if (normalized.includes('delivered')) return 'delivered'
  if (normalized.includes('delivery_delayed') || normalized.includes('delivery delayed')) return 'delayed'
  if (normalized.includes('bounce')) return 'bounced'
  if (normalized.includes('complain')) return 'complained'
  if (normalized.includes('click')) return 'clicked'
  if (normalized.includes('open')) return 'opened'
  if (normalized.includes('send')) return 'sent'
  return 'updated'
}

export function extractResendMessageId(result) {
  if (!result || typeof result !== 'object') return ''
  if (typeof result.id === 'string' && result.id.trim()) return result.id.trim()
  if (result.data && typeof result.data === 'object' && typeof result.data.id === 'string' && result.data.id.trim()) {
    return result.data.id.trim()
  }
  return ''
}

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

// ── Study reminder scheduler ───────────────────────────────────────────────

export async function scheduleStudyReminders(sendStudyReminderEmail) {
  if (!state.cachedSiteContent) return
  const now = new Date()

  for (const user of state.studyUsers) {
    if (user.studyRemindersEnabled !== true) continue
    const email = user.username
    const displayName = user.displayName || email
    const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
    if (enrolledStudySlugs.length === 0) continue

    const userSent = state.studyReminders.users[user.id] ?? {}
    for (const studySlug of enrolledStudySlugs) {
      const study = Array.isArray(state.cachedSiteContent.studies)
        ? state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
        : undefined
      if (!study) continue

      for (const section of study.sections ?? []) {
        const sectionId = section.id
        const releaseDate = getSectionReleaseDate(section)
        if (!releaseDate) continue
        // Lessons release at 1:00 AM; reminder emails go out 8 hours later, at 9:00 AM the same day.
        const sendDate = new Date(releaseDate.getTime() + 8 * 60 * 60 * 1000)
        if (sendDate > now) continue
        const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : []
        if (sentForStudy.includes(sectionId)) continue

        const hoursSinceSend = (now.getTime() - sendDate.getTime()) / (1000 * 60 * 60)
        if (hoursSinceSend > 24) continue

        const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
        const base = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
        const sectionUrl = `${base}/study/${study.slug}/${section.id}`
        try {
          await sendStudyReminderEmail(email, displayName, study.title, section.title, section.reference, sectionUrl)
        } catch (err) {
          console.error(`[study-reminders] failed to send reminder to ${email} for ${studySlug}/${sectionId}:`, err)
          continue
        }
        userSent[studySlug] = [...sentForStudy, sectionId]
        state.studyReminders.users[user.id] = userSent
        // Persist after each successful send so a later crash doesn't re-send
        queueStudyRemindersWrite()
      }
    }
  }
  state.studyReminders.updatedAt = new Date().toISOString()
  queueStudyRemindersWrite()
}

// ── Study re-engagement scheduler ─────────────────────────────────────────

function getReengagementTiers() {
  const cfg = state.cachedSiteContent ?? {}
  const raw = typeof cfg.studyReengagementDays === 'string' ? cfg.studyReengagementDays : '7,14,30'
  const days = raw.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n) && n > 0)
  if (days.length === 0) return [{ key: 't1', days: 7 }, { key: 't2', days: 14 }, { key: 't3', days: 30 }]
  return days.slice(0, 3).map((d, i) => ({ key: `t${i + 1}`, days: d }))
}

function makeUnsubUrl(userId) {
  const secret = process.env.RESEND_WEBHOOK_TOKEN ?? 'siteforge'
  const token = createHmac('sha256', secret).update(userId).digest('hex')
  const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
  const base = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
  return `${base}/api/study-auth/unsubscribe-reminders?uid=${encodeURIComponent(userId)}&token=${token}`
}

export async function scheduleReengagementEmails(sendStudyReengagementEmail) {
  if (!state.cachedSiteContent) return
  const now = new Date()
  const nowMs = now.getTime()
  const tiers = getReengagementTiers()

  for (const user of state.studyUsers) {
    if (user.studyRemindersEnabled !== true) continue
    if (!user.lastLoginAt) continue
    const lastLogin = new Date(user.lastLoginAt)
    if (isNaN(lastLogin.getTime())) continue
    const daysSinceLogin = (nowMs - lastLogin.getTime()) / (1000 * 60 * 60 * 24)

    const sent = typeof user.reengagementSentAt === 'object' && user.reengagementSentAt !== null ? user.reengagementSentAt : {}

    const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
    const studies = Array.isArray(state.cachedSiteContent.studies) ? state.cachedSiteContent.studies : []
    const enrolledStudies = enrolledStudySlugs
      .map(slug => studies.find(s => normalizeStudySlug(s?.slug) === slug))
      .filter(Boolean)

    if (enrolledStudies.length === 0) continue

    const firstStudy = enrolledStudies[0]
    const studyTitle = enrolledStudies.length === 1 ? firstStudy.title : null
    const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
    const base = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
    const studyUrl = enrolledStudies.length === 1
      ? `${base}/study/${firstStudy.slug}`
      : `${base}/study`

    for (const tier of tiers) {
      if (daysSinceLogin < tier.days) continue
      if (sent[tier.key]) continue

      const unsubUrl = makeUnsubUrl(user.id)
      try {
        await sendStudyReengagementEmail(user.username, user.displayName || user.username, studyTitle, studyUrl, tier.key, unsubUrl)
      } catch (err) {
        console.error(`[study-reengagement] failed to send ${tier.key} to ${user.username}:`, err)
        continue
      }
      user.reengagementSentAt = { ...sent, [tier.key]: new Date().toISOString() }
      queueStudyUsersWrite()
      break  // one tier per run so we don't spam if they've been gone >30 days
    }
  }
}
