// Login.jsx — the composed sign-in surface (§6.2) after the v0.12.0
// (CloudFlare Turnstile gate on OTC dispatch, roadmap item #10) /
// v0.10.0 (passcodes, roadmap item #8) rebase onto v0.8.0
// (beta-access-request capture, §6.1 / §14.1, roadmap item #6). v0.7.0
// (roadmap item #5) established the email + OTC scaffolding the later
// releases extended.
//
// v0.12.0: the email-entry step renders a Turnstile widget. The token
// it produces is sent to `/auth/otc/request` alongside the email. The
// passcode step also renders a widget for the "Use a code instead"
// fallback dispatch (same backend endpoint, same gate). When the
// `VITE_TURNSTILE_SITE_KEY` build var is unset, the widget renders
// nothing — the form still submits and the backend's TURNSTILE_REQUIRED
// policy decides admission.
//
// Four-to-six-step flow (most users see three; the longest path is
// pending-user with no passcode, who never sees the passcode steps):
//
//   1. 'email'           Enter email → GET /auth/passcode/check.
//                        * has_passcode=true  → step 'passcode'.
//                        * has_passcode=false → POST /auth/otc/request,
//                                               step 'code'.
//                        429 on either dispatch surfaces a "wait a
//                        moment" hint and keeps the user on step 1.
//
//   2a. 'passcode'       Enter passcode → POST /auth/passcode/verify.
//                        * 200 → redirect to "/".
//                        * 423 (lockout, 5 consecutive failures) →
//                          auto-fall back to OTC by requesting a fresh
//                          code and advancing to step 'code'.
//                        * 400 → wrong passcode; user can retry or
//                          click "Use a code instead" to fall back
//                          manually.
//
//   2b. 'code'           Enter the six-digit code → POST /auth/otc/verify.
//                        On 200, fetch /api/auth/me and branch:
//                          * needs_profile === true  → 'capture-profile'
//                          * has_passcode  === false → 'offer-passcode'
//                          * otherwise               → redirect to "/".
//                        needs_profile WINS over has_passcode — a
//                        pending user goes through the §6.1 capture
//                        flow first; setting a passcode while waiting
//                        for admin grant gains them nothing.
//                        Cmd/Ctrl+Enter on the code field is the
//                        keyboard shortcut.
//
//   3. 'capture-profile' (v0.8.0, §6.1) First name, last name, and "why
//                        I should be included in the beta" → POST
//                        /api/auth/me/beta-request → redirect to
//                        /beta-pending. The user's row stays
//                        permission_state='pending' until an admin
//                        grants access; they can set a passcode later
//                        from settings, or on a future sign-in once
//                        granted.
//
//   4a. 'offer-passcode' (v0.10.0) "Set a passcode for faster sign-in
//                        next time?" Yes → 'set-passcode'. Skip → "/".
//
//   4b. 'set-passcode'   Pick a passcode (4–20 chars) → POST
//                        /auth/passcode/set → redirect to "/". A
//                        "Skip for now" link also redirects to "/".
//
// Server-side, /auth/otc/request returns 202 uniformly and
// /auth/passcode/check returns has_passcode=false for an unknown
// email, so this surface never distinguishes "we couldn't reach you"
// from "we don't know you" — an unknown email always lands in the
// OTC path with no account-enumeration signal.
//
// The legacy Gitea OAuth callback remains at /auth/login →
// /auth/callback during the v0.7.0 migration; we surface a "Sign in
// with Gitea" link as a fallback in the footer so users with active
// OAuth sessions or older invite emails still have a path. We hide
// the fallback on 'capture-profile' so a half-captured pending user
// doesn't bail out into the OAuth path mid-form.

import { useEffect, useRef, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import {
  requestOtc,
  verifyOtc,
  submitBetaRequest,
  checkPasscode,
  verifyPasscode,
  setPasscode as apiSetPasscode,
  startDeviceTrust,
} from '../api'
import TurnstileWidget, { turnstileEnabled } from './TurnstileWidget'
import { EVENTS, track } from '../lib/analytics'

export default function Login() {
  // Steps: 'email' → 'passcode' or 'code' → (on the OTC path, after
  // verify) one of: 'capture-profile' (pending user), 'offer-passcode'
  // (no passcode yet), or straight to "/". 'set-passcode' is reached
  // from 'offer-passcode'.
  const [step, setStep] = useState('email')
  const [email, setEmail] = useState('')
  const [code, setCode] = useState('')
  const [passcode, setPasscode] = useState('')
  const [newPasscode, setNewPasscode] = useState('')
  // v0.11.0 — "trust this device for 30 days" checkbox, shared by the
  // OTC and passcode verify steps. The flag rides on the verify POST;
  // a checked box mints a device-trust row server-side and sets the
  // long-lived `rfc_device_trust` cookie. Defaults off so the user
  // makes an explicit choice — auth credentials shouldn't persist by
  // default.
  const [trustDevice, setTrustDevice] = useState(false)
  // v0.8.0 — capture-profile fields.
  const [firstName, setFirstName] = useState('')
  const [lastName, setLastName] = useState('')
  const [reason, setReason] = useState('')
  const [status, setStatus] = useState('')
  const [busy, setBusy] = useState(false)
  // v0.12.0: Turnstile token captured by the widget. `null` means no
  // challenge solved yet (or the site key is unset, in which case the
  // widget surfaces null on mount). The token is single-use; we clear
  // it back to null right after we send it so a second request on the
  // same form remount re-challenges. `turnstileReady` is true once the
  // widget has produced a token OR the widget is not configured at
  // build time (no site key) — the submit button reads from it so the
  // form locks up when the operator has wired Turnstile but the user
  // hasn't solved the challenge yet.
  const [turnstileToken, setTurnstileToken] = useState(null)
  const turnstileOn = turnstileEnabled()
  const turnstileReady = !turnstileOn || !!turnstileToken
  const emailRef = useRef(null)
  const codeRef = useRef(null)
  const passcodeRef = useRef(null)
  const newPasscodeRef = useRef(null)
  const firstNameRef = useRef(null)
  const navigate = useNavigate()

  useEffect(() => {
    if (step === 'email') emailRef.current?.focus()
    else if (step === 'code') codeRef.current?.focus()
    else if (step === 'passcode') passcodeRef.current?.focus()
    else if (step === 'capture-profile') firstNameRef.current?.focus()
    else if (step === 'set-passcode') newPasscodeRef.current?.focus()
  }, [step])

  // v0.11.0 — on mount, try the device-trust cookie path. If the
  // browser still carries a valid `rfc_device_trust` cookie from a
  // prior "trust this device" gesture, the server re-establishes the
  // session without any user input and we redirect home. The cookie
  // is HttpOnly so we can't peek at it; we just call the endpoint and
  // see whether it returns 200. 401 (no cookie / invalid / revoked)
  // is the structural-silent case — the user proceeds to the email
  // step normally. We do not surface any UI about the attempt; a
  // failure should be invisible.
  useEffect(() => {
    let cancelled = false
    ;(async () => {
      try {
        await startDeviceTrust()
        if (!cancelled) {
          // v0.15.0 — analytics: device-trust cookie path is one of
          // three sign-in methods the taxonomy distinguishes.
          track(EVENTS.USER_SIGNED_IN, { method: 'trust-device' })
          window.location.assign('/')
        }
      } catch (_) {
        // No trusted device — fall through to the email step.
      }
    })()
    return () => { cancelled = true }
  }, [])

  async function submitEmail(e) {
    e.preventDefault()
    if (!email.trim() || !email.includes('@')) {
      setStatus('Enter a valid email address.')
      return
    }
    setBusy(true)
    setStatus('')
    try {
      const { has_passcode } = await checkPasscode(email.trim())
      if (has_passcode) {
        setStep('passcode')
        setStatus('')
      } else {
        await requestOtc(email.trim(), { turnstileToken })
        // v0.12.0: the token is single-use; drop it so a re-request
        // from the code step (via "Use a different email" → back to
        // email) starts with a fresh challenge.
        setTurnstileToken(null)
        setStep('code')
        setStatus('Check your inbox — a six-digit code is on the way.')
      }
    } catch (err) {
      // Any failure consumes the token from CloudFlare's side; clear
      // so the widget re-renders a fresh challenge on retry.
      setTurnstileToken(null)
      if (err.status === 429) {
        setStatus('Slow down — wait a minute before requesting another code.')
      } else if (err.status === 400) {
        setStatus("Couldn't verify you're human. Please retry the challenge.")
      } else {
        setStatus(err.message || 'Could not start sign-in. Try again.')
      }
    } finally {
      setBusy(false)
    }
  }

  async function submitPasscode(e) {
    if (e) e.preventDefault()
    if (!passcode.trim()) {
      setStatus('Enter your passcode.')
      return
    }
    setBusy(true)
    setStatus('')
    try {
      await verifyPasscode(email.trim(), passcode.trim(), { trustDevice })
      // v0.15.0 — analytics: passcode is the second of three
      // sign-in methods. trust-device gets credited separately when
      // the cookie-driven path fires above.
      track(EVENTS.USER_SIGNED_IN, { method: 'passcode' })
      // Reload so App.jsx's getMe() picks up the fresh session. A
      // returning passcode user is by definition already past the
      // §6.1 capture step (they couldn't have set a passcode while
      // pending), so we go straight to "/".
      window.location.assign('/')
    } catch (err) {
      if (err.status === 423) {
        // Lockout — auto-fall back to OTC. The OTC request endpoint
        // is independent of the passcode lockout, so this lands a
        // fresh code in the user's inbox immediately.
        setPasscode('')
        try {
          // v0.12.0: pass whatever token the widget on the passcode
          // step has produced. If the operator has Turnstile required
          // and the user hasn't solved the passcode-step widget, the
          // backend refuses and we bounce them back to email-entry
          // with a clear status (see catch below).
          await requestOtc(email.trim(), { turnstileToken })
          setTurnstileToken(null)
          setStep('code')
          setStatus(
            'Too many failed attempts. We sent a one-time code to your email — use it to sign in.',
          )
        } catch (e2) {
          setTurnstileToken(null)
          if (e2.status === 429) {
            setStep('code')
            setStatus(
              'Too many failed attempts. Wait a minute, then request a one-time code to sign in.',
            )
          } else if (e2.status === 400) {
            setStep('email')
            setStatus(
              'Too many failed attempts. Solve the challenge below to receive a one-time code.',
            )
          } else {
            setStatus(
              'Too many failed attempts. Use the "Use a code instead" link to sign in via email.',
            )
          }
        }
      } else {
        setStatus('Wrong passcode. Try again, or use a one-time code instead.')
      }
      setBusy(false)
    }
  }

  async function submitCode(e) {
    if (e) e.preventDefault()
    if (!code.trim() || code.trim().length !== 6) {
      setStatus('Enter the six-digit code from your email.')
      return
    }
    setBusy(true)
    setStatus('')
    try {
      await verifyOtc(email.trim(), code.trim(), { trustDevice })
      // v0.15.0 — analytics: OTC is the third sign-in method.
      // We fire it here regardless of whether the user then lands
      // in capture-profile or offer-passcode — sign-in has happened
      // server-side either way.
      track(EVENTS.USER_SIGNED_IN, { method: 'otc' })
      // OTC verified — the server has signed in the user. Fetch the
      // canonical /api/auth/me to decide where to land:
      //   * needs_profile  → §6.1 capture (then /beta-pending).
      //   * no passcode    → §6.2 offer-passcode (then /).
      //   * otherwise      → /.
      // needs_profile wins over has_passcode: a pending user can't yet
      // do anything that benefits from faster sign-in, so we don't
      // distract them with the passcode offer mid-admission.
      const meResp = await fetch('/api/auth/me', { credentials: 'include' })
      let me = null
      if (meResp.ok) {
        try {
          me = await meResp.json()
        } catch (_) {
          me = null
        }
      }
      if (me?.needs_profile === true) {
        setStep('capture-profile')
        setStatus('')
        setBusy(false)
        return
      }
      if (me?.has_passcode === false) {
        setStep('offer-passcode')
        setStatus('')
        setBusy(false)
        return
      }
      // Either /me returned the granted-with-passcode shape, or the
      // call failed but the session cookie is set — fall through to
      // a hard reload so App.jsx re-fetches and renders accordingly.
      window.location.assign('/')
    } catch (err) {
      setStatus('That code is invalid or expired. Try again, or request a new code.')
      setBusy(false)
    }
  }

  async function submitProfile(e) {
    if (e) e.preventDefault()
    const fn = firstName.trim()
    const ln = lastName.trim()
    const why = reason.trim()
    if (!fn || !ln || !why) {
      setStatus('All three fields are required.')
      return
    }
    setBusy(true)
    setStatus('')
    try {
      await submitBetaRequest({
        first_name: fn,
        last_name: ln,
        beta_request_reason: why,
      })
      // v0.15.0 — analytics: a successful capture-profile submit is
      // the moment a beta-access request lands. No PII in the event
      // body (no name, no reason text); the count + timestamp is
      // what the funnel needs.
      track(EVENTS.BETA_ACCESS_REQUESTED)
      // Hard-load so App.jsx re-fetches /api/auth/me and picks up
      // the captured fields. The user stays permission_state='pending'
      // until an admin grants access — the next thing they should
      // see is the "your request is in review" page.
      window.location.assign('/beta-pending')
    } catch (err) {
      setStatus(err.message || 'Could not submit your request. Try again.')
      setBusy(false)
    }
  }

  async function submitNewPasscode(e) {
    if (e) e.preventDefault()
    const pc = newPasscode.trim()
    if (pc.length < 4) {
      setStatus('Passcode must be at least 4 characters.')
      return
    }
    setBusy(true)
    setStatus('')
    try {
      await apiSetPasscode(pc)
      window.location.assign('/')
    } catch (err) {
      // 422 carries the validation message verbatim (denylist /
      // length); surface it as-is so the user knows what to change.
      setStatus(err.message || 'Could not set passcode. Try a different one.')
      setBusy(false)
    }
  }

  function onCodeKey(e) {
    // §6.2 ergonomic: Cmd/Ctrl+Enter submits from the code field.
    if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
      submitCode(e)
    }
  }

  function onPasscodeKey(e) {
    if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
      submitPasscode(e)
    }
  }

  function onReasonKey(e) {
    // §6.1 ergonomic: Cmd/Ctrl+Enter submits the capture form from
    // the reason textarea (the multi-line input that would otherwise
    // swallow Enter as a newline).
    if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
      submitProfile(e)
    }
  }

  function onNewPasscodeKey(e) {
    if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
      submitNewPasscode(e)
    }
  }

  function backToEmail() {
    setStep('email')
    setCode('')
    setPasscode('')
    setStatus('')
  }

  async function fallbackToOtc() {
    // Manual "Use a code instead" from the passcode step. Same shape
    // as the email-step OTC dispatch. v0.12.0: pass through whatever
    // Turnstile token the passcode-step widget has produced (or null
    // when the widget is disabled at build time).
    setBusy(true)
    setStatus('')
    try {
      await requestOtc(email.trim(), { turnstileToken })
      setTurnstileToken(null)
      setPasscode('')
      setStep('code')
      setStatus('Check your inbox — a six-digit code is on the way.')
    } catch (err) {
      setTurnstileToken(null)
      if (err.status === 429) {
        setStatus('Slow down — wait a minute before requesting another code.')
      } else if (err.status === 400) {
        // v0.12.0: the widget rejected or no token was sent. Bounce
        // the user back to the email step so they get a fresh
        // challenge alongside the email input.
        setStep('email')
        setStatus("Couldn't verify you're human. Please retry the challenge.")
      } else {
        setStatus(err.message || 'Could not request a code. Try again.')
      }
    } finally {
      setBusy(false)
    }
  }

  function skipPasscodeOffer() {
    window.location.assign('/')
  }

  return (
    <div className="otc-login">
      <div className="otc-login-inner">
        <h1>Sign in</h1>
        {step === 'email' && (
          <form onSubmit={submitEmail}>
            <p className="otc-hint">
              Enter your email. If you've set a passcode, you'll enter that
              next; otherwise we'll send a one-time code.
            </p>
            <input
              ref={emailRef}
              type="email"
              autoComplete="email"
              value={email}
              onChange={e => setEmail(e.target.value)}
              placeholder="you@example.com"
              required
              disabled={busy}
            />
            {/*
              v0.12.0: CloudFlare Turnstile widget. Renders nothing
              when VITE_TURNSTILE_SITE_KEY is unset (and turnstileReady
              defaults to true in that case so the submit gate doesn't
              lock up). On every challenge the widget calls onToken
              with the fresh token; we feed it to /auth/otc/request.
            */}
            <TurnstileWidget onToken={setTurnstileToken} />
            <button
              type="submit"
              disabled={busy || !email.trim() || !turnstileReady}
            >
              {busy ? 'Checking…' : 'Continue'}
            </button>
          </form>
        )}
        {step === 'passcode' && (
          <form onSubmit={submitPasscode}>
            <p className="otc-hint">
              Enter the passcode for <strong>{email}</strong>.
            </p>
            <input
              ref={passcodeRef}
              type="password"
              autoComplete="current-password"
              value={passcode}
              onChange={e => setPasscode(e.target.value)}
              onKeyDown={onPasscodeKey}
              placeholder="Your passcode"
              required
              disabled={busy}
            />
            {/* v0.11.0 — trust device for 30 days. The checkbox lives
                on the verify step so the user makes the trust gesture
                in the same breath as signing in. Off by default; the
                user opts in deliberately. */}
            <label className="otc-trust-device">
              <input
                type="checkbox"
                checked={trustDevice}
                onChange={e => setTrustDevice(e.target.checked)}
                disabled={busy}
              />
              <span>Trust this device for 30 days</span>
            </label>
            {/*
              v0.12.0: a second Turnstile widget for the
              "Use a code instead" fallback dispatch. The passcode
              verify path does not consume a Turnstile token (it has
              its own 5-attempt lockout shape from v0.10.0), but if
              the user falls back to OTC the same /auth/otc/request
              endpoint runs and needs a token. We render the widget
              on this step too so the fallback works without bouncing
              back to email-entry first.
            */}
            <TurnstileWidget onToken={setTurnstileToken} />
            <div className="otc-actions">
              <button type="submit" disabled={busy || !passcode.trim()}>
                {busy ? 'Signing in…' : 'Sign in'}
              </button>
              <button
                type="button"
                className="btn-link-quiet"
                onClick={fallbackToOtc}
                disabled={busy || !turnstileReady}
              >
                Use a code instead
              </button>
              <button
                type="button"
                className="btn-link-quiet"
                onClick={backToEmail}
                disabled={busy}
              >
                Use a different email
              </button>
            </div>
          </form>
        )}
        {step === 'code' && (
          <form onSubmit={submitCode}>
            <p className="otc-hint">
              Enter the six-digit code we sent to <strong>{email}</strong>.
            </p>
            <input
              ref={codeRef}
              type="text"
              inputMode="numeric"
              pattern="[0-9]*"
              autoComplete="one-time-code"
              maxLength={6}
              value={code}
              onChange={e => setCode(e.target.value.replace(/\D/g, ''))}
              onKeyDown={onCodeKey}
              placeholder="123456"
              required
              disabled={busy}
            />
            {/* v0.11.0 — trust device for 30 days. Same shape as the
                passcode step; the user opts in deliberately. */}
            <label className="otc-trust-device">
              <input
                type="checkbox"
                checked={trustDevice}
                onChange={e => setTrustDevice(e.target.checked)}
                disabled={busy}
              />
              <span>Trust this device for 30 days</span>
            </label>
            <div className="otc-actions">
              <button type="submit" disabled={busy || code.length !== 6}>
                {busy ? 'Signing in…' : 'Sign in'}
              </button>
              <button
                type="button"
                className="btn-link-quiet"
                onClick={backToEmail}
                disabled={busy}
              >
                Use a different email
              </button>
            </div>
            <p className="otc-shortcut-hint">
              Tip: <kbd>⌘</kbd>+<kbd>Enter</kbd> (or <kbd>Ctrl</kbd>+<kbd>Enter</kbd>) to sign in.
            </p>
          </form>
        )}
        {step === 'capture-profile' && (
          <form onSubmit={submitProfile}>
            <p className="otc-hint">
              You're signed in. {import.meta.env.VITE_APP_NAME} is in private
              beta — tell us a bit about yourself and an admin will review
              your request.
            </p>
            <label className="otc-field-label">First name</label>
            <input
              ref={firstNameRef}
              type="text"
              autoComplete="given-name"
              value={firstName}
              onChange={e => setFirstName(e.target.value)}
              required
              disabled={busy}
              maxLength={120}
            />
            <label className="otc-field-label">Last name</label>
            <input
              type="text"
              autoComplete="family-name"
              value={lastName}
              onChange={e => setLastName(e.target.value)}
              required
              disabled={busy}
              maxLength={120}
            />
            <label className="otc-field-label">
              Why you'd like to be included in the beta
            </label>
            <textarea
              value={reason}
              onChange={e => setReason(e.target.value)}
              onKeyDown={onReasonKey}
              required
              disabled={busy}
              rows={5}
              maxLength={4000}
              placeholder="A sentence or two is plenty."
            />
            <div className="otc-actions">
              <button
                type="submit"
                disabled={busy || !firstName.trim() || !lastName.trim() || !reason.trim()}
              >
                {busy ? 'Submitting…' : 'Submit request'}
              </button>
            </div>
            <p className="otc-shortcut-hint">
              Tip: <kbd>⌘</kbd>+<kbd>Enter</kbd> (or <kbd>Ctrl</kbd>+<kbd>Enter</kbd>) to submit.
            </p>
          </form>
        )}
        {step === 'offer-passcode' && (
          <div className="otc-offer-passcode">
            <p className="otc-hint">
              You're signed in. Want to set a passcode for faster sign-in
              next time? You can always use a one-time code instead — and
              you can change or remove the passcode from your settings.
            </p>
            <div className="otc-actions">
              <button
                type="button"
                onClick={() => { setStep('set-passcode'); setStatus('') }}
              >
                Set a passcode
              </button>
              <button
                type="button"
                className="btn-link-quiet"
                onClick={skipPasscodeOffer}
              >
                Skip for now
              </button>
            </div>
          </div>
        )}
        {step === 'set-passcode' && (
          <form onSubmit={submitNewPasscode}>
            <p className="otc-hint">
              Pick a passcode (4–20 characters). You'll use it with your
              email to sign in next time.
            </p>
            <input
              ref={newPasscodeRef}
              type="password"
              autoComplete="new-password"
              value={newPasscode}
              onChange={e => setNewPasscode(e.target.value)}
              onKeyDown={onNewPasscodeKey}
              placeholder="New passcode"
              required
              disabled={busy}
              minLength={4}
              maxLength={20}
            />
            <div className="otc-actions">
              <button type="submit" disabled={busy || newPasscode.trim().length < 4}>
                {busy ? 'Saving…' : 'Save passcode'}
              </button>
              <button
                type="button"
                className="btn-link-quiet"
                onClick={skipPasscodeOffer}
                disabled={busy}
              >
                Skip for now
              </button>
            </div>
          </form>
        )}
        {status && <p className="otc-status">{status}</p>}
        {step !== 'capture-profile' && (
          <p className="otc-fallback">
            <Link to="/philosophy">Read the philosophy →</Link>
            <span className="otc-fallback-sep">·</span>
            <a href="/auth/login">Sign in with Gitea (fallback)</a>
          </p>
        )}
      </div>
    </div>
  )
}
