// Point the DB at a closed port BEFORE requiring the controller (its models build
// the pool). Every model/service call is monkeypatched, so no query runs;
// db.close() at the end releases the pool so the process exits cleanly.
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')

// Unit-test the auth controller's security-sensitive decision logic. These are
// the rules a regression must never silently break:
//   - a tripped honeypot fails generically and is never issued a session;
//   - every credential failure returns the SAME generic message (no user
//     enumeration, no "which field was wrong");
//   - correct password on an inactive account is refused (no session, no TOTP);
//   - a TOTP-enabled user gets a challenge, NOT a session, until the code checks;
//   - registration is gated by the registration mode; a dup username is a 409;
//   - logout always clears the cookie and revokes the session when present.
const ctrl = require('../src/router/v1/auth/auth.controller')
const users = require('../src/model/users/users.model')
const activity = require('../src/model/activity/activity.model')
const settings = require('../src/model/settings/settings.model')
const sessionService = require('../src/auth/session.service')
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: {},
    cleared: [],
    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.cleared.push(name)
      return this
    },
  }
}

// Spies / stubs for the collaborators the controller drives.
let sessionsCreated
const orig = {}
beforeEach(() => {
  sessionsCreated = []
  for (const [mod, name] of [
    [users, 'getRawByUsername'], [users, 'validatePassword'], [users, 'recordLogin'],
    [users, 'getRawById'], [users, 'getById'], [users, 'createUser'], [users, 'isDuplicateUsername'],
    [activity, 'log'], [settings, 'getRegistrationMode'],
    [sessionService, 'createSession'], [sessionService, 'createPartialSession'], [sessionService, 'upgradeSessionAfterTotp'], [sessionService, 'revokeSession'],
    [totp, 'verifyCode'],
    [botScore, 'recordHoneypot'], [botScore, 'recordLoginFailure'],
    [loginProtection, 'recordFailure'], [loginProtection, 'recordSuccess'],
  ]) {
    orig[`${name}`] = orig[`${name}`] || { mod, val: mod[name] }
  }
  // Safe defaults; individual tests override.
  users.recordLogin = async () => {}
  users.getById = async (id) => ({ id, username: 'u', role: 'player' })
  activity.log = async () => {}
  botScore.recordHoneypot = () => {}
  botScore.recordLoginFailure = () => {}
  loginProtection.recordFailure = () => {}
  loginProtection.recordSuccess = () => {}
  sessionService.createSession = (user) => {
    sessionsCreated.push(user)
    return { token: 'session-token' }
  }
})

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: {}, session: null })

// ── honeypot ────────────────────────────────────────────────────────────
test('login: a filled honeypot field fails generically and issues no session', async () => {
  let scored = false
  botScore.recordHoneypot = () => {
    scored = true
  }
  const res = mockRes()
  await ctrl.login(baseReq({ username: 'x', password: 'y', company: 'ACME Bot' }), res)
  assert.equal(res.statusCode, 401)
  assert.equal(res.body.message, 'Incorrect username or password.')
  assert.equal(scored, true)
  assert.equal(sessionsCreated.length, 0)
})

// ── credential failures are indistinguishable ───────────────────────────
test('login: an unknown username and a wrong password return the identical generic failure', async () => {
  users.getRawByUsername = async () => null // unknown user
  users.validatePassword = async () => false
  const res1 = mockRes()
  await ctrl.login(baseReq({ username: 'ghost', password: 'z' }), res1)

  users.getRawByUsername = async () => ({ id: 1, username: 'real', status: 'active' })
  users.validatePassword = async () => false // wrong password
  const res2 = mockRes()
  await ctrl.login(baseReq({ username: 'real', password: 'wrong' }), res2)

  assert.equal(res1.statusCode, 401)
  assert.equal(res2.statusCode, 401)
  assert.deepEqual(res1.body, res2.body) // no enumeration: same message either way
  assert.equal(sessionsCreated.length, 0)
})

// ── inactive account: correct password, still refused ───────────────────
test('login: correct password on a non-active account is a 403 with no session', async () => {
  users.getRawByUsername = async () => ({ id: 1, username: 'banned', status: 'banned' })
  users.validatePassword = async () => true
  const res = mockRes()
  await ctrl.login(baseReq({ username: 'banned', password: 'right' }), res)
  assert.equal(res.statusCode, 403)
  assert.equal(sessionsCreated.length, 0)
})

// ── TOTP gate: password ok but a second factor is required ──────────────
test('login: a TOTP-enabled user gets a challenge, not a session', async () => {
  users.getRawByUsername = async () => ({ id: 5, username: 'safe', status: 'active', totp_enabled: 1 })
  users.validatePassword = async () => true
  sessionService.createPartialSession = () => 'challenge-jwt'
  const res = mockRes()
  await ctrl.login(baseReq({ username: 'safe', password: 'right' }), res)
  assert.equal(res.body.totpRequired, true)
  assert.equal(res.body.challenge, 'challenge-jwt')
  assert.equal(sessionsCreated.length, 0, 'no real session until the code is verified')
  assert.equal(res.cookies['auth_token'] || res.cookies.token, undefined)
})

test('login: a user without TOTP is logged straight in (session + cookie)', async () => {
  users.getRawByUsername = async () => ({ id: 9, username: 'plain', role: 'player', status: 'active', totp_enabled: 0 })
  users.validatePassword = async () => true
  const res = mockRes()
  await ctrl.login(baseReq({ username: 'plain', password: 'right' }), res)
  assert.equal(sessionsCreated.length, 1)
  assert.equal(sessionsCreated[0].id, 9)
  assert.equal(res.body.user.id, 9)
  assert.equal(Object.keys(res.cookies).length, 1, 'a session cookie was set')
})

// ── loginTotp second step ───────────────────────────────────────────────
test('loginTotp: an expired/invalid challenge is a 401 (no user lookup)', async () => {
  sessionService.upgradeSessionAfterTotp = () => null
  let lookedUp = false
  users.getRawById = async () => {
    lookedUp = true
    return null
  }
  const res = mockRes()
  await ctrl.loginTotp(baseReq({ challenge: 'stale', code: '000000' }), res)
  assert.equal(res.statusCode, 401)
  assert.equal(lookedUp, false)
})

test('loginTotp: a wrong code is a 401 and issues no session', async () => {
  sessionService.upgradeSessionAfterTotp = () => ({ id: 5 })
  users.getRawById = async () => ({ id: 5, totp_enabled: 1, totp_secret: 'S' })
  totp.verifyCode = () => false
  const res = mockRes()
  await ctrl.loginTotp(baseReq({ challenge: 'ok', code: '123456' }), res)
  assert.equal(res.statusCode, 401)
  assert.equal(sessionsCreated.length, 0)
})

test('loginTotp: a valid code issues the session with authMethod totp', async () => {
  sessionService.upgradeSessionAfterTotp = () => ({ id: 5 })
  users.getRawById = async () => ({ id: 5, username: 'safe', role: 'admin', totp_enabled: 1, totp_secret: 'S' })
  totp.verifyCode = () => true
  let method
  sessionService.createSession = (user, authMethod) => {
    method = authMethod
    sessionsCreated.push(user)
    return { token: 't' }
  }
  const res = mockRes()
  await ctrl.loginTotp(baseReq({ challenge: 'ok', code: '654321' }), res)
  assert.equal(sessionsCreated.length, 1)
  assert.equal(method, 'totp')
  assert.equal(res.body.user.id, 5)
})

// ── register gating ─────────────────────────────────────────────────────
test('register: refused (403) when the registration mode excludes passwords', async () => {
  settings.getRegistrationMode = async () => 'sso' // password path closed
  const res = mockRes()
  await ctrl.register(baseReq({ username: 'newbie', password: 'pw' }), res)
  assert.equal(res.statusCode, 403)
})

test('register: an invalid username is a 400 before any user is created', async () => {
  settings.getRegistrationMode = async () => 'password'
  let created = false
  users.createUser = async () => {
    created = true
  }
  const res = mockRes()
  await ctrl.register(baseReq({ username: 'x', password: 'pw' }), res) // too short
  assert.equal(res.statusCode, 400)
  assert.equal(created, false)
})

test('register: a duplicate username surfaces as a 409', async () => {
  settings.getRegistrationMode = async () => 'both'
  users.createUser = async () => {
    throw new Error('dup')
  }
  users.isDuplicateUsername = () => true
  const res = mockRes()
  await ctrl.register(baseReq({ username: 'takenname', password: 'pw' }), res)
  assert.equal(res.statusCode, 409)
})

test('register: a valid new player is created and auto-logged-in', async () => {
  settings.getRegistrationMode = async () => 'password'
  users.createUser = async ({ username, role }) => ({ id: 30, username, role })
  const res = mockRes()
  await ctrl.register(baseReq({ username: 'freshplayer', password: 'pw', email: ' a@b.c ' }), res)
  assert.equal(res.body.user.id, 30)
  assert.equal(res.body.user.role, 'player')
  assert.equal(sessionsCreated.length, 1)
})

// ── logout always clears, revokes when a session exists ─────────────────
test('logout: clears the cookie and revokes the server-side session when present', async () => {
  let revoked = null
  sessionService.revokeSession = async (id) => {
    revoked = id
  }
  const req = { ...baseReq(), session: { sessionId: 'jti-1', userId: 7, expiresAt: 123 } }
  const res = mockRes()
  await ctrl.logout(req, res)
  assert.equal(res.cleared.length, 1, 'cookie cleared')
  assert.equal(revoked, 'jti-1')
})

test('logout: still succeeds (cookie cleared) when there is no session to revoke', async () => {
  let revokeCalled = false
  sessionService.revokeSession = async () => {
    revokeCalled = true
  }
  const res = mockRes()
  await ctrl.logout(baseReq(), res)
  assert.equal(res.cleared.length, 1)
  assert.equal(revokeCalled, false)
  assert.match(res.body.message, /logged out/i)
})

test('logout: a revocation error never fails the logout', async () => {
  sessionService.revokeSession = async () => {
    throw new Error('store unreachable')
  }
  const req = { ...baseReq(), session: { sessionId: 'jti', userId: 1, expiresAt: 1 } }
  const res = mockRes()
  await ctrl.logout(req, res) // must resolve
  assert.equal(res.cleared.length, 1)
  assert.match(res.body.message, /logged out/i)
})

// ── me ──────────────────────────────────────────────────────────────────
test('me: returns 401 when the backing user no longer exists', async () => {
  users.getById = async () => null
  const res = mockRes()
  await ctrl.me({ ...baseReq(), user: { id: 99 } }, res)
  assert.equal(res.statusCode, 401)
})
