// ── Session service ────────────────────────────────────────────────────────
//
// The single seam every caller goes through to issue and validate a session.
// Today a "session" is a signed JWT (cookie for web, or a Bearer token), but
// callers only ever see the abstract Session object below — never the raw token
// shape. That indirection is what lets Part 2 (mobile bearer tokens) and Part 3
// (SSO) add new `authMethod`s without touching controllers or middleware.
//
// A Session object:
//   {
//     sessionId,   // stable id for this session (JWT jti)
//     userId,      // the user's DB id
//     username,
//     role,
//     authMethod,  // 'local' | 'totp' | 'mobile' | 'sso'
//     createdAt,   // ms epoch the token was issued (JWT iat)
//     expiresAt,   // ms epoch the token expires (JWT exp), or null
//     lastSeenAt,  // ms epoch this session was last validated
//   }
//
// Revocation for web/cookie sessions is backed by two stores: a per-session jti
// denylist (revoked_sessions — single logout) and a per-user cutoff
// (users.tokens_valid_after — password change / log out everywhere). requireAuth
// consults both. The functions here are the seam the controllers call.

const crypto = require('crypto')

const token = require('./token')
const revokedSessions = require('../model/revokedSessions/revokedSessions.model')
const trustedDevices = require('../model/trustedDevices/trustedDevices.model')
const users = require('../model/users/users.model')
const log = require('../utils/logger')('session')

// Valid authentication methods. 'local'/'totp' are the web flows; 'mobile' is the
// bearer flow (Part 2); 'google'/'discord'/'oidc' are SSO providers and 'sso' is
// the generic fallback label (Part 3). Sessions are tagged by how they were
// authenticated without changing this module per provider.
const AUTH_METHODS = ['local', 'totp', 'mobile', 'google', 'discord', 'oidc', 'sso']

// The claim that positively marks a token as a real, full session. Every JWT in
// the app is signed with the same secret and is distinguished only by claims, so
// a session must be identified by what it *is* (typ === 'session'), never by the
// mere absence of some other marker. Only the session-minting paths below stamp
// it; flow/challenge tokens (the TOTP challenge, the SSO transaction cookie) do
// not, so — even though they verify against the same secret — they can never be
// mistaken for a session. See issue #32 (sso_tx token-type confusion).
const SESSION_TYP = 'session'

// Build a Session object from a decoded JWT payload. Returns null for anything
// that is not a full session. Validation is positively typed: a token qualifies
// only if it was explicitly minted as a session. As belt-and-suspenders we also
// reject any token carrying a non-session marker (stage = TOTP challenge, kind =
// SSO transaction), so a future minting path that forgets to omit those still
// can't produce an accepted session.
function sessionFromDecoded(decoded, now = Date.now()) {
  if (!decoded || decoded.typ !== SESSION_TYP) return null
  if (decoded.stage || decoded.kind) return null
  return {
    sessionId: decoded.jti || null,
    userId: decoded.id,
    username: decoded.username,
    role: decoded.role,
    authMethod: decoded.authMethod || 'local',
    createdAt: decoded.iat ? decoded.iat * 1000 : null,
    expiresAt: decoded.exp ? decoded.exp * 1000 : null,
    lastSeenAt: now,
  }
}

// Issue a real session for a fully-authenticated user. Signs a JWT carrying the
// identity claims plus authMethod + a fresh session id (jti), and returns both
// the raw token (the caller sets the cookie or returns it as a bearer token)
// and the decoded Session object. Does NOT touch cookies or the DB — issuing the
// cookie and recording the login stay in the controller so its bot-scoring /
// backoff / activity-log orchestration is unchanged.
function createSession(user, authMethod = 'local') {
  const method = AUTH_METHODS.includes(authMethod) ? authMethod : 'local'
  const sessionId = crypto.randomUUID()
  const raw = token.signToken(user, { authMethod: method, jti: sessionId, typ: SESSION_TYP })
  const session = sessionFromDecoded(token.verifyToken(raw))
  log.info('session created', { userId: user.id, username: user.username, authMethod: method, sessionId })
  return { token: raw, session }
}

// Issue the short-lived "password verified, awaiting TOTP" challenge. This is
// deliberately NOT a session — validateSession rejects it — so a half-completed
// login can never be presented as a full one.
function createPartialSession(user) {
  log.info('partial (TOTP) session issued', { userId: user.id, username: user.username })
  return token.signTotpChallenge(user)
}

// Complete the TOTP step: verify the challenge token and return the decoded
// identity ({ id, stage }) so the caller can load the user and createSession().
// Returns null for an expired/invalid/non-challenge token.
function upgradeSessionAfterTotp(challengeToken) {
  const decoded = token.verifyTotpChallenge(challengeToken)
  if (!decoded) {
    log.warn('TOTP challenge rejected (expired or invalid)')
    return null
  }
  return decoded
}

// Validate the session on an incoming request WITHOUT hitting the DB — pure
// token verification + identity decode. Returns a Session object or null.
// Stage-tagged tokens (the TOTP challenge) are explicitly not sessions.
// DB re-validation of the user is a middleware concern (requireAuth), kept
// separate so a demoted/deleted user still loses access on the next request.
function validateSession(req, now = Date.now()) {
  const raw = token.extractToken(req)
  if (!raw) return null
  return sessionFromDecoded(token.verifyToken(raw), now)
}

// Decode a raw token string into a Session object (or null). Used where the
// token is already in hand rather than on a request.
function decodeIdentity(rawToken, now = Date.now()) {
  if (!rawToken) return null
  return sessionFromDecoded(token.verifyToken(rawToken), now)
}

// ── Mobile (bearer) sessions ───────────────────────────────────────────────
// Native clients get a short-lived JWT access token (validated on every request
// exactly like a cookie session) plus a long-lived opaque refresh token. The
// refresh token is random and never a JWT: it is stored server-side by hash and
// is the only revocable half, which is what makes mobile logout meaningful.
//
// These functions are intentionally pure — they mint and hash but do NOT touch
// the database. The controller persists the returned refreshHash via the
// mobileSessions model, keeping this module DB-free and unit-testable.

const MOBILE_ACCESS_TTL = process.env.MOBILE_ACCESS_TTL || '15m'
const MOBILE_REFRESH_TTL_DAYS = Number(process.env.MOBILE_REFRESH_TTL_DAYS) || 30

// Hash a raw refresh token to the value stored in the DB. Exported so the
// controller and model agree on the exact representation.
function hashRefreshToken(raw) {
  return crypto.createHash('sha256').update(String(raw)).digest('hex')
}

// Mint a fresh access + refresh pair for a user. `now` is injectable for tests.
function mintMobileTokens(user, meta = {}, now = Date.now()) {
  const sessionId = crypto.randomUUID()
  const accessToken = token.signToken(
    user,
    { authMethod: 'mobile', jti: sessionId, typ: SESSION_TYP },
    { expiresIn: MOBILE_ACCESS_TTL },
  )
  // 256 bits of entropy, url-safe. Opaque — carries no claims.
  const refreshToken = crypto.randomBytes(32).toString('base64url')
  const refreshExpiresAt = new Date(now + MOBILE_REFRESH_TTL_DAYS * 24 * 60 * 60 * 1000)
  return {
    accessToken,
    refreshToken,
    refreshHash: hashRefreshToken(refreshToken),
    refreshExpiresAt,
    expiresIn: MOBILE_ACCESS_TTL,
    deviceHash: meta.deviceHash || null,
    userAgent: meta.userAgent || null,
    session: sessionFromDecoded(token.verifyToken(accessToken), now),
  }
}

// Issue a mobile session at login.
function createMobileSession(user, meta = {}, now = Date.now()) {
  const out = mintMobileTokens(user, meta, now)
  log.info('mobile session created', { userId: user.id, username: user.username, sessionId: out.session.sessionId })
  return out
}

// Rotate a mobile session on refresh — same shape as createMobileSession. The
// caller is responsible for having validated + revoked the presented refresh
// token before calling this (rotation), and for persisting the new refreshHash.
function refreshMobileSession(user, meta = {}, now = Date.now()) {
  const out = mintMobileTokens(user, meta, now)
  log.info('mobile session refreshed', { userId: user.id, sessionId: out.session.sessionId })
  return out
}

// Validate a raw bearer access token → Session object or null. Rejects
// stage-tagged tokens (a TOTP challenge is not a bearer session).
function validateBearerToken(rawToken, now = Date.now()) {
  if (!rawToken) return null
  return sessionFromDecoded(token.verifyToken(rawToken), now)
}

// Optional per-session metadata derived from the request. Attached to the
// session object by middleware for logging/auditing; NOT baked into the token
// (keeps tokens small and avoids trusting client-supplied device data as a claim).
function sessionMeta(req) {
  const ip = req.ip || null
  const userAgent = (req.headers && req.headers['user-agent']) || null
  const deviceHash = crypto
    .createHash('sha256')
    .update(`${userAgent || ''}|${ip || ''}`)
    .digest('hex')
    .slice(0, 16)
  return { ip, userAgent, deviceHash }
}

// ── Trusted devices (MFA "Trust this device") ──────────────────────────────
// A trusted device lets a login SKIP the TOTP step (never the password). The
// opaque trust token lives client-side (rg_trust cookie on web, X-Trust-Token /
// EncryptedSharedPreferences on native); only its sha256 hash is stored, so — like
// the mobile refresh token — the server side is revocable and never holds the raw
// secret. These functions mint/hash/resolve; the controller sets the cookie and
// the trustedDevices model persists the row. sha256 (not bcrypt): the token is a
// 256-bit random value looked up BY its hash via a UNIQUE index.

const TRUSTED_DEVICE_TTL_DAYS = Number(process.env.TRUSTED_DEVICE_TTL_DAYS) || 30

// Hash a raw trust token to the value stored in the DB. Separate name from
// hashRefreshToken so intent is explicit at call sites, though the algorithm is
// the same deterministic sha256.
function hashTrustToken(raw) {
  return crypto.createHash('sha256').update(String(raw)).digest('hex')
}

// Mint a fresh opaque trust token + its hash + expiry. `meta` (from sessionMeta)
// supplies the best-effort device fingerprint stored for display. `now` injectable
// for tests. Does NOT touch cookies or the DB.
function mintTrustToken(meta = {}, now = Date.now()) {
  const trustToken = crypto.randomBytes(32).toString('base64url') // 256 bits, opaque
  const expiresAt = new Date(now + TRUSTED_DEVICE_TTL_DAYS * 24 * 60 * 60 * 1000)
  return {
    trustToken,
    trustHash: hashTrustToken(trustToken),
    deviceHash: meta.deviceHash || null,
    userAgent: meta.userAgent || null,
    expiresAt,
  }
}

// Resolve the trust token on an incoming request to its still-valid DB row (or
// null). The caller MUST confirm row.user_id matches the user who just passed the
// password step before honoring it — a trust token is scoped to the account that
// created it. Never throws on a DB hiccup here; the caller falls back to TOTP.
async function resolveTrustedDevice(req) {
  const raw = token.extractTrustToken(req)
  if (!raw) return null
  return trustedDevices.findValidByHash(hashTrustToken(raw))
}

// Stamp a trusted device as used (called when its trust was honored to skip TOTP).
async function honorTrustedDevice(id) {
  if (!id) return false
  await trustedDevices.touchLastUsed(id)
  return true
}

// True if the user already holds the maximum number of trusted devices. Callers
// refuse a new trust (signaling the client to revoke one first) rather than
// pruning silently. See docs/website/TRUSTED_DEVICES_MFA.md §5.
async function trustDeviceCapReached(userId) {
  return trustedDevices.isAtCap(userId)
}

// ── Revocation / invalidation ──────────────────────────────────────────────
// Web/cookie sessions are JWTs, so revocation is enforced by requireAuth reading
// two server-side stores these functions write:
//   • revoked_sessions — a per-session jti denylist (single logout)
//   • users.tokens_valid_after — a per-user cutoff (log out everywhere)
// A jti + its expiry (from the decoded token) are needed to denylist one session;
// invalidating all of a user's sessions only needs their id.

// Revoke a single session by its jti. Needs the token's expiry so the denylist
// row can self-prune once the JWT would fail verification anyway. Idempotent.
async function revokeSession(sessionId, { userId = null, expiresAt } = {}) {
  if (!sessionId) {
    log.warn('revokeSession called without a sessionId (jti) — nothing to revoke')
    return false
  }
  // Fall back to the max JWT lifetime if the caller didn't pass the token's exp,
  // so the denylist row still outlives any token carrying this jti.
  const exp = expiresAt || Date.now() + token.cookieMaxAge()
  await revokedSessions.revoke({ jti: sessionId, userId, expiresAt: exp })
  log.info('session revoked', { sessionId, userId })
  return true
}

// Alias kept for callers that speak of "invalidating" one session.
async function invalidateSession(sessionId, opts) {
  return revokeSession(sessionId, opts)
}

// Has this session (jti) been individually revoked? Used by requireAuth on every
// authenticated request. Broad "valid after" cutoffs are checked separately by
// the middleware against the fresh user row it already loads.
async function isSessionRevoked(sessionId) {
  if (!sessionId) return false
  return revokedSessions.isRevoked(sessionId)
}

// Invalidate every session a user holds (password change / log out everywhere)
// by advancing their tokens_valid_after cutoff. Covers cookie sessions issued
// before now regardless of jti.
async function invalidateAllUserSessions(userId) {
  if (!userId) {
    log.warn('invalidateAllUserSessions called without a userId')
    return false
  }
  await users.invalidateSessions(userId)
  log.info('all user sessions invalidated', { userId })
  return true
}

module.exports = {
  AUTH_METHODS,
  createSession,
  createPartialSession,
  upgradeSessionAfterTotp,
  validateSession,
  decodeIdentity,
  sessionMeta,
  revokeSession,
  invalidateSession,
  isSessionRevoked,
  invalidateAllUserSessions,
  // Mobile bearer sessions.
  createMobileSession,
  refreshMobileSession,
  validateBearerToken,
  hashRefreshToken,
  // Trusted devices (MFA "Trust this device").
  hashTrustToken,
  mintTrustToken,
  resolveTrustedDevice,
  honorTrustedDevice,
  trustDeviceCapReached,
}
