// ── Mobile SSO authorization bridge — start + exchange ─────────────────────
//
// The native app's "Sign in with Google/Discord" without shipping any OAuth
// secret. This controller owns the two app-facing endpoints; the SSO redirect
// mechanics and the callback branch live in sso.controller (reused, not
// duplicated), and the bridge state lives in the mobileAuthBridge model.
//
//   GET  /auth/mobile/sso/start     → seed a bridge session, reuse the SSO redirect
//   POST /auth/mobile/sso/exchange  → code + PKCE verifier → mobile bearer tokens
//
// Two PKCE layers are in play (see docs BACKEND_DESIGN §4): Layer A (website↔IdP,
// handled entirely inside the reused SSO flow) and Layer B (app↔website, verified
// here at /exchange). Do not conflate them.

const crypto = require('crypto')

const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const mobileSessions = require('../../../model/mobileSessions/mobileSessions.model')
const mobileBridge = require('../../../model/mobileAuthBridge/mobileAuthBridge.model')
const settings = require('../../../model/settings/settings.model')
const sessionService = require('../../../auth/session.service')
const ssoState = require('../../../auth/ssoState')
const ssoController = require('./sso.controller')
const { establishTrust } = require('./trustDevice.helper')

const log = require('../../../utils/logger')('auth-mobile-sso')

// Exact-match allowlist of app callback URIs. Default is the one fixed
// application-owned custom scheme; HTTPS App Link URIs can be appended per shard
// later (see docs/android/APP_LINKS.md). EXACT match only — never a prefix match
// (prefix matching on custom schemes is a known open-redirect vector on mobile).
const REDIRECT_ALLOWLIST = new Set(
  (process.env.MOBILE_AUTH_REDIRECT_URIS || 'runicgateway://auth/callback')
    .split(',')
    .map((s) => s.trim())
    .filter(Boolean),
)

const PROVIDER_ID_RE = /^[a-z0-9-]+$/

// This shard's own https App Link callback. Built from APP_BASE_URL (preferred, so
// it is never derived from an attacker-set Host header) or, failing that, the
// request's own origin. Path is the fixed /mobile/callback the app's autoVerify
// intent-filter is registered for.
function selfOriginCallback(req) {
  const base = (process.env.APP_BASE_URL || '').trim().replace(/\/+$/, '')
  const origin = base || `${req.protocol}://${req.get('host')}`
  return `${origin}/mobile/callback`
}

// redirect_uri is valid if it is one of the statically-allowlisted app callbacks
// (default the custom scheme), OR — only when the admin has enabled App Links —
// this shard's own https://<host>/mobile/callback. EXACT match in both cases; the
// App Links entry is additive and never narrows the custom-scheme allowlist.
async function isAllowedRedirect(redirectUri, req) {
  if (REDIRECT_ALLOWLIST.has(redirectUri)) return true
  // App Links only ever add an https callback, so skip the settings lookup for
  // anything that can't be one — custom-scheme rejections stay fast and DB-free.
  if (typeof redirectUri === 'string' && redirectUri.startsWith('https://')) {
    if (await settings.isMobileAppLinksEnabled()) {
      return redirectUri === selfOriginCallback(req)
    }
  }
  return false
}

// Append query params to an (already-allowlisted) app callback URI.
function appDeepLink(redirectUri, params) {
  const sep = redirectUri.includes('?') ? '&' : '?'
  const qs = Object.entries(params)
    .map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
    .join('&')
  return `${redirectUri}${sep}${qs}`
}

// Constant-time string compare (equal-length guard first).
function safeEqual(a, b) {
  const bufA = Buffer.from(String(a))
  const bufB = Buffer.from(String(b))
  return bufA.length === bufB.length && crypto.timingSafeEqual(bufA, bufB)
}

// GET /auth/mobile/sso/start?provider&code_challenge&state&redirect_uri
// Opened by the app in a Custom Tab. Validates the request, seeds a bridge
// session carrying the app's PKCE challenge + state + callback, then reuses the
// existing SSO redirect (tagged mode:'mobile') to hand off to the IdP.
async function start(req, res) {
  const { provider, code_challenge: codeChallenge, state, redirect_uri: redirectUri } = req.query
  try {
    // redirect_uri must be exactly one of the registered app callbacks. Validate
    // it FIRST — everything else can only be surfaced to the app by redirecting
    // to a trusted callback, so an untrusted one is a hard 400 (no redirect).
    if (!(await isAllowedRedirect(redirectUri, req))) {
      log.warn('mobile sso start: redirect_uri not in allowlist', { ip: req.ip })
      return res.status(400).json({ message: 'Unrecognized redirect URI.' })
    }
    if (!PROVIDER_ID_RE.test(provider || '')) {
      return res.redirect(appDeepLink(redirectUri, { error: 'invalid_provider', state }))
    }

    const { sessionId } = await mobileBridge.startSession({ provider, codeChallenge, redirectUri, state })
    const ok = await ssoController.redirectToIdp(req, res, provider, {
      mode: 'mobile',
      mobileSessionId: sessionId,
    })
    if (!ok) {
      log.warn('mobile sso start: provider unavailable', { provider })
      return res.redirect(appDeepLink(redirectUri, { error: 'provider_unavailable', state }))
    }
    // redirectToIdp already issued the 302 on success.
  } catch (err) {
    log.error('mobile sso start', err)
    // We validated redirect_uri above, so it's safe to bounce the error to the app.
    return res.redirect(appDeepLink(redirectUri, { error: 'server_error', state }))
  }
}

// POST /auth/mobile/sso/exchange  { code, code_verifier }
// Trades the one-time authorization code (+ its PKCE verifier) for the SAME mobile
// access + refresh pair as /auth/mobile/login. Single-use, PKCE-bound.
async function exchange(req, res) {
  const { code, code_verifier: codeVerifier } = req.body
  try {
    const codeRow = await mobileBridge.findRedeemableCode(code)
    if (!codeRow) {
      log.warn('mobile sso exchange: code unknown/expired/used', { ip: req.ip })
      return res.status(401).json({ message: 'Invalid or expired authorization code.' })
    }
    const sess = await mobileBridge.getSession(codeRow.session_id)
    if (!sess) {
      return res.status(401).json({ message: 'Invalid or expired authorization code.' })
    }

    // PKCE Layer B: the app proves it holds the verifier for the challenge it
    // registered at /start. Check BEFORE burning the code so a caller lacking the
    // verifier (e.g. a callback interceptor) can't consume a legitimate code.
    if (!safeEqual(ssoState.codeChallengeFor(codeVerifier || ''), sess.code_challenge)) {
      log.warn('mobile sso exchange: PKCE verifier mismatch', { ip: req.ip })
      return res.status(401).json({ message: 'PKCE verification failed.' })
    }

    // Atomic single-use gate: only the winner of a concurrent double-redeem
    // proceeds; a losing/replayed attempt sees false here.
    if (!(await mobileBridge.consumeCode(code))) {
      log.warn('mobile sso exchange: code already used', { ip: req.ip })
      return res.status(401).json({ message: 'Invalid or expired authorization code.' })
    }

    const user = await users.getById(codeRow.user_id) // fresh row; 401 if the account vanished
    if (!user) {
      return res.status(401).json({ message: 'Invalid or expired authorization code.' })
    }
    await mobileBridge.finishSession(codeRow.session_id)

    const meta = sessionService.sessionMeta(req)
    const out = sessionService.createMobileSession(user, meta)
    await mobileSessions.store({
      userId: user.id,
      tokenHash: out.refreshHash,
      deviceHash: out.deviceHash,
      deviceName: req.body.device_name || null,
      userAgent: out.userAgent,
      expiresAt: out.refreshExpiresAt,
    })
    await activity.log({ req, userId: user.id, action: 'auth.mobile.login', detail: { sso: sess.provider } })

    // The user ticked "trust this device" on the Custom Tab TOTP form. That already
    // set the browser's rg_trust cookie (which is what lets the NEXT Custom Tab SSO
    // sign-in skip the code); mint the app its OWN trust token here so a native
    // password login on the same device skips the code too. Minting at this point
    // — an authenticated app→server call — is deliberate: the token reaches the app
    // in a JSON body and never travels in the deep-link URL or sits in the bridge
    // row. Best-effort: a device at the trust cap just gets no token, never a failed
    // sign-in, so this can't turn a good login into an error.
    let trustToken = null
    if (sess.trust_device) {
      try {
        const trust = await establishTrust(req, user, {
          platform: 'mobile',
          deviceName: req.body.device_name || null,
        })
        if (trust.ok) trustToken = trust.trustToken
        else if (trust.capReached) log.info('mobile sso: trust refused, device cap reached', { id: user.id })
      } catch (err) {
        log.error('mobile sso: could not establish trust (continuing, sign-in already succeeded)', err)
      }
    }

    log.info('mobile sso exchange success', { id: user.id, provider: sess.provider, ip: req.ip })
    return res.json({
      accessToken: out.accessToken,
      refreshToken: out.refreshToken,
      expiresIn: out.expiresIn,
      user: { id: user.id, username: user.username, role: user.role },
      ...(trustToken ? { trustToken } : {}),
    })
  } catch (err) {
    log.error('mobile sso exchange', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

module.exports = { start, exchange }
