// Point the DB at a closed port BEFORE requiring the controller (its models build
// the pool). Every collaborator is monkeypatched, so no query runs.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'

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

// Trusted devices on the SSO paths. The gap these lock down: SSO used to jump
// straight from needsTotp() to staging a challenge, so a browser the user had
// explicitly trusted was still asked for a code on EVERY Google/Discord sign-in,
// and the SSO second step had no way to establish trust at all.
//
// Covered here:
//   - a trusted device (bound to THEM) skips the SSO second factor;
//   - a trust bound to a DIFFERENT user is ignored — challenge as usual;
//   - a store error falls back to the challenge (fail closed to asking);
//   - the mobile bridge (Custom Tab) gets the same skip;
//   - POST /auth/sso/totp with trustDevice sets the trust cookie, and flags the
//     bridge session so /exchange can mint the app's own token;
//   - at the device cap the sign-in still completes, with a trustLimitReached prompt.
const ssoCtrl = require('../src/router/v1/auth/sso.controller')
const users = require('../src/model/users/users.model')
const activity = require('../src/model/activity/activity.model')
const userIdentities = require('../src/model/userIdentities/userIdentities.model')
const sessionService = require('../src/auth/session.service')
const trustedDevices = require('../src/model/trustedDevices/trustedDevices.model')
const mobileBridge = require('../src/model/mobileAuthBridge/mobileAuthBridge.model')
const ssoState = require('../src/auth/ssoState')
const totp = require('../src/utils/totp')
const botScore = require('../src/middleware/botScore')
const loginProtection = require('../src/middleware/loginProtection')
const db = require('../src/utils/db')

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

function mockRes() {
  return {
    statusCode: 200,
    body: null,
    cookies: {},
    redirectedTo: null,
    status(c) { this.statusCode = c; return this },
    json(b) { this.body = b; return this },
    cookie(name, val) { this.cookies[name] = val; return this },
    clearCookie(name) { this.cookies[name] = undefined; return this },
    redirect(to) { this.redirectedTo = to; return this },
  }
}

const TOTP_USER = { id: 5, username: 'gwen', role: 'player', status: 'active', totp_enabled: 1, totp_secret: 'S' }

let sessionsCreated
const orig = {}
beforeEach(() => {
  sessionsCreated = []
  for (const [mod, name] of [
    [users, 'getRawById'], [users, 'getById'], [users, 'recordLogin'],
    [activity, 'log'],
    [userIdentities, 'findByProviderSubject'],
    [sessionService, 'createSession'],
    [sessionService, 'resolveTrustedDevice'], [sessionService, 'honorTrustedDevice'],
    [sessionService, 'trustDeviceCapReached'], [sessionService, 'mintTrustToken'], [sessionService, 'sessionMeta'],
    [trustedDevices, 'store'], [trustedDevices, 'listActiveForUser'],
    [mobileBridge, 'getSession'], [mobileBridge, 'markTrustRequested'], [mobileBridge, 'issueAuthCode'],
    [ssoState, 'createTotpPending'], [ssoState, 'verifyTotpPending'],
    [totp, 'verifyCode'],
    [botScore, 'recordLoginFailure'], [loginProtection, 'recordFailure'], [loginProtection, 'recordSuccess'],
  ]) {
    orig[name] = orig[name] || { mod, val: mod[name] }
  }
  users.recordLogin = async () => {}
  activity.log = async () => {}
  botScore.recordLoginFailure = () => {}
  loginProtection.recordFailure = () => {}
  loginProtection.recordSuccess = () => {}
  sessionService.createSession = (user, authMethod) => {
    sessionsCreated.push({ user, authMethod })
    return { token: 'session-token' }
  }
  sessionService.honorTrustedDevice = async () => true
  sessionService.sessionMeta = () => ({ deviceHash: 'dh', userAgent: 'UA' })
  sessionService.trustDeviceCapReached = async () => false
  sessionService.mintTrustToken = () => ({
    trustToken: 'raw-trust', trustHash: 'hash', deviceHash: 'dh', userAgent: 'UA', expiresAt: new Date(Date.now() + 1e6),
  })
  trustedDevices.store = async () => 1
  ssoState.createTotpPending = () => 'pending-jwt'
  userIdentities.findByProviderSubject = async () => ({ user_id: 5 })
  users.getById = async () => TOTP_USER
})
afterEach(() => {
  for (const key of Object.keys(orig)) { orig[key].mod[key] = orig[key].val; delete orig[key] }
})

const baseReq = (body = {}) => ({ body, ip: '10.0.0.1', headers: {}, cookies: {} })
const tx = { returnTo: '/account' }
const profile = { subject: 'sub-1', email: 'g@example.test' }

// ── finishLogin(): trusted device skips the SSO second factor ──────────────
test('sso login: a TOTP user on a device trusted by THEM skips the code', async () => {
  users.getRawById = async () => TOTP_USER
  sessionService.resolveTrustedDevice = async () => ({ id: 11, user_id: 5 })
  let honored = null
  sessionService.honorTrustedDevice = async (id) => { honored = id }

  const res = mockRes()
  await ssoCtrl.finishLogin(baseReq(), res, 'google', 'sso', tx, profile)

  assert.equal(sessionsCreated.length, 1, 'session issued without a TOTP bounce')
  assert.equal(honored, 11, 'the trusted device was stamped as used')
  assert.ok(!String(res.redirectedTo).includes('sso_totp'), `did not bounce to the code form (got ${res.redirectedTo})`)
})

test('sso login: a trust bound to a DIFFERENT user is ignored (code still required)', async () => {
  sessionService.resolveTrustedDevice = async () => ({ id: 11, user_id: 999 })

  const res = mockRes()
  await ssoCtrl.finishLogin(baseReq(), res, 'google', 'sso', tx, profile)

  assert.match(String(res.redirectedTo), /sso_totp=1/)
  assert.equal(sessionsCreated.length, 0)
})

test('sso login: a trusted-device lookup error falls back to the code (fail closed)', async () => {
  sessionService.resolveTrustedDevice = async () => { throw new Error('store down') }

  const res = mockRes()
  await ssoCtrl.finishLogin(baseReq(), res, 'google', 'sso', tx, profile)

  assert.match(String(res.redirectedTo), /sso_totp=1/)
  assert.equal(sessionsCreated.length, 0)
})

// ── finishSsoTotp(): the second step can now establish trust ───────────────
test('sso totp: trustDevice sets the trust cookie and completes the sign-in', async () => {
  ssoState.verifyTotpPending = () => ({ id: 5, provider: 'google', authMethod: 'sso', returnTo: '/account' })
  users.getRawById = async () => TOTP_USER
  totp.verifyCode = () => true

  const res = mockRes()
  await ssoCtrl.finishSsoTotp(baseReq({ code: '123456', trustDevice: true, deviceName: 'Kitchen laptop' }), res)

  assert.equal(res.statusCode, 200)
  assert.equal(res.cookies.rg_trust, 'raw-trust', 'trust cookie issued')
  assert.equal(res.body.user.id, 5)
  assert.equal(res.body.trustLimitReached, undefined)
})

test('sso totp: without trustDevice no trust cookie is set', async () => {
  ssoState.verifyTotpPending = () => ({ id: 5, provider: 'google', authMethod: 'sso', returnTo: '/account' })
  users.getRawById = async () => TOTP_USER
  totp.verifyCode = () => true

  const res = mockRes()
  await ssoCtrl.finishSsoTotp(baseReq({ code: '123456' }), res)

  assert.equal(res.statusCode, 200)
  assert.equal(res.cookies.rg_trust, undefined)
})

test('sso totp: at the device cap the sign-in still succeeds, with a trustLimitReached prompt', async () => {
  ssoState.verifyTotpPending = () => ({ id: 5, provider: 'google', authMethod: 'sso', returnTo: '/account' })
  users.getRawById = async () => TOTP_USER
  totp.verifyCode = () => true
  sessionService.trustDeviceCapReached = async () => true
  trustedDevices.listActiveForUser = async () => [{ id: 1, platform: 'web' }]

  const res = mockRes()
  await ssoCtrl.finishSsoTotp(baseReq({ code: '123456', trustDevice: true }), res)

  assert.equal(res.statusCode, 200)
  assert.equal(res.body.trustLimitReached, true)
  assert.equal(res.cookies.rg_trust, undefined, 'no cookie when the cap refused the trust')
  assert.equal(sessionsCreated.length, 1, 'the session is still issued')
})

// ── mobile bridge: the Custom Tab gets the same treatment ──────────────────
test('sso totp (mobile bridge): trustDevice flags the session so /exchange can mint the app token', async () => {
  ssoState.verifyTotpPending = () => ({ id: 5, provider: 'google', authMethod: 'sso', mobileSessionId: 'sess-1' })
  users.getRawById = async () => TOTP_USER
  totp.verifyCode = () => true
  mobileBridge.getSession = async () => ({
    session_id: 'sess-1', status: 'pending', expires_at: new Date(Date.now() + 60000),
    redirect_uri: 'runicgateway://auth/callback', state: 'st',
  })
  mobileBridge.issueAuthCode = async () => ({ code: 'authcode' })
  let flagged = null
  mobileBridge.markTrustRequested = async (id) => { flagged = id; return true }

  const res = mockRes()
  await ssoCtrl.finishSsoTotp(baseReq({ code: '123456', trustDevice: true }), res)

  assert.equal(flagged, 'sess-1', 'bridge session flagged for the app-side trust token')
  assert.equal(res.cookies.rg_trust, 'raw-trust', 'the Custom Tab browser is trusted too')
  assert.match(String(res.body.redirect), /^runicgateway:\/\/auth\/callback/)
})

test('sso totp (mobile bridge): without trustDevice the session is not flagged', async () => {
  ssoState.verifyTotpPending = () => ({ id: 5, provider: 'google', authMethod: 'sso', mobileSessionId: 'sess-1' })
  users.getRawById = async () => TOTP_USER
  totp.verifyCode = () => true
  mobileBridge.getSession = async () => ({
    session_id: 'sess-1', status: 'pending', expires_at: new Date(Date.now() + 60000),
    redirect_uri: 'runicgateway://auth/callback', state: 'st',
  })
  mobileBridge.issueAuthCode = async () => ({ code: 'authcode' })
  let flagged = null
  mobileBridge.markTrustRequested = async (id) => { flagged = id; return true }

  const res = mockRes()
  await ssoCtrl.finishSsoTotp(baseReq({ code: '123456' }), res)

  assert.equal(flagged, null)
  assert.equal(res.cookies.rg_trust, undefined)
})
