// Mobile SSO authorization bridge — controller/integration tests. Models are
// stubbed (mutable object exports), so these are DB-free and exercise the bridge
// start/exchange handlers plus the mode:'mobile' branches added to sso.controller.
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key'
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'

const { test, beforeEach, after } = require('node:test')
const assert = require('node:assert/strict')

const mobileSso = require('../src/router/v1/auth/mobileSso.controller')
const ssoCtrl = require('../src/router/v1/auth/sso.controller')
const ssoState = require('../src/auth/ssoState')
const token = require('../src/auth/token')
const bridge = require('../src/model/mobileAuthBridge/mobileAuthBridge.model')
const mobileSessions = require('../src/model/mobileSessions/mobileSessions.model')
const users = require('../src/model/users/users.model')
const activity = require('../src/model/activity/activity.model')
const authProviders = require('../src/model/authProviders/authProviders.model')
const userIdentities = require('../src/model/userIdentities/userIdentities.model')
const settings = require('../src/model/settings/settings.model')
const registry = require('../src/auth/providers/registry')
const totp = require('../src/utils/totp')
const db = require('../src/utils/db')

after(() => db.close())

const CALLBACK = 'runicgateway://auth/callback'
const PROFILE = { subject: 'sub-1', email: 'alice@example.com', name: 'Alice' }
const SESSION = {
  session_id: 'sess-1',
  status: 'pending',
  provider: 'google',
  redirect_uri: CALLBACK,
  state: 'st-abc',
  code_challenge: ssoState.codeChallengeFor('verifier-xyz'),
  expires_at: new Date(Date.now() + 5 * 60 * 1000),
}

let logged
beforeEach(() => {
  logged = []
  activity.log = async (e) => { logged.push(e) }
  authProviders.getWithSecret = async () => ({ id: 'google', kind: 'google', enabled: 1, client_id: 'c', client_secret_enc: 'e' })
  registry.instantiate = () => ({ handleCallback: async () => ({ ...PROFILE }) })
  registry.validateConfig = () => ({ valid: true })
  userIdentities.findByProviderSubject = async () => ({ user_id: 7 })
  settings.getRegistrationMode = async () => 'disabled'
  users.getById = async (id) => ({ id, username: 'alice', role: 'player' })
  users.recordLogin = async () => {}
  mobileSessions.store = async () => 1
  bridge.getSession = async () => ({ ...SESSION })
  bridge.issueAuthCode = async () => ({ code: 'RAWCODE' })
  bridge.startSession = async () => ({ sessionId: 'sess-1' })
  bridge.findRedeemableCode = async () => ({ user_id: 7, session_id: 'sess-1' })
  bridge.consumeCode = async () => true
  bridge.finishSession = async () => 1
})

function mockRes() {
  return {
    statusCode: 200, redirectedTo: null, body: null, cookies: {}, cleared: [],
    status(c) { this.statusCode = c; return this },
    json(b) { this.body = b; return this },
    redirect(u) { this.redirectedTo = u; return this },
    cookie(n, v) { this.cookies[n] = v; return this },
    clearCookie(n) { this.cleared.push(n); return this },
  }
}

// ── /start ─────────────────────────────────────────────────────────────────

test('start: an un-allowlisted redirect_uri is a hard 400 (no redirect, exact-match only)', async () => {
  const res = mockRes()
  await mobileSso.start(
    { query: { provider: 'google', code_challenge: 'c', state: 's', redirect_uri: 'evil://auth/callback' }, ip: '1.2.3.4' },
    res,
  )
  assert.equal(res.statusCode, 400)
  assert.equal(res.redirectedTo, null)
})

test('start: a prefix of the allowlisted URI is rejected (not a prefix match)', async () => {
  const res = mockRes()
  await mobileSso.start(
    { query: { provider: 'google', code_challenge: 'c', state: 's', redirect_uri: 'runicgateway://auth/callback.evil.com' }, ip: '1.2.3.4' },
    res,
  )
  assert.equal(res.statusCode, 400)
})

test('start: seeds a bridge session and reuses the SSO redirect tagged mode:mobile', async () => {
  let startArgs = null
  let txData = null
  bridge.startSession = async (a) => { startArgs = a; return { sessionId: 'sess-99' } }
  const saved = ssoCtrl.redirectToIdp
  ssoCtrl.redirectToIdp = async (req, res, provider, data) => { txData = { provider, ...data }; res.redirect('https://idp/authorize'); return true }
  try {
    const res = mockRes()
    await mobileSso.start(
      { query: { provider: 'google', code_challenge: 'chal', state: 'st', redirect_uri: CALLBACK }, ip: '1.2.3.4' },
      res,
    )
    assert.equal(res.redirectedTo, 'https://idp/authorize')
    assert.equal(startArgs.redirectUri, CALLBACK)
    assert.equal(startArgs.codeChallenge, 'chal')
    assert.equal(txData.provider, 'google')
    assert.equal(txData.mode, 'mobile')
    assert.equal(txData.mobileSessionId, 'sess-99')
  } finally {
    ssoCtrl.redirectToIdp = saved
  }
})

test('start: a disabled/unavailable provider surfaces the error to the app callback', async () => {
  const saved = ssoCtrl.redirectToIdp
  ssoCtrl.redirectToIdp = async () => false
  try {
    const res = mockRes()
    await mobileSso.start(
      { query: { provider: 'google', code_challenge: 'chal', state: 'st', redirect_uri: CALLBACK }, ip: '1.2.3.4' },
      res,
    )
    assert.equal(res.redirectedTo, `${CALLBACK}?error=provider_unavailable&state=st`)
  } finally {
    ssoCtrl.redirectToIdp = saved
  }
})

// ── /exchange ────────────────────────────────────────────────────────────────

function exchangeReq(body) {
  return { body, ip: '127.0.0.1', headers: { 'user-agent': 'Android' } }
}

test('exchange: valid code + PKCE verifier → mobile bearer tokens (same shape as login)', async () => {
  const res = mockRes()
  await mobileSso.exchange(exchangeReq({ code: 'RAWCODE', code_verifier: 'verifier-xyz' }), res)
  assert.equal(res.statusCode, 200)
  assert.ok(res.body.accessToken, 'access token issued')
  assert.ok(res.body.refreshToken, 'refresh token issued')
  assert.equal(res.body.user.id, 7)
  // The issued access token validates as a mobile session.
  const s = require('../src/auth/session.service').validateBearerToken(res.body.accessToken)
  assert.equal(s.authMethod, 'mobile')
  assert.equal(logged.at(-1).action, 'auth.mobile.login')
  assert.equal(logged.at(-1).detail.sso, 'google')
})

test('exchange: unknown/expired/used code → 401', async () => {
  bridge.findRedeemableCode = async () => null
  const res = mockRes()
  await mobileSso.exchange(exchangeReq({ code: 'nope', code_verifier: 'verifier-xyz' }), res)
  assert.equal(res.statusCode, 401)
})

test('exchange: wrong PKCE verifier → 401 and the code is NOT consumed', async () => {
  let consumed = false
  bridge.consumeCode = async () => { consumed = true; return true }
  const res = mockRes()
  await mobileSso.exchange(exchangeReq({ code: 'RAWCODE', code_verifier: 'WRONG' }), res)
  assert.equal(res.statusCode, 401)
  assert.equal(consumed, false, 'a failed PKCE check must not burn a legitimate code')
})

test('exchange: a reused (already-consumed) code → 401 (single use)', async () => {
  bridge.consumeCode = async () => false // lost the single-use race / replay
  const res = mockRes()
  await mobileSso.exchange(exchangeReq({ code: 'RAWCODE', code_verifier: 'verifier-xyz' }), res)
  assert.equal(res.statusCode, 401)
})

test('exchange: vanished user → 401', async () => {
  users.getById = async () => null
  const res = mockRes()
  await mobileSso.exchange(exchangeReq({ code: 'RAWCODE', code_verifier: 'verifier-xyz' }), res)
  assert.equal(res.statusCode, 401)
})

// ── callback (mode:'mobile') via sso.controller ──────────────────────────────

function callbackReq(tx) {
  return {
    params: { provider: 'google' },
    cookies: { [ssoState.TX_COOKIE]: tx.txToken },
    query: { state: tx.nonce, code: 'idp-code' },
    ip: '127.0.0.1', protocol: 'http', get: () => 'localhost', headers: {},
  }
}

test('callback (mobile): linked account → deep link with a one-time code + echoed state, NO cookie', async () => {
  const tx = ssoState.createTx({ provider: 'google', mode: 'mobile', mobileSessionId: 'sess-1' })
  const res = mockRes()
  await ssoCtrl.callback(callbackReq(tx), res)
  assert.equal(res.redirectedTo, `${CALLBACK}?code=RAWCODE&state=st-abc`)
  assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'no web session cookie on a mobile flow')
  assert.equal(logged.at(-1).action, 'auth.sso.login')
  assert.equal(logged.at(-1).detail.mobile, true)
})

test('callback (mobile): unlinked account with registration closed → error deep link (link-only)', async () => {
  userIdentities.findByProviderSubject = async () => null
  const tx = ssoState.createTx({ provider: 'google', mode: 'mobile', mobileSessionId: 'sess-1' })
  const res = mockRes()
  await ssoCtrl.callback(callbackReq(tx), res)
  assert.equal(res.redirectedTo, `${CALLBACK}?error=not_linked&state=st-abc`)
  assert.equal(logged.length, 0)
})

test('callback (mobile): a 2FA account is routed through the TOTP form, code NOT yet issued', async () => {
  users.getById = async (id) => ({ id, username: 'alice', role: 'player', totp_enabled: 1 })
  let issued = false
  bridge.issueAuthCode = async () => { issued = true; return { code: 'RAWCODE' } }
  const tx = ssoState.createTx({ provider: 'google', mode: 'mobile', mobileSessionId: 'sess-1' })
  const res = mockRes()
  await ssoCtrl.callback(callbackReq(tx), res)
  assert.equal(res.redirectedTo, '/account/login?sso_totp=1')
  assert.ok(res.cookies[ssoState.TOTP_COOKIE], 'pending-TOTP cookie staged')
  assert.equal(issued, false, 'no auth code before the second factor passes')
  // The staged challenge carries the bridge session so completion can deep-link back.
  const pending = ssoState.verifyTotpPending(res.cookies[ssoState.TOTP_COOKIE])
  assert.equal(pending.mobileSessionId, 'sess-1')
})

test('callback (mobile): an invalid/expired bridge session fails without leaking a redirect', async () => {
  bridge.getSession = async () => null
  const tx = ssoState.createTx({ provider: 'google', mode: 'mobile', mobileSessionId: 'gone' })
  const res = mockRes()
  await ssoCtrl.callback(callbackReq(tx), res)
  assert.equal(res.statusCode, 400)
  assert.equal(res.redirectedTo, null)
})

// ── finishSsoTotp (mode:'mobile') ────────────────────────────────────────────

function totpReq(pending, code) {
  return {
    cookies: pending ? { [ssoState.TOTP_COOKIE]: pending } : {},
    body: { code }, ip: '127.0.0.1', protocol: 'http', get: () => 'localhost', headers: {},
  }
}

test('finishSsoTotp (mobile): correct code → JSON { redirect } deep link, no cookie', async () => {
  users.getRawById = async (id) => ({ id, username: 'alice', role: 'player', totp_enabled: 1, totp_secret: 'S' })
  totp.verifyCode = () => true
  const pending = ssoState.createTotpPending({ userId: 7, provider: 'google', authMethod: 'google', returnTo: '/account', mobileSessionId: 'sess-1' })
  const res = mockRes()
  await ssoCtrl.finishSsoTotp(totpReq(pending, '123456'), res)
  assert.equal(res.body.redirect, `${CALLBACK}?code=RAWCODE&state=st-abc`)
  assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'mobile 2FA completion sets no web session cookie')
  assert.ok(res.cleared.includes(ssoState.TOTP_COOKIE))
  assert.equal(logged.at(-1).detail.totp, true)
})

test('finishSsoTotp (mobile): expired bridge session → 401', async () => {
  users.getRawById = async (id) => ({ id, username: 'alice', role: 'player', totp_enabled: 1, totp_secret: 'S' })
  totp.verifyCode = () => true
  bridge.getSession = async () => null
  const pending = ssoState.createTotpPending({ userId: 7, provider: 'google', authMethod: 'google', mobileSessionId: 'gone' })
  const res = mockRes()
  await ssoCtrl.finishSsoTotp(totpReq(pending, '123456'), res)
  assert.equal(res.statusCode, 401)
})
