import { createHash, randomBytes } from 'node:crypto'

/**
 * Keeping bots out of the forms that write to the Leads collection.
 *
 * Three checks, deliberately in this order, because the first two cost nothing and catch the cheap bots
 * that make up most of the noise, while the third needs an account with Cloudflare:
 *
 *   1. a honeypot field  — a visible-to-nobody input that a person never fills and a form-filling bot does
 *   2. a time trap       — a person needs seconds to read and type; a script posts the moment it parses
 *   3. Turnstile         — Cloudflare's «I'm not a robot» widget, which is the modern, privacy-respecting
 *                          replacement for reCAPTCHA and the only one of the three a determined bot cannot
 *                          simply be taught to pass
 *
 * Turnstile switches itself on: with no keys in the environment the widget is not rendered and the check is
 * skipped, so the form keeps working exactly as before. The moment both keys are set, the widget appears and
 * a submission without a valid token is refused. That way nothing breaks between deploying this and creating
 * the keys.
 */

/** How long a real person needs, at the very least, to read the form and type into it. */
const MIN_SECONDS = 3

/** How many requests one address may send in an hour. Set LEAD_MAX_PER_HOUR to change it. */
function maxPerHour(): number {
  const n = Number((process.env.LEAD_MAX_PER_HOUR || '').trim())
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : 7
}

/** And how long a page may sit open before its token is treated as stale rather than suspicious. */
const MAX_HOURS = 12

export const TURNSTILE_FIELD = 'cf-turnstile-response'
export const STAMP_FIELD = 'ts'
export const HONEYPOT_FIELD = 'website'

export function turnstileSiteKey(): string {
  return (process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || '').trim()
}

function turnstileSecret(): string {
  return (process.env.TURNSTILE_SECRET_KEY || '').trim()
}

/** Whether the widget should be rendered and its answer demanded. */
export function turnstileEnabled(): boolean {
  return Boolean(turnstileSiteKey() && turnstileSecret())
}


/**
 * Ask Cloudflare whether this token is a real one, for this secret, used once.
 *
 * A network failure here is deliberately NOT treated as a failed check: if Cloudflare is unreachable the
 * form still accepts the request, because losing a genuine student's question is worse than letting a
 * spam message through on the rare minute that Cloudflare is down. The honeypot and the time trap still run.
 */
async function turnstilePasses(token: string, ip?: string): Promise<boolean> {
  if (!token) return false
  try {
    const body = new URLSearchParams({ secret: turnstileSecret(), response: token })
    if (ip) body.set('remoteip', ip)
    const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
      method: 'POST',
      body,
      signal: AbortSignal.timeout(8000),
    })
    const data = (await res.json()) as { success?: boolean }
    return Boolean(data?.success)
  } catch {
    return true // unreachable, not failed: see the note above
  }
}


/**
 * How many requests each address has sent in the last hour, kept in this process and nowhere else.
 *
 * The address is hashed with a salt made when the process starts, so what sits in memory cannot be turned
 * back into anybody's IP, and it is never written to the database, to a log or to disk. An IP is personal
 * data — this course teaches that in Lesson 3 of Unit 1 — and a counter that stops spam does not need to
 * remember who anyone was. Everything here evaporates when the container restarts.
 *
 * The honest limits of doing it this way: a deploy clears the counters, and if the site is ever run as more
 * than one container each one counts on its own. Both are fine for a limit whose job is to blunt a flood,
 * and neither is worth a Redis dependency or a table of visitors' addresses.
 */
const seen = new Map<string, number[]>()
const SALT = randomBytes(16).toString('hex')

function key(ip: string): string {
  return createHash('sha256').update(SALT).update(ip).digest('hex').slice(0, 32)
}

/** Record this request and say whether the address has now gone over the hour's allowance. */
function overTheLimit(ip: string): boolean {
  const now = Date.now()
  const hourAgo = now - 3600_000
  const k = key(ip)
  const times = (seen.get(k) || []).filter((t) => t > hourAgo)
  times.push(now)
  seen.set(k, times)

  // Keep the map from growing for ever on a long-running container: anything whose newest request is older
  // than the window is gone, and so is the address it stood for.
  if (seen.size > 5000) {
    for (const [other, ts] of seen) {
      if (!ts.some((t) => t > hourAgo)) seen.delete(other)
    }
  }
  return times.length > maxPerHour()
}

export type SpamVerdict =
  | { ok: true }
  | { ok: false; reason: 'honeypot' | 'tooFast' | 'turnstile' | 'tooMany' }


/**
 * What a visitor is told when the only thing they got wrong was the box. Kept here, next to the check that
 * produces it, rather than in the CMS: it is a message about a control the editors do not manage, and a form
 * that cannot explain itself because a translation is missing is worse than one with a fixed sentence.
 */
export function botMessage(locale: 'ar' | 'en'): string {
  return locale === 'en'
    ? 'Please tick the “I’m not a robot” box and send again.'
    : 'من فضلك علّم على مربع «أنا مش روبوت» وابعت تاني.'
}


/**
 * What someone is told when their address has sent too many requests this hour.
 *
 * This one is NOT silently dropped the way a caught bot is, and that is the whole point: a school, a company
 * or a home can put many people behind one address, so the person reading this may well be real. They are
 * told what happened and given another way to reach the academy, instead of being left thinking their
 * question was sent when it was not.
 */
export function tooManyMessage(locale: 'ar' | 'en'): string {
  return locale === 'en'
    ? 'We have had several requests from your connection in the past hour. Please try again later, or reach us on WhatsApp or by email — the details are on this page.'
    : 'وصلنا أكتر من طلب من نفس الاتصال في الساعة اللي فاتت. جرّب تاني بعد شوية، أو كلّمنا على واتساب أو بالإيميل — البيانات في الصفحة دي.'
}


/**
 * Run every check that applies. `honeypot` and `stamp` come straight from the form; `token` is the widget's
 * answer, and `ip` is the visitor's address when the caller knows it.
 */
export async function checkNotABot(input: {
  honeypot: string
  stamp: string
  token: string
  ip?: string
}): Promise<SpamVerdict> {
  if (input.honeypot.trim()) return { ok: false, reason: 'honeypot' }

  const started = Number(input.stamp)
  if (Number.isFinite(started) && started > 0) {
    const seconds = (Date.now() - started) / 1000
    // Only the too-fast end is rejected. A page left open for hours is a person who got distracted, and a
    // clock that disagrees with the server would otherwise lock a real visitor out of the form for good.
    if (seconds < MIN_SECONDS && seconds > -60) return { ok: false, reason: 'tooFast' }
    if (seconds > MAX_HOURS * 3600) return { ok: true }
  }

  if (turnstileEnabled() && !(await turnstilePasses(input.token, input.ip))) {
    return { ok: false, reason: 'turnstile' }
  }

  // Counted last, so a request that was going to be refused anyway does not use up the allowance of an
  // address that a real person may be sharing.
  if (input.ip && overTheLimit(input.ip)) return { ok: false, reason: 'tooMany' }

  return { ok: true }
}
