// Magic-link auth endpoints for multi-tenant cloud mode.
//
// Flow:
//   1. POST /auth/request-link  { email }
//        - Normalize, rate-limit, generate token, hash, store hash
//        - Send email with verifyUrl containing the plaintext token
//        - Always returns { ok: true } — never leaks whether email exists
//
//   2. GET /auth/verify?token=<plaintext>
//        - Hash, look up, validate (unused + unexpired)
//        - Mark used; upsert user; issue session cookie
//        - If first user ever, mark is_admin = 1 (operator bootstrap)
//        - If request carries a recap_anon_trial cookie, link the trial
//          row to the new user_id so their trial summary lands in their
//          library and the conversion gets recorded
//        - 302 redirect to /
//
//   3. POST /auth/signout
//        - Delete session row, clear cookie, redirect to / (or 204 if API)
//
// Multi-mode only. The route registration helper itself returns early
// in single mode so single-mode boot doesn't initialize SMTP / DB
// codepaths.

import {
  randomBytes,
  createHash,
  scryptSync,
  timingSafeEqual,
} from "crypto";
import * as cookie from "cookie";
import { getDb } from "./db.js";
import { sendMail, isSmtpReady } from "./smtp.js";
import { renderMagicLinkEmail } from "./email-template.js";
import { getConfigSnapshot } from "./config.js";
import { getClientIp, TRIAL_COOKIE, linkToUser } from "./anon-trial.js";
import { renameScopeDir } from "./history.js";
import { requireUser } from "./tenant-auth.js";

export const SESSION_COOKIE = "recap_session";
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; // 15 minutes

// ── Password hashing ─────────────────────────────────────────────────
// scrypt with a per-password 16-byte salt, stored as
// "scrypt$<saltHex>$<hashHex>" in users.password_hash. Same primitive
// admin-auth.js uses for the single-mode operator password, so the
// crypto surface area stays consistent across the codebase. N=2^15
// (KDF cost) is what the Node docs suggest for interactive logins —
// ~50ms on commodity hardware, slow enough to deter brute force,
// fast enough to not feel laggy.
const SCRYPT_KEYLEN = 64;
const SCRYPT_OPTS = { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
const SCRYPT_PREFIX = "scrypt$";

export function hashPassword(plain) {
  const salt = randomBytes(16);
  const derived = scryptSync(plain, salt, SCRYPT_KEYLEN, SCRYPT_OPTS);
  return `${SCRYPT_PREFIX}${salt.toString("hex")}$${derived.toString("hex")}`;
}

function verifyPassword(plain, stored) {
  if (!stored || !stored.startsWith(SCRYPT_PREFIX)) return false;
  const [, saltHex, hashHex] = stored.split("$");
  if (!saltHex || !hashHex) return false;
  let salt, expected;
  try {
    salt = Buffer.from(saltHex, "hex");
    expected = Buffer.from(hashHex, "hex");
  } catch {
    return false;
  }
  if (expected.length !== SCRYPT_KEYLEN) return false;
  let actual;
  try {
    actual = scryptSync(plain, salt, SCRYPT_KEYLEN, SCRYPT_OPTS);
  } catch {
    return false;
  }
  // timingSafeEqual requires equal length, which we just enforced above.
  return timingSafeEqual(actual, expected);
}

// Mild policy — 8 char minimum, 256 max. We deliberately don't enforce
// "one uppercase + one digit + ..." style rules; the consensus modern
// view (NIST 800-63B) is that length matters far more than composition,
// and rules that demand specific shapes just push users toward
// predictable substitutions ("Password1!"). Min 8 catches the worst
// cases without alienating people using passphrases.
export function validatePasswordPolicy(plain) {
  if (typeof plain !== "string") return "password_required";
  if (plain.length < 8) return "password_too_short";
  if (plain.length > 256) return "password_too_long";
  return null;
}

// ── Rate limits ────────────────────────────────────────────────────────
// Both buckets are in-memory: not durable across restarts, but the worst
// case is one extra link request per email/IP per restart, which is fine.
// For abuse on the scale where in-memory limits are insufficient, the
// operator's IP/UA logs + manual deny-list are the next layer.
const MAX_LINKS_PER_EMAIL_PER_HOUR = 5;
const MAX_LINKS_PER_IP_PER_HOUR = 10;
const ONE_HOUR_MS = 60 * 60 * 1000;

const emailBuckets = new Map(); // email → [timestamp, ...]
const ipBuckets = new Map(); // ip → [timestamp, ...]

function pushBucket(map, key, now) {
  const arr = map.get(key) || [];
  const fresh = arr.filter((t) => now - t < ONE_HOUR_MS);
  fresh.push(now);
  map.set(key, fresh);
  return fresh.length;
}

function bucketCount(map, key, now) {
  const arr = map.get(key) || [];
  const fresh = arr.filter((t) => now - t < ONE_HOUR_MS);
  if (fresh.length !== arr.length) map.set(key, fresh);
  return fresh.length;
}

// Periodically prune old bucket entries so the maps don't grow
// unbounded under heavy traffic. Fire-and-forget; the filter above
// also self-prunes per access.
const BUCKET_GC_MS = 5 * 60 * 1000;
setInterval(() => {
  const cutoff = Date.now() - ONE_HOUR_MS;
  for (const map of [emailBuckets, ipBuckets]) {
    for (const [key, arr] of map.entries()) {
      const fresh = arr.filter((t) => t > cutoff);
      if (fresh.length === 0) map.delete(key);
      else if (fresh.length !== arr.length) map.set(key, fresh);
    }
  }
}, BUCKET_GC_MS).unref?.();

function normalizeEmail(raw) {
  if (typeof raw !== "string") return "";
  return raw.trim().toLowerCase();
}

function isPlausibleEmail(s) {
  // Deliberately permissive — the user's mail server is the source of
  // truth for whether an address works (they either receive the link or
  // they don't). Just sanity-check that we have something with @ and a
  // dot in the domain.
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s) && s.length <= 254;
}

function sha256(s) {
  return createHash("sha256").update(s).digest("hex");
}

function uuid() {
  // 16 random bytes formatted as a UUIDv4-ish hex with hyphens. Doesn't
  // need to be RFC-compliant — uniqueness is the only requirement.
  return randomBytes(16).toString("hex");
}

function clipUA(ua) {
  return String(ua || "").slice(0, 256);
}

// ── Public URL plumbing ────────────────────────────────────────────────
// The verify URL in the email needs the operator's ClearNet URL set via
// the "Set Recap Public URL" StartOS action. We refuse to send magic
// links if it's empty — otherwise the email would link to localhost or
// an internal hostname.
async function getPublicUrl() {
  const snap = await getConfigSnapshot();
  const url = (snap.recap_public_url || "").trim();
  return url.replace(/\/$/, "");
}

// ── Route handlers ─────────────────────────────────────────────────────

async function handleRequestLink(req, res) {
  const email = normalizeEmail(req.body?.email);
  if (!email || !isPlausibleEmail(email)) {
    // Deliberately vague — don't help enumerate valid emails. Just say
    // "ok" even on bad input so the response shape is the same.
    return res.json({ ok: true });
  }

  const publicUrl = await getPublicUrl();
  if (!publicUrl) {
    console.error(
      "[auth] /auth/request-link blocked: recap_public_url not set. Run the 'Set Recap Public URL' StartOS action first.",
    );
    return res.status(503).json({
      error: "public_url_not_set",
      message:
        "This Recap instance hasn't been fully configured yet. Ask the operator to set the public URL.",
    });
  }
  if (!isSmtpReady()) {
    console.error(
      "[auth] /auth/request-link blocked: SMTP not ready. Configure StartOS System SMTP.",
    );
    return res.status(503).json({
      error: "smtp_not_ready",
      message:
        "This Recap instance can't send email yet. Ask the operator to configure SMTP.",
    });
  }

  const ip = getClientIp(req);
  const now = Date.now();

  // Rate limits. Two buckets — email and IP. Either trips → 429 with a
  // generic message that doesn't leak which limit was hit.
  if (bucketCount(emailBuckets, email, now) >= MAX_LINKS_PER_EMAIL_PER_HOUR) {
    return res.status(429).json({
      error: "rate_limited",
      message: "Too many sign-in requests for this email. Try again in an hour.",
    });
  }
  if (ip && bucketCount(ipBuckets, ip, now) >= MAX_LINKS_PER_IP_PER_HOUR) {
    return res.status(429).json({
      error: "rate_limited",
      message: "Too many sign-in requests from this network. Try again in an hour.",
    });
  }

  pushBucket(emailBuckets, email, now);
  if (ip) pushBucket(ipBuckets, ip, now);

  // Determine intent: signin (existing user) vs signup (new email).
  // Both flows are identical to the server — the field is just for
  // analytics. We don't leak intent in the response.
  const existing = getDb()
    .prepare("SELECT id FROM users WHERE email = ?")
    .get(email);
  const intent = existing ? "signin" : "signup";

  // Capture the trial cookie at request-link time and store it
  // server-side alongside the token. At /auth/verify we'll use it
  // to link the trial → user even when the magic-link click lands
  // in a different browser / cookie jar than the one that requested
  // the link (the iOS Private mode + in-app email webview case).
  // We read it directly from req.headers.cookie because the trial
  // cookie middleware may or may not have populated req.trial
  // depending on path matching — being explicit here is safer.
  let trialCookieId = null;
  try {
    const parsed = cookie.parse(req.headers?.cookie || "");
    if (parsed[TRIAL_COOKIE]) trialCookieId = parsed[TRIAL_COOKIE];
  } catch {
    // cookie parse failures are non-fatal; fall through with null
  }

  // Issue + send via the shared helper. We deliberately don't
  // surface send failures back to the user — the standard advice for
  // magic-link auth is "always pretend we sent it" so attackers can't
  // probe which emails are configured. Operator sees the error in
  // logs and can investigate (usually SMTP creds wrong or Gmail
  // rate-limited).
  await sendSignInLink({
    email,
    intent,
    ip,
    userAgent: req.headers?.["user-agent"],
    trialCookieId,
  });

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

async function handleVerify(req, res) {
  const plaintext = String(req.query?.token || "").trim();
  if (!plaintext) {
    return res.status(400).send(renderErrorPage("Missing token."));
  }

  const tokenHash = sha256(plaintext);
  const now = Date.now();

  const tx = getDb().transaction(() => {
    const row = getDb()
      .prepare(
        "SELECT * FROM magic_link_tokens WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?",
      )
      .get(tokenHash, now);
    if (!row) return { error: "invalid_or_expired" };
    getDb()
      .prepare("UPDATE magic_link_tokens SET used_at = ? WHERE token_hash = ?")
      .run(now, tokenHash);
    return { row };
  });

  let result;
  try {
    result = tx();
  } catch (err) {
    console.error("[auth] verify tx failed:", err);
    return res.status(500).send(renderErrorPage("Internal error."));
  }
  if (result.error) {
    return res
      .status(400)
      .send(
        renderErrorPage(
          "This sign-in link has expired or already been used. Request a fresh one.",
        ),
      );
  }

  const email = result.row.email;
  const ip = getClientIp(req);
  const ua = clipUA(req.headers?.["user-agent"]);

  // Upsert user row.
  let user = getDb()
    .prepare("SELECT * FROM users WHERE email = ?")
    .get(email);
  if (!user) {
    const id = uuid();
    const syntheticInstallId = uuid();
    // First user ever = operator bootstrap. We could also gate this on
    // "is the install fresh (no users at all)" — which is what this
    // check does. Once at least one user exists, subsequent signups
    // are regular tenants with is_admin = 0.
    const userCountRow = getDb()
      .prepare("SELECT COUNT(*) AS n FROM users")
      .get();
    const isAdmin = (userCountRow?.n || 0) === 0 ? 1 : 0;
    getDb()
      .prepare(
        `INSERT INTO users
          (id, email, created_at, last_signin_at, synthetic_install_id, is_admin, signup_ip, signup_user_agent)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
      )
      .run(id, email, now, now, syntheticInstallId, isAdmin, ip || null, ua);
    user = getDb().prepare("SELECT * FROM users WHERE id = ?").get(id);

    // Seed tenant_credits for new NON-ADMIN users with the operator's
    // configured default. Goes into the REPLENISHABLE bucket so
    // setReplenishPeriod=daily/weekly/monthly refills it on schedule.
    // Admin users skip this entirely — their relay calls bill the
    // operator pool (via /data/license.txt), not a local balance.
    //
    // Pro/Max users (license attached at creation, e.g. via the
    // anon-signup-with-purchase flow) ALSO skip this — they spend
    // from their license-keyed relay pool, so a tenant_credits row
    // would just sit unused and confuse the admin Tenants view.
    if (!isAdmin && !user.keysat_license) {
      try {
        const { seedSignup } = await import("./tenant-credits.js");
        await seedSignup(id);
      } catch (err) {
        console.warn("[auth] tenant_credits seed failed:", err);
      }
    }

    if (isAdmin) {
      console.log(
        `[auth] First user signed up — ${email} bootstrapped as operator (is_admin=1)`,
      );
      // Legacy library at /data/history/owner/ stays where it is —
      // admin's scopeForRequest() returns "owner" regardless of mode,
      // so single-mode and multi-mode admin both read the same path.
      // No rename. Switching back to single mode keeps the operator's
      // library accessible at /data/history/owner/.
    }
  } else {
    getDb()
      .prepare("UPDATE users SET last_signin_at = ? WHERE id = ?")
      .run(now, user.id);
  }

  // Resolve which anon trial cookie to link. Two sources:
  //   1. result.row.trial_cookie_id — captured server-side at
  //      /auth/request-link time. Survives cross-browser / in-app-
  //      webview magic-link clicks because it doesn't depend on the
  //      verify request carrying the cookie.
  //   2. req.cookies[TRIAL_COOKIE] — the legacy path, used when the
  //      magic-link click lands in the SAME browser that did the
  //      anon activity (typical desktop / non-private-mode flow).
  //
  // Priority: token-row wins. It represents the explicit intent
  // captured at signup-request time and is the more reliable
  // signal. The req cookie is a fallback for old token rows from
  // before the trial_cookie_id column existed, and for any
  // edge-case where the request-link path didn't capture it.
  //
  // We also rename the trial's history folder (anon/<cookie_id>/) to
  // the user's id so the summary they ran before signing up shows
  // up in their library. Skipped for returning users (existing
  // account) — they already have a library and we can't safely
  // merge filesystem-side. The trial row still gets linked; the
  // anon/<cookie_id>/ folder just stays as an orphan.
  try {
    let trialCookieId = result.row.trial_cookie_id || null;
    if (!trialCookieId) {
      const cookies = cookie.parse(req.headers?.cookie || "");
      trialCookieId = cookies[TRIAL_COOKIE] || null;
    }
    if (trialCookieId) {
      await linkToUser(trialCookieId, user.id);
      if (user.created_at === user.last_signin_at) {
        try {
          await renameScopeDir(`anon/${trialCookieId}`, user.id);
        } catch (err) {
          console.warn(
            "[auth] trial→user scope rename failed:",
            err?.message || err,
          );
        }
      }
    }
  } catch {
    // best-effort; trial linking isn't on the critical signin path
  }

  // Issue session — shared with /auth/signin-password so cookie shape
  // + sessions-row format stay identical regardless of which auth
  // method got the user here.
  issueSession({ userId: user.id, req, res });
  res.redirect(302, "/");
}

// Shared session-issuance helper. Used by both /auth/verify (magic-link
// success path) and /auth/signin-password (password success path) so
// the cookie shape + sessions-row format stay identical.
function issueSession({ userId, req, res }) {
  const sessionId = randomBytes(32).toString("base64url");
  const now = Date.now();
  const expiresAt = now + SESSION_MAX_AGE_MS;
  const ua = clipUA(req.headers?.["user-agent"]);
  const ip = getClientIp(req);
  getDb()
    .prepare(
      `INSERT INTO sessions
        (id, user_id, created_at, expires_at, last_used_at, user_agent, ip_address)
       VALUES (?, ?, ?, ?, ?, ?, ?)`,
    )
    .run(sessionId, userId, now, expiresAt, now, ua, ip || null);
  const maxAgeSeconds = Math.floor(SESSION_MAX_AGE_MS / 1000);
  res.setHeader(
    "Set-Cookie",
    [
      `${SESSION_COOKIE}=${sessionId}`,
      `Max-Age=${maxAgeSeconds}`,
      "Path=/",
      "HttpOnly",
      "SameSite=Lax",
      "Secure",
    ].join("; "),
  );
  return sessionId;
}

// ── Internal sessions for the subscription background processor ──────────────
// Per-tenant subscriptions: the processor summarizes an approved auto-queue
// item by calling /api/process over loopback, which in multi mode is
// authenticated. So it mints a SHORT-LIVED real session for the item's
// owning user, sends it as the recap_session cookie, and deletes it right
// after. This reuses the real auth path — NOT a bypass: a bad/expired token
// just 401s and the item is marked failed, never an auth hole.
export function mintInternalSession(userId) {
  const sessionId = randomBytes(32).toString("base64url");
  const now = Date.now();
  getDb()
    .prepare(
      `INSERT INTO sessions
        (id, user_id, created_at, expires_at, last_used_at, user_agent, ip_address)
       VALUES (?, ?, ?, ?, ?, ?, ?)`,
    )
    .run(
      sessionId,
      userId,
      now,
      now + 30 * 60 * 1000, // 30 min is plenty for one summarize run
      now,
      "subscription-processor",
      "127.0.0.1",
    );
  return sessionId;
}

export function deleteInternalSession(sessionId) {
  if (!sessionId) return;
  try {
    getDb().prepare("DELETE FROM sessions WHERE id = ?").run(sessionId);
  } catch {}
}

// The operator/admin owns the "owner" scope in multi mode — resolve their
// user id so the processor can run owner-scoped items as the operator.
export function adminUserId() {
  try {
    const row = getDb()
      .prepare("SELECT id FROM users WHERE is_admin = 1 ORDER BY created_at LIMIT 1")
      .get();
    return row?.id || null;
  } catch {
    return null;
  }
}

// /auth/signin-password — accept { email, password }, verify, issue a
// session cookie. Used by the auth.html form when the user opts to
// type a password (faster than waiting on an email).
//
// We DELIBERATELY don't differentiate "no such email" vs "wrong
// password" in the error response — both return 401 with a generic
// message. This stops credential-stuffing tools from using us as an
// email-existence oracle.
//
// Rate limits: same email + IP buckets as /auth/request-link, so the
// password endpoint can't be brute-forced any faster than someone
// could spam magic-link emails.
async function handleSignInPassword(req, res) {
  const email = normalizeEmail(req.body?.email);
  const password = req.body?.password;
  if (!email || !isPlausibleEmail(email) || typeof password !== "string") {
    return res.status(401).json({
      error: "bad_credentials",
      message: "Email or password is wrong.",
    });
  }
  const ip = getClientIp(req);
  const now = Date.now();
  if (bucketCount(emailBuckets, email, now) >= MAX_LINKS_PER_EMAIL_PER_HOUR) {
    return res
      .status(429)
      .json({ error: "rate_limited", message: "Too many sign-in attempts." });
  }
  if (ip && bucketCount(ipBuckets, ip, now) >= MAX_LINKS_PER_IP_PER_HOUR) {
    return res
      .status(429)
      .json({ error: "rate_limited", message: "Too many sign-in attempts." });
  }
  pushBucket(emailBuckets, email, now);
  if (ip) pushBucket(ipBuckets, ip, now);

  let user;
  try {
    user = getDb()
      .prepare("SELECT * FROM users WHERE email = ?")
      .get(email);
  } catch (err) {
    console.error("[auth] signin-password lookup failed:", err);
    return res.status(500).json({ error: "internal_error" });
  }
  // Run scrypt even when the user doesn't exist so timing doesn't
  // betray which emails are registered. The dummy verify spends the
  // same ~50ms scrypt-or-so as the real path.
  const stored = user?.password_hash || `${SCRYPT_PREFIX}0000$0000`;
  const ok = verifyPassword(password, stored);
  if (!user || !user.password_hash || !ok) {
    return res
      .status(401)
      .json({ error: "bad_credentials", message: "Email or password is wrong." });
  }
  // Authenticated. Update last_signin_at and issue a session.
  getDb()
    .prepare("UPDATE users SET last_signin_at = ? WHERE id = ?")
    .run(now, user.id);
  issueSession({ userId: user.id, req, res });
  res.json({ ok: true });
}

async function handleSignout(req, res) {
  try {
    const cookies = cookie.parse(req.headers?.cookie || "");
    const sessionId = cookies[SESSION_COOKIE];
    if (sessionId) {
      getDb()
        .prepare("DELETE FROM sessions WHERE id = ?")
        .run(sessionId);
    }
  } catch {}
  // Expire the cookie immediately.
  res.setHeader(
    "Set-Cookie",
    `${SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax; Secure`,
  );
  // Honor JSON callers (UI fetch) vs link callers (form submit).
  if (req.accepts?.("json") && !req.accepts?.("html")) {
    return res.status(204).end();
  }
  res.redirect(302, "/");
}

// Self-contained branded error page used when /auth/verify fails.
// Doesn't pull in the full app shell so it works even if the static
// bundle is broken or partially served. Visual style matches /auth.html
// (same dark-glass card, same primary-button accent) so the user feels
// they're still in the same product flow.
function renderErrorPage(message) {
  const safe = String(message)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;");
  return `<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
  <title>Sign-in failed · Recaps</title>
  <link rel="icon" type="image/png" href="/assets/icon.png">
  <meta name="theme-color" content="#0a0e1a">
  <style>
    *{box-sizing:border-box;margin:0;padding:0}
    html,body{height:100%}
    body{
      font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
      background:#0a0e1a;color:#e2e8f0;
      display:flex;align-items:center;justify-content:center;padding:24px;
    }
    .card{
      width:100%;max-width:420px;background:#121828;
      border:1px solid #1f2942;border-radius:12px;padding:32px 28px;
    }
    .logo{display:flex;align-items:center;gap:12px;margin-bottom:24px}
    .logo img{width:32px;height:32px;border-radius:6px}
    .logo span{font-size:18px;font-weight:600;color:#f5f9ff}
    h1{font-size:20px;font-weight:600;color:#f5f9ff;margin-bottom:10px}
    p.msg{font-size:14px;line-height:1.55;color:#cbd5e1;margin-bottom:22px}
    .btn-row{display:flex;gap:8px;flex-wrap:wrap}
    a.btn-primary{
      display:inline-block;background:#3b82f6;color:#fff;text-decoration:none;
      font-size:14px;font-weight:600;padding:10px 18px;border-radius:8px;
    }
    a.btn-primary:hover{background:#2563eb}
    a.btn-secondary{
      display:inline-block;background:transparent;color:#94a3b8;text-decoration:none;
      font-size:14px;font-weight:500;padding:10px 14px;border-radius:8px;border:1px solid #334155;
    }
    a.btn-secondary:hover{color:#cbd5e1;border-color:#475569}
  </style>
</head>
<body>
  <div class="card">
    <div class="logo">
      <img src="/assets/icon.png" alt="Recaps" onerror="this.style.display='none'">
      <span>Recaps</span>
    </div>
    <h1>Sign-in didn't work</h1>
    <p class="msg">${safe}</p>
    <div class="btn-row">
      <a href="/auth.html" class="btn-primary">Get a new sign-in link</a>
      <a href="/" class="btn-secondary">Back to Recaps</a>
    </div>
  </div>
</body>
</html>`;
}

// sendSignInLink({ email, intent, ip?, userAgent?, emailBody? }) —
// reusable magic-link issuance + email send. Used by:
//   • /auth/request-link              — visitor-initiated sign-in
//   • license-purchase poll-settle    — system-initiated post-purchase
//                                       "your account is ready" send
//
// Generates a 32-byte token, hashes it, stores the hash in
// magic_link_tokens, builds a verifyUrl, sends the email with either
// the default magic-link body OR a caller-supplied (subject, text,
// html) tuple for custom flows. Returns { ok: true, expires_at } on
// success; { ok: false, error, message? } on failure.
//
// Doesn't enforce rate limits — that's the caller's job. /auth/request-link
// has the per-email + per-IP buckets; the post-purchase path is
// inherently rate-limited by the actual payment, so no extra bucket
// needed.
export async function sendSignInLink({
  email,
  intent = "signin",
  ip = null,
  userAgent = "",
  emailBody = null,
  trialCookieId = null,
}) {
  if (!email || !isPlausibleEmail(email)) {
    return { ok: false, error: "bad_email" };
  }
  const publicUrl = await getPublicUrl();
  if (!publicUrl) {
    return { ok: false, error: "public_url_not_set" };
  }
  if (!isSmtpReady()) {
    return { ok: false, error: "smtp_not_ready" };
  }
  const now = Date.now();
  const plaintext = randomBytes(32).toString("base64url");
  const tokenHash = sha256(plaintext);
  const expiresAt = now + MAGIC_LINK_TTL_MS;
  try {
    getDb()
      .prepare(
        `INSERT INTO magic_link_tokens
          (token_hash, email, created_at, expires_at, intent, request_ip, request_ua, trial_cookie_id)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
      )
      .run(
        tokenHash,
        email,
        now,
        expiresAt,
        intent,
        ip || null,
        clipUA(userAgent),
        trialCookieId || null,
      );
  } catch (err) {
    console.error("[auth] sendSignInLink insert failed:", err);
    return { ok: false, error: "internal_error" };
  }
  const verifyUrl = `${publicUrl}/auth/verify?token=${encodeURIComponent(plaintext)}`;
  // Default to the standard sign-in email body; callers can override
  // either with a pre-built {subject,text,html} object OR a function
  // that receives the verifyUrl and returns that shape. The function
  // form is what license-purchase uses to inject the celebratory
  // "your Pro account is ready" copy with the verifyUrl pre-rendered
  // into the body.
  let message;
  if (typeof emailBody === "function") {
    message = emailBody(verifyUrl);
  } else if (emailBody && typeof emailBody === "object") {
    message = emailBody;
  } else {
    message = renderMagicLinkEmail({
      verifyUrl,
      brandName: "Recaps",
      expiresInMinutes: 15,
    });
  }
  try {
    await sendMail({
      to: email,
      subject: message.subject,
      text: message.text,
      html: message.html,
    });
  } catch (err) {
    console.error(
      "[auth] sendSignInLink sendMail failed for",
      email,
      ":",
      err?.message || err,
    );
    // The token is already inserted; if the operator's SMTP is flaky
    // the user can re-request a link. Return error so the caller can
    // decide whether to surface it or swallow.
    return { ok: false, error: "send_failed" };
  }
  return { ok: true, expires_at: expiresAt };
}

// setupAuthRoutes(app) — registers /auth/* endpoints. Multi-mode only;
// wired in server/index.js behind the RECAP_MODE === 'multi' branch.
//
// Magic-link is the primary auth surface:
//   POST /auth/request-link         — issue a magic link by email
//   GET  /auth/verify?token=...     — consume the link, issue session
//   POST /auth/signout              — drop the session
//   GET  /auth/signout              — same (link-click convenience)
//
// Password endpoints are the optional faster-signin add-on:
//   POST /auth/set-password         — set OR overwrite my password
//   POST /auth/clear-password       — remove my password (magic-link only)
//   POST /auth/signin-password      — sign in with email + password
//
// Note there is no /auth/reset-password endpoint by design — reset is
// implemented as "request a magic link, sign in, then call
// /auth/set-password with the new one." Adding a dedicated reset
// endpoint would duplicate the magic-link flow without adding any
// security or UX.
export function setupAuthRoutes(app) {
  app.post("/auth/request-link", (req, res) => {
    handleRequestLink(req, res).catch((err) => {
      console.error("[auth] /auth/request-link unhandled:", err);
      res.status(500).json({ error: "internal_error" });
    });
  });

  app.get("/auth/verify", (req, res) => {
    handleVerify(req, res).catch((err) => {
      console.error("[auth] /auth/verify unhandled:", err);
      res.status(500).send(renderErrorPage("Internal error."));
    });
  });

  app.post("/auth/signin-password", (req, res) => {
    handleSignInPassword(req, res).catch((err) => {
      console.error("[auth] /auth/signin-password unhandled:", err);
      res.status(500).json({ error: "internal_error" });
    });
  });

  // Note: /api/account/password (set + clear) is registered by
  // account-routes.js, not here — those endpoints REQUIRE an existing
  // session, so they live outside the /auth/* public-path namespace
  // (which is allowed through the tenant-auth middleware unauthenticated).

  app.post("/auth/signout", (req, res) => {
    handleSignout(req, res).catch((err) => {
      console.error("[auth] /auth/signout unhandled:", err);
      res.status(500).json({ error: "internal_error" });
    });
  });

  // Convenience GET version for plain link clicks ("Sign out") from
  // the UI without needing a form POST.
  app.get("/auth/signout", (req, res) => {
    handleSignout(req, res).catch((err) => {
      console.error("[auth] /auth/signout unhandled:", err);
      res.status(500).send(renderErrorPage("Internal error."));
    });
  });
}
