Source: routes/auth.js

/**
 * @module routes/auth
 * @description Authentication routes for email/password and OAuth (GitHub, Google).
 *
 * ### Endpoints (INF-005: all under `/api/v1/auth/`)
 * | Method | Path                              | Description                          |
 * |--------|-----------------------------------|--------------------------------------|
 * | POST   | `/api/v1/auth/register`           | Email/password registration          |
 * | POST   | `/api/v1/auth/login`              | Email/password sign-in               |
 * | POST   | `/api/v1/auth/logout`             | Token revocation + cookie clear      |
 * | POST   | `/api/v1/auth/refresh`            | Refresh session (extend cookie TTL)  |
 * | GET    | `/api/v1/auth/me`                 | Return current user from cookie      |
 * | GET    | `/api/v1/auth/export`             | Export user-owned account data (SEC-003) |
 * | DELETE | `/api/v1/auth/account`            | Delete account + owned data (SEC-003) |
 * | GET    | `/api/v1/auth/verify`             | Verify email via token (SEC-001)     |
 * | POST   | `/api/v1/auth/resend-verification`| Resend verification email (SEC-001)  |
 * | POST   | `/api/v1/auth/forgot-password`    | Request a password reset token       |
 * | POST   | `/api/v1/auth/reset-password`     | Reset password using a valid token   |
 * | GET    | `/api/v1/auth/github/callback`    | GitHub OAuth token exchange          |
 * | GET    | `/api/v1/auth/google/callback`    | Google OAuth token exchange          |
 *
 * ### Security measures
 * - Passwords hashed with scrypt (64-byte key, 16-byte random salt)
 * - JWT stored in HttpOnly; Secure; SameSite=Strict cookie — never in localStorage
 * - A companion `token_exp` cookie (Non-HttpOnly) exposes only the exp timestamp
 *   so the frontend can proactively warn before expiry without ever touching the JWT
 * - JWT signed with HS256, 8-hour expiry
 * - Rate limiting: separate per-endpoint buckets (login: 10, forgot/reset: 5 per IP per 15 min)
 * - Revoked tokens kept in an in-memory Map (production: use Redis — see ENH-002)
 * - Password reset tokens persisted in DB table `password_reset_tokens` (migration 003)
 * - Input validation and sanitisation on every endpoint
 * - OAuth state parameter validated on the frontend to prevent CSRF
 * - CSRF double-submit cookie protection on all mutating endpoints (via appSetup.js)
 * - No sensitive data (passwords, raw OAuth tokens, JWT strings) returned to client
 */

import express from "express";
import crypto from "crypto";
import * as userRepo from "../database/repositories/userRepo.js";
import * as resetTokenRepo from "../database/repositories/passwordResetTokenRepo.js";
import * as verificationTokenRepo from "../database/repositories/verificationTokenRepo.js";
import * as workspaceRepo from "../database/repositories/workspaceRepo.js";
import * as accountRepo from "../database/repositories/accountRepo.js";
import * as projectRepo from "../database/repositories/projectRepo.js";
import { formatLogLine } from "../utils/logFormatter.js";
import { stopSchedule } from "../scheduler.js";
import { sendVerificationEmail } from "../utils/emailSender.js";
import { buildJwtPayload, buildUserResponse } from "../utils/authWorkspace.js";
import { cookieSameSite } from "../middleware/appSetup.js";
import {
  signJwt, getJwtSecret, revokedTokens,
  requireUser, AUTH_COOKIE,
} from "../middleware/authenticate.js";

/**
 * Backward-compatible alias.  All files that do
 *   `import { requireAuth } from "./routes/auth.js"`
 * continue to work — `requireUser` is the same JWT cookie → bearer → query
 * middleware that `requireAuth` used to be.
 */
export const requireAuth = requireUser;

// ─── Cookie helpers ───────────────────────────────────────────────────────────

/** Expiry hint cookie — Non-HttpOnly so the frontend can read the `exp` timestamp. */
export const EXP_COOKIE      = "token_exp";
/** JWT TTL in seconds (8 hours). Must match signJwt default. */
export const JWT_TTL_SEC     = 8 * 60 * 60;

/**
 * Set the HttpOnly auth cookie + a readable expiry hint cookie on a response.
 * Called after every successful authentication (login, OAuth, refresh, workspace switch).
 *
 * @param {Object} res       - Express response object.
 * @param {string} token     - The signed JWT string.
 * @param {number} expSec    - Unix timestamp of token expiry (seconds).
 */
export function setAuthCookie(res, token, expSec) {
  const maxAge  = JWT_TTL_SEC;
  const sameSite = cookieSameSite();

  // Use appendHeader so we don't overwrite the _csrf cookie that the
  // CSRF middleware may have already queued on this response via setHeader.
  // Primary cookie: HttpOnly prevents JS from ever reading the JWT.
  // SameSite policy is determined by cookieSameSite() — Strict for same-origin,
  // None; Secure for cross-origin (GitHub Pages + Render).
  res.appendHeader("Set-Cookie",
    `${AUTH_COOKIE}=${token}; Path=/; HttpOnly; Max-Age=${maxAge}${sameSite}`
  );
  // Expiry hint: NOT HttpOnly — frontend reads it for proactive expiry UX.
  // Contains only the numeric exp timestamp, not the token.
  res.appendHeader("Set-Cookie",
    `${EXP_COOKIE}=${expSec}; Path=/; Max-Age=${maxAge}${sameSite}`
  );
}

/**
 * Clear both auth cookies, effectively logging the user out client-side.
 * @param {Object} res - Express response object.
 */
function clearAuthCookies(res) {
  const sameSite = cookieSameSite();
  res.appendHeader("Set-Cookie", `${AUTH_COOKIE}=; Path=/; HttpOnly; Max-Age=0${sameSite}`);
  res.appendHeader("Set-Cookie", `${EXP_COOKIE}=; Path=/; Max-Age=0${sameSite}`);
}


const router = express.Router();

// ─── Helpers ─────────────────────────────────────────────────────────────────

/**
 * Hash a password using Node.js scrypt (no native addon needed).
 * Generates a 16-byte random salt and derives a 64-byte key.
 *
 * @param   {string} password - The plaintext password to hash.
 * @returns {Promise<string>}   Format: `"<hex-salt>:<hex-derived-key>"`.
 * @private
 */
async function hashPassword(password) {
  const salt = crypto.randomBytes(16).toString("hex");
  const derived = await new Promise((res, rej) =>
    crypto.scrypt(password, salt, 64, (err, key) => (err ? rej(err) : res(key)))
  );
  return `${salt}:${derived.toString("hex")}`;
}

/**
 * Verify a plaintext password against a stored hash.
 * Uses constant-time comparison to prevent timing attacks.
 *
 * @param   {string}  password - The plaintext password to verify.
 * @param   {string}  stored   - The stored hash in `"<salt>:<key>"` format.
 * @returns {Promise<boolean>}   `true` if the password matches.
 * @private
 */
async function verifyPassword(password, stored) {
  const [salt, hash] = stored.split(":");
  const derived = await new Promise((res, rej) =>
    crypto.scrypt(password, salt, 64, (err, key) => (err ? rej(err) : res(key)))
  );
  // Constant-time comparison to prevent timing attacks
  return crypto.timingSafeEqual(Buffer.from(hash, "hex"), derived);
}

// signJwt, verifyJwt, getJwtSecret, revokedTokens — imported from
// middleware/authenticate.js above.  No duplicated implementations here.

// Password reset tokens are stored in the `password_reset_tokens` DB table
// (migration 003). The token TTL is enforced by the `expiresAt` column.
const RESET_TOKEN_TTL_MS = 30 * 60 * 1000; // 30 minutes

// Email verification tokens (SEC-001, migration 003).
// 24-hour TTL gives users plenty of time to check their inbox.
const VERIFICATION_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours

// Rate limiters — separate buckets per endpoint category so flooding
// one endpoint (e.g. forgot-password) doesn't lock out login.
const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes

const rateBuckets = {
  login:         { map: new Map(), max: 10 },  // 10 login attempts per IP per 15 min
  forgotPassword:{ map: new Map(), max: 5 },   // 5 reset requests per IP per 15 min
  resetPassword: { map: new Map(), max: 5 },   // 5 reset attempts per IP per 15 min
};

/**
 * Check rate limit for a specific bucket.
 * @param {string} bucket — key in rateBuckets (e.g. "login", "forgotPassword")
 * @param {string} ip
 * @returns {{ allowed: boolean, retryAfterSec: number }}
 */
function checkRateLimit(bucket, ip) {
  const { map, max } = rateBuckets[bucket];
  const now = Date.now();
  const entry = map.get(ip);
  if (!entry || entry.resetAt < now) {
    map.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
    return { allowed: true };
  }
  if (entry.count >= max) {
    const retryAfterSec = Math.ceil((entry.resetAt - now) / 1000);
    return { allowed: false, retryAfterSec };
  }
  entry.count++;
  return { allowed: true };
}

// Purge expired DB tokens periodically (reset + verification).
// In-memory revoked JWT purging is handled by middleware/authenticate.js.
// .unref() prevents this timer from keeping the process alive during tests.
const _purgeInterval = setInterval(() => {
  try {
    resetTokenRepo.deleteExpired();
  } catch (err) { console.error(formatLogLine("error", null, `[auth/purge] Failed to delete expired reset tokens: ${err.message}`)); }
  try {
    verificationTokenRepo.deleteExpired();
  } catch (err) { console.error(formatLogLine("error", null, `[auth/purge] Failed to delete expired verification tokens: ${err.message}`)); }
}, 60 * 60 * 1000);
_purgeInterval.unref();

// requireAuth is exported above as an alias for requireUser from
// middleware/authenticate.js — see the import block at the top of this file.

// ─── Validation helpers ───────────────────────────────────────────────────────

/**
 * Validate an email address format.
 * @param   {string}  email - The email to validate.
 * @returns {boolean}         `true` if the format is valid.
 * @private
 */
function isValidEmail(email) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email);
}
/**
 * Trim and truncate a string to prevent oversized input.
 * Returns empty string for non-string values.
 *
 * @param   {*}      str           - The value to sanitise.
 * @param   {number} [maxLen=200]  - Maximum allowed length.
 * @returns {string}                 The sanitised string.
 * @private
 */
function sanitiseString(str, maxLen = 200) {
  return typeof str === "string" ? str.trim().slice(0, maxLen) : "";
}

// ─── Password strength validation (GAP-02) ───────────────────────────────────
// Enforces complexity beyond a minimum length: at least one uppercase letter,
// one lowercase letter, one digit, and one special character.  Also rejects the
// 20 most common passwords that pass the character-class checks.

const COMMON_PASSWORDS = new Set([
  "password", "12345678", "123456789", "1234567890", "qwerty123",
  "password1", "iloveyou", "sunshine1", "princess1", "football1",
  "charlie1", "access14", "trustno1", "passw0rd", "master123",
  "welcome1", "monkey123", "dragon12", "letmein1", "abc12345",
]);

/**
 * Validate password strength.
 * @param   {string} password
 * @returns {string|null} Error message, or null if valid.
 */
function validatePasswordStrength(password) {
  if (typeof password !== "string" || password.length < 8) {
    return "Password must be at least 8 characters.";
  }
  if (password.length > 128) {
    return "Password is too long.";
  }
  if (!/[A-Z]/.test(password)) {
    return "Password must contain at least one uppercase letter.";
  }
  if (!/[a-z]/.test(password)) {
    return "Password must contain at least one lowercase letter.";
  }
  if (!/[0-9]/.test(password)) {
    return "Password must contain at least one digit.";
  }
  if (!/[^A-Za-z0-9]/.test(password)) {
    return "Password must contain at least one special character.";
  }
  if (COMMON_PASSWORDS.has(password.toLowerCase())) {
    return "This password is too common. Please choose a stronger one.";
  }
  return null;
}

/**
 * Ensure the user belongs to at least one workspace, creating a personal
 * workspace if needed.  Called from every auth path (login, OAuth) so no
 * user can end up without a workspace.
 *
 * @param {Object} user — User row from the database.
 */
function ensureUserWorkspace(user) {
  const existing = workspaceRepo.getByUserId(user.id);
  if (existing && existing.length > 0) return;
  const slug = `${(user.name || "user").toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${crypto.randomBytes(3).toString("hex")}`;
  workspaceRepo.create({ name: `${user.name || "My"}'s Workspace`, slug, ownerId: user.id });
}

// ─── Routes ──────────────────────────────────────────────────────────────────

/**
 * Register a new user with email and password.
 *
 * @route POST /api/v1/auth/register
 * @param {Object} req.body
 * @param {string} req.body.name     - Full name (max 100 chars).
 * @param {string} req.body.email    - Email address (max 254 chars).
 * @param {string} req.body.password - Password (8–128 chars).
 * @returns {201} `{ message }` on success.
 * @returns {400} Validation error.
 * @returns {409} Email already exists.
 */
router.post("/register", async (req, res) => {
  try {
    const name     = sanitiseString(req.body.name, 100);
    const email    = sanitiseString(req.body.email, 254).toLowerCase();
    const password = req.body.password;

    if (!name)                       return res.status(400).json({ error: "Name is required." });
    if (!isValidEmail(email))        return res.status(400).json({ error: "A valid email address is required." });
    const pwErr = validatePasswordStrength(password);
    if (pwErr)                       return res.status(400).json({ error: pwErr });

    const existing = userRepo.getByEmail(email);
    if (existing) {
      return res.status(409).json({ error: "An account with this email already exists." });
    }

    const id           = crypto.randomUUID();
    const passwordHash = await hashPassword(password);
    const now          = new Date().toISOString();

    // SEC-001: New users are created with emailVerified = 0 (false).
    // They must verify their email before they can log in.
    // SKIP_EMAIL_VERIFICATION=true bypasses this for dev/CI environments.
    const skipVerification = process.env.SKIP_EMAIL_VERIFICATION === "true";

    // Set emailVerified atomically at creation time — no separate UPDATE needed.
    // This prevents a window where the user exists with emailVerified=1 if the
    // update were to fail after the INSERT.
    const user = { id, name, email, passwordHash, role: "user", emailVerified: skipVerification ? 1 : 0, createdAt: now, updatedAt: now };
    userRepo.create(user);

    if (skipVerification) {
      // Auto-verify: dev/CI mode — no email required
      return res.status(201).json({ message: "Account created successfully." });
    }

    // Generate and send verification email
    const verifyToken = crypto.randomBytes(32).toString("base64url");
    const tokenExpiresAt = new Date(Date.now() + VERIFICATION_TOKEN_TTL_MS).toISOString();
    try {
      verificationTokenRepo.create(verifyToken, id, email, tokenExpiresAt);
      await sendVerificationEmail(email, verifyToken, name);
    } catch (emailErr) {
      // Non-fatal: account is created, user can request a resend.
      console.error(formatLogLine("error", null, `[auth/register] Failed to send verification email: ${emailErr.message}`));
    }

    return res.status(201).json({
      message: "Account created. Please check your email to verify your account.",
      requiresVerification: true,
    });
  } catch (err) {
    console.error(formatLogLine("error", null, `[auth/register] ${err.message}`));
    return res.status(500).json({ error: "Registration failed. Please try again." });
  }
});

/**
 * Sign in with email and password. Sets auth cookies and returns user profile.
 * Rate-limited to 10 attempts per IP per 15 minutes.
 *
 * @route POST /api/v1/auth/login
 * @param {Object} req.body
 * @param {string} req.body.email    - Email address.
 * @param {string} req.body.password - Password.
 * @returns {200} `{ user: { id, name, email, role, avatar, workspaceId, workspaceName, workspaceRole } }`.
 * @returns {400} Invalid input.
 * @returns {401} Wrong credentials.
 * @returns {403} Email not verified.
 * @returns {429} Rate limit exceeded (`Retry-After` header set).
 */
router.post("/login", async (req, res) => {
  const ip = req.ip || "unknown";
  const rate = checkRateLimit("login", ip);
  if (!rate.allowed) {
    res.setHeader("Retry-After", rate.retryAfterSec);
    return res.status(429).json({ error: `Too many sign-in attempts. Try again in ${Math.ceil(rate.retryAfterSec / 60)} minutes.` });
  }

  try {
    const email    = sanitiseString(req.body.email, 254).toLowerCase();
    const password = req.body.password;

    if (!isValidEmail(email) || typeof password !== "string") {
      return res.status(400).json({ error: "Invalid email or password." });
    }

    const user = userRepo.getByEmail(email);

    // Always run verifyPassword (even on non-existent user) to prevent timing attacks
    const dummyHash = "00000000000000000000000000000000:0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
    const valid = user?.passwordHash ? await verifyPassword(password, user.passwordHash) : await verifyPassword(password, dummyHash).catch(() => false);

    if (!user || !valid) {
      return res.status(401).json({ error: "Invalid email or password." });
    }

    // SEC-001: Block login for unverified email accounts.
    // emailVerified defaults to 1 for existing/OAuth users (migration 003),
    // so only new email/password registrations are affected.
    if (user.emailVerified === 0) {
      return res.status(403).json({
        error: "Please verify your email address before signing in.",
        code: "EMAIL_NOT_VERIFIED",
        email: user.email,
      });
    }

    // ACL-001: Ensure the user has a workspace before issuing a token.
    ensureUserWorkspace(user);

    const payload = buildJwtPayload(user);
    const token = signJwt(payload, getJwtSecret());
    const exp   = Math.floor(Date.now() / 1000) + JWT_TTL_SEC;

    setAuthCookie(res, token, exp);

    // Note: token is NOT returned in the response body — it lives in the HttpOnly
    // cookie only. The frontend reads user profile from this response and stores
    // it in React state. The token_exp cookie exposes the expiry timestamp.
    return res.json({ user: buildUserResponse(user) });
  } catch (err) {
    console.error(formatLogLine("error", null, `[auth/login] ${err.message}`));
    return res.status(500).json({ error: "Sign-in failed. Please try again." });
  }
});

/**
 * Sign out — revokes the JWT server-side so it can't be reused.
 * Requires `Authorization: Bearer <token>`.
 *
 * @route POST /api/v1/auth/logout
 * @returns {200} `{ message: "Signed out successfully." }`.
 * @returns {401} Missing or invalid token.
 */
router.post("/logout", requireAuth, (req, res) => {
  const { jti, exp } = req.authUser;
  if (jti) revokedTokens.set(jti, exp);
  clearAuthCookies(res);
  return res.json({ message: "Signed out successfully." });
});

/**
 * Get the currently authenticated user's profile with workspace context.
 *
 * @route GET /api/v1/auth/me
 * @returns {200} `{ id, name, email, role, avatar, createdAt, workspaceId, workspaceName, workspaceRole }`.
 * @returns {401} Missing or invalid token.
 * @returns {404} User not found in database.
 */
router.get("/me", requireAuth, (req, res) => {
  const user = userRepo.getById(req.authUser.sub);
  if (!user) return res.status(404).json({ error: "User not found." });
  return res.json({ ...buildUserResponse(user, req.authUser.workspaceId), createdAt: user.createdAt });
});

// ─── Account export / deletion (SEC-003) ─────────────────────────────────────

/**
 * Check whether a user registered via OAuth only (no password set).
 *
 * @param {Object} user
 * @returns {boolean}
 */
function isOAuthOnlyUser(user) {
  return !user?.passwordHash;
}

/**
 * Verify the authenticated user's password for sensitive account actions.
 * For OAuth-only users (no passwordHash), password verification is skipped
 * — the OAuth session itself serves as proof of identity.
 *
 * @param {Object} user
 * @param {string} password
 * @returns {Promise<boolean>}
 */
async function verifyAccountPassword(user, password) {
  if (isOAuthOnlyUser(user)) return true;
  if (typeof password !== "string" || !password) return false;
  return verifyPassword(password, user.passwordHash);
}

/**
 * Export all user-owned account data as JSON (GDPR/CCPA data portability).
 * Requires password confirmation via `x-account-password` header.
 *
 * @route GET /api/v1/auth/export
 * @returns {200} JSON export payload.
 * @returns {400} Missing password.
 * @returns {403} Invalid password.
 */
router.get("/export", requireAuth, async (req, res) => {
  const user = userRepo.getById(req.authUser.sub);
  if (!user) return res.status(404).json({ error: "User not found." });

  // OAuth-only users have no password — skip confirmation (session is proof).
  if (!isOAuthOnlyUser(user)) {
    const password = req.headers["x-account-password"];
    if (typeof password !== "string" || !password) {
      return res.status(400).json({ error: "Password confirmation is required." });
    }
    const valid = await verifyAccountPassword(user, password);
    if (!valid) {
      return res.status(403).json({ error: "Password confirmation failed." });
    }
  }

  const data = accountRepo.buildAccountExport(user.id);
  return res.json(data);
});

/**
 * Delete account and all user-owned workspace data (GDPR right to erasure).
 * Requires password confirmation.
 *
 * @route DELETE /api/v1/auth/account
 * @param {Object} req.body
 * @param {string} req.body.password - Account password confirmation.
 * @returns {200} `{ ok: true }` on success.
 */
router.delete("/account", requireAuth, async (req, res) => {
  const user = userRepo.getById(req.authUser.sub);
  if (!user) return res.status(404).json({ error: "User not found." });

  // OAuth-only users have no password — skip confirmation (session is proof).
  if (!isOAuthOnlyUser(user)) {
    const password = req.body?.password;
    if (typeof password !== "string" || !password) {
      return res.status(400).json({ error: "Password confirmation is required." });
    }
    const valid = await verifyAccountPassword(user, password);
    if (!valid) {
      return res.status(403).json({ error: "Password confirmation failed." });
    }
  }

  // Collect owned project IDs before deletion so we can stop their in-memory
  // cron tasks afterwards. deleteAccount() removes the DB rows but cannot
  // reach the process-local scheduler task Map.
  const ownedWsIds = workspaceRepo.getOwnedWorkspaceIds(user.id);
  const ownedProjectIds = ownedWsIds.flatMap(wsId => projectRepo.getAllIncludeDeleted(wsId).map(p => p.id));

  try {
    accountRepo.deleteAccount(user.id);
  } catch (err) {
    console.error(formatLogLine("error", null, `[auth/account] Delete failed for user ${user.id}: ${err.message}`));
    return res.status(500).json({ error: "Failed to delete account." });
  }

  // Stop in-memory cron tasks for deleted projects (no-op if no schedule existed).
  for (const projectId of ownedProjectIds) {
    stopSchedule(projectId);
  }

  // Revoke the current JWT so it cannot be reused from another tab or replay.
  const { jti, exp } = req.authUser;
  if (jti) revokedTokens.set(jti, exp);
  clearAuthCookies(res);
  return res.json({ ok: true, message: "Account and owned data deleted." });
});

/**
 * Refresh the session — issue a new JWT and reset the cookie TTL.
 * Called proactively by the frontend 5 minutes before expiry so users
 * never get silently logged out mid-session.
 *
 * The existing token must still be valid (not expired, not revoked).
 * The old JTI is revoked and a new one is issued.
 *
 * @route POST /api/v1/auth/refresh
 * @returns {200} `{ user }` — same shape as login response.
 * @returns {401} If the current session is invalid or expired.
 */
router.post("/refresh", requireAuth, (req, res) => {
  const user = userRepo.getById(req.authUser.sub);
  if (!user) return res.status(401).json({ error: "User not found." });

  // Revoke the old token
  const { jti: oldJti, exp: oldExp } = req.authUser;
  if (oldJti) revokedTokens.set(oldJti, oldExp);

  // Issue a fresh token with a new JTI (includes updated workspace context)
  const payload = buildJwtPayload(user, req.authUser.workspaceId);
  const token = signJwt(payload, getJwtSecret());
  const exp   = Math.floor(Date.now() / 1000) + JWT_TTL_SEC;
  setAuthCookie(res, token, exp);

  return res.json({ user: buildUserResponse(user, req.authUser.workspaceId) });
});

// ─── Email Verification (SEC-001) ────────────────────────────────────────────

/**
 * Verify a user's email address using a signed token.
 * Called when the user clicks the verification link in their email.
 *
 * @route GET /api/v1/auth/verify
 * @param {string} req.query.token - The verification token from the email.
 * @returns {200} `{ message, verified: true }` on success.
 * @returns {400} Invalid, expired, or already-used token.
 */
router.get("/verify", async (req, res) => {
  const { token } = req.query;

  if (!token || typeof token !== "string") {
    return res.status(400).json({ error: "Verification token is required." });
  }

  let entry;
  try {
    entry = verificationTokenRepo.claim(token);
  } catch (dbErr) {
    console.error(formatLogLine("error", null, `[auth/verify] DB error: ${dbErr.message}`));
    return res.status(500).json({ error: "Server error. Please try again." });
  }

  if (!entry) {
    return res.status(400).json({ error: "Invalid or expired verification link. Please request a new one." });
  }

  const user = userRepo.getById(entry.userId);
  if (!user) {
    return res.status(400).json({ error: "Account not found." });
  }

  // Mark user as verified
  userRepo.update(user.id, { emailVerified: 1, updatedAt: new Date().toISOString() });

  // Clean up any remaining unused tokens for this user
  try {
    verificationTokenRepo.deleteUnusedByUserId(entry.userId);
  } catch (dbErr) {
    console.error(formatLogLine("error", null, `[auth/verify] Token cleanup error: ${dbErr.message}`));
  }

  if (process.env.NODE_ENV !== "production") {
    console.log(formatLogLine("info", null, `[auth/verify] Email verified for ${user.email}`));
  }

  return res.json({ message: "Email verified successfully. You can now sign in.", verified: true });
});

/**
 * Resend the verification email for an unverified account.
 * Rate-limited to prevent abuse.
 *
 * @route POST /api/v1/auth/resend-verification
 * @param {Object} req.body
 * @param {string} req.body.email - Email address of the unverified account.
 * @returns {200} `{ message }` — always returns success to prevent enumeration.
 */
router.post("/resend-verification", async (req, res) => {
  const ip = req.ip || "unknown";
  const rate = checkRateLimit("forgotPassword", ip); // reuse the same bucket
  if (!rate.allowed) {
    res.setHeader("Retry-After", rate.retryAfterSec);
    return res.status(429).json({ error: `Too many requests. Try again in ${Math.ceil(rate.retryAfterSec / 60)} minutes.` });
  }

  const email = sanitiseString(req.body.email, 254).toLowerCase();
  if (!isValidEmail(email)) {
    return res.status(400).json({ error: "A valid email address is required." });
  }

  const genericMsg = "If an unverified account with that email exists, a verification link has been sent.";

  const user = userRepo.getByEmail(email);
  if (!user || user.emailVerified !== 0) {
    // No account or already verified — silently succeed to prevent enumeration
    return res.json({ message: genericMsg });
  }

  const verifyToken = crypto.randomBytes(32).toString("base64url");
  const tokenExpiresAt = new Date(Date.now() + VERIFICATION_TOKEN_TTL_MS).toISOString();
  try {
    verificationTokenRepo.create(verifyToken, user.id, email, tokenExpiresAt);
    await sendVerificationEmail(email, verifyToken, user.name);
  } catch (emailErr) {
    console.error(formatLogLine("error", null, `[auth/resend-verification] Failed to send: ${emailErr.message}`));
    return res.status(500).json({ error: "Failed to send verification email. Please try again." });
  }

  return res.json({ message: genericMsg });
});

// ─── Password Reset ──────────────────────────────────────────────────────────

/**
 * Request a password reset. Generates a time-limited token.
 * In production this would send an email; in dev the token is returned
 * in the response and logged to console for convenience.
 *
 * Always returns 200 regardless of whether the email exists to prevent
 * user enumeration.
 *
 * @route POST /api/v1/auth/forgot-password
 * @param {Object} req.body
 * @param {string} req.body.email - Email address of the account.
 * @returns {200} `{ message, ...(dev: resetToken, resetUrl) }`.
 */
router.post("/forgot-password", async (req, res) => {
  // Rate-limit to prevent token-flooding DoS (fills memory with reset tokens)
  const ip = req.ip || "unknown";
  const rate = checkRateLimit("forgotPassword", ip);
  if (!rate.allowed) {
    res.setHeader("Retry-After", rate.retryAfterSec);
    return res.status(429).json({ error: `Too many requests. Try again in ${Math.ceil(rate.retryAfterSec / 60)} minutes.` });
  }

  const email = sanitiseString(req.body.email, 254).toLowerCase();
  if (!isValidEmail(email)) {
    return res.status(400).json({ error: "A valid email address is required." });
  }

  const user = userRepo.getByEmail(email);

  // Always return success to prevent user enumeration
  const genericMsg = "If an account with that email exists, a password reset link has been generated.";

  if (!user || !user.passwordHash) {
    // No account or OAuth-only account — silently succeed
    return res.json({ message: genericMsg });
  }

  // Generate a cryptographically random reset token and persist it to the DB.
  // Storing tokens in the DB (migration 003) means they survive server restarts
  // and work correctly across multiple API instances.
  const resetToken = crypto.randomBytes(32).toString("base64url");
  const tokenExpiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS).toISOString();
  try {
    // Invalidate any existing unused tokens for this user before creating a new one
    resetTokenRepo.create(resetToken, user.id, tokenExpiresAt);
  } catch (dbErr) {
    console.error(formatLogLine("error", null, `[auth/forgot-password] DB error: ${dbErr.message}`));
    return res.status(500).json({ error: "Failed to generate reset token. Please try again." });
  }

  // In production: send email with resetUrl. For now, log + return in dev.
  const appUrl = process.env.APP_URL || "http://localhost:3000";
  const baseUrl = (process.env.APP_BASE_PATH || "/").replace(/\/$/, "");
  const resetUrl = `${appUrl}${baseUrl}/forgot-password?token=${resetToken}`;

  if (process.env.NODE_ENV !== "production") {
    console.log(`[auth/forgot-password] Reset token for ${email}: ${resetToken}`);
    console.log(`[auth/forgot-password] Reset URL: ${resetUrl}`);
  }

  const response = { message: genericMsg };
  // Only expose the token in the response when explicitly opted-in.
  // Using NODE_ENV!=="production" was unsafe: staging servers without the flag
  // set would leak live reset tokens to any caller. ENABLE_DEV_RESET_TOKENS
  // must be deliberately set — absence of a production flag is not sufficient.
  if (process.env.ENABLE_DEV_RESET_TOKENS === "true") {
    response.resetToken = resetToken;
    response.resetUrl = resetUrl;
  }

  return res.json(response);
});

/**
 * Reset password using a valid reset token.
 *
 * @route POST /api/v1/auth/reset-password
 * @param {Object} req.body
 * @param {string} req.body.token       - The reset token from the email/URL.
 * @param {string} req.body.newPassword - New password (8–128 chars).
 * @returns {200} `{ message }` on success.
 * @returns {400} Invalid token, expired, or validation error.
 */
router.post("/reset-password", async (req, res) => {
  // Rate-limit to prevent brute-force token guessing
  const ip = req.ip || "unknown";
  const rate = checkRateLimit("resetPassword", ip);
  if (!rate.allowed) {
    res.setHeader("Retry-After", rate.retryAfterSec);
    return res.status(429).json({ error: `Too many requests. Try again in ${Math.ceil(rate.retryAfterSec / 60)} minutes.` });
  }

  const { token, newPassword } = req.body;

  if (!token || typeof token !== "string") {
    return res.status(400).json({ error: "Reset token is required." });
  }
  const pwErr = validatePasswordStrength(newPassword);
  if (pwErr) {
    return res.status(400).json({ error: pwErr });
  }

  // Atomically claim the token — marks it as used in a single UPDATE so two
  // concurrent requests with the same token cannot both succeed (TOCTOU fix).
  let entry;
  try {
    entry = resetTokenRepo.claim(token);
  } catch (dbErr) {
    console.error(formatLogLine("error", null, `[auth/reset-password] DB error: ${dbErr.message}`));
    return res.status(500).json({ error: "Server error. Please try again." });
  }

  if (!entry) {
    return res.status(400).json({ error: "Invalid or expired reset token. Please request a new one." });
  }

  const user = userRepo.getById(entry.userId);
  if (!user) {
    return res.status(400).json({ error: "Account not found." });
  }

  // Update password
  const newHash = await hashPassword(newPassword);
  userRepo.update(user.id, { passwordHash: newHash, updatedAt: new Date().toISOString() });

  // Invalidate all other unused tokens for this user so old reset links
  // cannot be replayed. The current token is already marked as used by claim().
  try {
    resetTokenRepo.deleteUnusedByUserId(entry.userId);
  } catch (dbErr) {
    // Non-fatal — password was already changed; just log the cleanup failure.
    console.error(formatLogLine("error", null, `[auth/reset-password] Token cleanup error: ${dbErr.message}`));
  }

  if (process.env.NODE_ENV !== "production") {
    console.log(`[auth/reset-password] Password reset for ${user.email}`);
  }

  return res.json({ message: "Password has been reset successfully. You can now sign in." });
});

// ─── GitHub OAuth ─────────────────────────────────────────────────────────────

/**
 * GitHub OAuth callback. Exchanges an authorization code for an access token,
 * fetches the user profile, sets auth cookies, and returns the user profile.
 *
 * @route GET /api/v1/auth/github/callback
 * @param {string} req.query.code - The OAuth authorization code from GitHub.
 * @returns {200} `{ user: { id, name, email, role, avatar, workspaceId, workspaceName, workspaceRole } }`.
 * @returns {400} Missing code parameter.
 * @returns {401} Token exchange or profile fetch failed.
 * @returns {503} GitHub OAuth not configured on this server.
 */
router.get("/github/callback", async (req, res) => {
  const code  = req.query.code;
  if (!code) return res.status(400).json({ error: "Missing code parameter." });

  const clientId     = process.env.GITHUB_CLIENT_ID;
  const clientSecret = process.env.GITHUB_CLIENT_SECRET;
  if (!clientId || !clientSecret) {
    return res.status(503).json({ error: "GitHub OAuth is not configured on this server." });
  }

  try {
    // Exchange code for access token
    const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
      method: "POST",
      headers: { "Content-Type": "application/json", Accept: "application/json" },
      body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, code }),
    });
    const tokenData = await tokenRes.json();
    if (tokenData.error || !tokenData.access_token) {
      throw new Error(tokenData.error_description || "GitHub token exchange failed.");
    }

    // Fetch user profile
    const profileRes = await fetch("https://api.github.com/user", {
      headers: { Authorization: `Bearer ${tokenData.access_token}`, "User-Agent": "Sentri-App" },
    });
    const profile = await profileRes.json();

    // Fetch primary email if not public
    let email = profile.email;
    if (!email) {
      const emailsRes = await fetch("https://api.github.com/user/emails", {
        headers: { Authorization: `Bearer ${tokenData.access_token}`, "User-Agent": "Sentri-App" },
      });
      const emails = await emailsRes.json();
      email = emails.find(e => e.primary && e.verified)?.email || emails[0]?.email;
    }
    if (!email) throw new Error("Could not retrieve a verified email from GitHub.");

    const user = await findOrCreateOAuthUser({
      provider: "github",
      providerId: String(profile.id),
      email: email.toLowerCase(),
      name: profile.name || profile.login,
      avatar: profile.avatar_url || null,
    });

    ensureUserWorkspace(user);

    const payload = buildJwtPayload(user);
    const token = signJwt(payload, getJwtSecret());
    const exp   = Math.floor(Date.now() / 1000) + JWT_TTL_SEC;
    setAuthCookie(res, token, exp);

    return res.json({ user: buildUserResponse(user) });
  } catch (err) {
    console.error(formatLogLine("error", null, `[auth/github] ${err.message}`));
    return res.status(401).json({ error: err.message || "GitHub authentication failed." });
  }
});

// ─── Google OAuth ─────────────────────────────────────────────────────────────

/**
 * Google OAuth callback. Exchanges an authorization code for an access token,
 * fetches the user profile, sets auth cookies, and returns the user profile.
 *
 * @route GET /api/v1/auth/google/callback
 * @param {string} req.query.code - The OAuth authorization code from Google.
 * @returns {200} `{ user: { id, name, email, role, avatar, workspaceId, workspaceName, workspaceRole } }`.
 * @returns {400} Missing code parameter.
 * @returns {401} Token exchange or profile fetch failed.
 * @returns {503} Google OAuth not configured on this server.
 */
router.get("/google/callback", async (req, res) => {
  const code = req.query.code;
  if (!code) return res.status(400).json({ error: "Missing code parameter." });

  const clientId     = process.env.GOOGLE_CLIENT_ID;
  const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
  const redirectUri  = process.env.GOOGLE_REDIRECT_URI || `${process.env.APP_URL || "http://localhost:3000"}/login?provider=google`;

  if (!clientId || !clientSecret) {
    return res.status(503).json({ error: "Google OAuth is not configured on this server." });
  }

  try {
    // Exchange code for tokens
    const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({ code, client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri, grant_type: "authorization_code" }),
    });
    const tokenData = await tokenRes.json();
    if (!tokenData.access_token) {
      throw new Error(tokenData.error_description || "Google token exchange failed.");
    }

    // Fetch user info
    const profileRes = await fetch("https://www.googleapis.com/oauth2/v3/userinfo", {
      headers: { Authorization: `Bearer ${tokenData.access_token}` },
    });
    const profile = await profileRes.json();

    if (!profile.email_verified) throw new Error("Google account email is not verified.");

    const user = await findOrCreateOAuthUser({
      provider: "google",
      providerId: profile.sub,
      email: profile.email.toLowerCase(),
      name: profile.name || profile.email.split("@")[0],
      avatar: profile.picture || null,
    });

    ensureUserWorkspace(user);

    const payload = buildJwtPayload(user);
    const token = signJwt(payload, getJwtSecret());
    const exp   = Math.floor(Date.now() / 1000) + JWT_TTL_SEC;
    setAuthCookie(res, token, exp);

    return res.json({ user: buildUserResponse(user) });
  } catch (err) {
    console.error(formatLogLine("error", null, `[auth/google] ${err.message}`));
    return res.status(401).json({ error: err.message || "Google authentication failed." });
  }
});

// ─── Shared OAuth helper ──────────────────────────────────────────────────────

/**
 * Find an existing user by OAuth provider ID or email, or create a new one.
 * If a user with the same email exists (registered via a different provider),
 * the accounts are linked automatically.
 *
 * @param {Object} opts
 * @param {string} opts.provider   - OAuth provider name (e.g. `"github"`, `"google"`).
 * @param {string} opts.providerId - Provider-specific user ID.
 * @param {string} opts.email      - User's email (lowercased).
 * @param {string} opts.name       - Display name.
 * @param {string|null} opts.avatar - Avatar URL, or `null`.
 * @returns {Promise<User>}          The found or newly created user object.
 * @private
 */
async function findOrCreateOAuthUser({ provider, providerId, email, name, avatar }) {
  const key      = `${provider}:${providerId}`;
  let userId     = userRepo.getOAuthUserId(key);
  let user       = userId ? userRepo.getById(userId) : null;

  if (!user) {
    // Check if an account with this email exists (link providers)
    user = userRepo.getByEmail(email);
  }

  if (!user) {
    // Create new user
    const id  = crypto.randomUUID();
    const now = new Date().toISOString();
    user      = { id, name, email, passwordHash: null, role: "user", avatar, createdAt: now, updatedAt: now };
    userRepo.create(user);
  }

  // Always keep OAuth provider link up to date
  userRepo.setOAuthLink(key, user.id);

  // SEC-001: If the user registered via email/password but hasn't verified yet,
  // auto-verify them now — the OAuth provider already verified the email.
  // This prevents the scenario where a user registers, then logs in via OAuth,
  // but can never use email/password login because emailVerified stays 0.
  const updates = {};
  if (user.emailVerified === 0) {
    updates.emailVerified = 1;
  }
  // Update avatar if missing
  if (!user.avatar && avatar) {
    updates.avatar = avatar;
    user.avatar = avatar;
  }
  if (Object.keys(updates).length > 0) {
    updates.updatedAt = new Date().toISOString();
    userRepo.update(user.id, updates);
    if (updates.emailVerified) user.emailVerified = 1;
  }

  return user;
}

export default router;