// 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 invite acceptance. The two invariants that matter most:
//   - the new user is created at the invite's PRESET role (an invite is its own
//     authority — it bypasses the registration gate but not the role);
//   - a lost double-accept race rolls back the just-created user, so a spent
//     invite can never yield two accounts.
// Plus the usual guards: honeypot, invalid token, invalid username, dup username.
const ctrl = require('../src/router/v1/auth/invite.controller')
const authCtrl = require('../src/router/v1/auth/auth.controller')
const invites = require('../src/model/invites/invites.model')
const users = require('../src/model/users/users.model')
const activity = require('../src/model/activity/activity.model')
const sessionService = require('../src/auth/session.service')
const db = require('../src/utils/db')

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

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

const orig = {}
beforeEach(() => {
  for (const [mod, name] of [
    [invites, 'findValidByToken'], [invites, 'accept'],
    [users, 'createUser'], [users, 'isDuplicateUsername'], [users, 'remove'], [users, 'recordLogin'],
    [activity, 'log'], [sessionService, 'createSession'],
  ]) {
    orig[name] = { mod, val: mod[name] }
  }
  activity.log = async () => {}
  users.recordLogin = async () => {}
  sessionService.createSession = () => ({ token: 'session-token' })
})
afterEach(() => {
  for (const key of Object.keys(orig)) {
    orig[key].mod[key] = orig[key].val
    delete orig[key]
  }
})

const HONEYPOT = authCtrl.HONEYPOT_FIELD
const req = (body = {}, token = 'tok') => ({ body, ip: '10.0.0.1', headers: {}, params: { token } })

// ── getInvite ───────────────────────────────────────────────────────────
test('getInvite 404s an invalid token and otherwise returns only email + role', async () => {
  invites.findValidByToken = async () => null
  const res404 = mockRes()
  await ctrl.getInvite(req({}, 'bad'), res404)
  assert.equal(res404.statusCode, 404)

  invites.findValidByToken = async () => ({ id: 1, email: 'invitee@x.io', role: 'moderator', token_hash: 'SECRET' })
  const resOk = mockRes()
  await ctrl.getInvite(req({}, 'good'), resOk)
  assert.deepEqual(resOk.body, { email: 'invitee@x.io', role: 'moderator' }) // no id/token/hash
})

// ── acceptInvite guards ─────────────────────────────────────────────────
test('acceptInvite rejects a tripped honeypot with a 400 before any lookup', async () => {
  let lookedUp = false
  invites.findValidByToken = async () => {
    lookedUp = true
    return null
  }
  const res = mockRes()
  await ctrl.acceptInvite(req({ username: 'x', password: 'p', [HONEYPOT]: 'bot' }), res)
  assert.equal(res.statusCode, 400)
  assert.equal(lookedUp, false)
})

test('acceptInvite 404s an invalid/expired invite token', async () => {
  invites.findValidByToken = async () => null
  const res = mockRes()
  await ctrl.acceptInvite(req({ username: 'validname', password: 'pw' }), res)
  assert.equal(res.statusCode, 404)
})

test('acceptInvite 400s an invalid username without creating a user', async () => {
  invites.findValidByToken = async () => ({ id: 1, email: 'a@x.io', role: 'editor' })
  let created = false
  users.createUser = async () => {
    created = true
  }
  const res = mockRes()
  await ctrl.acceptInvite(req({ username: 'x', password: 'pw' }), res) // too short
  assert.equal(res.statusCode, 400)
  assert.equal(created, false)
})

// ── the preset-role invariant ───────────────────────────────────────────
test('acceptInvite creates the user at the invite role (not player) and logs them in', async () => {
  invites.findValidByToken = async () => ({ id: 2, email: 'mod@x.io', role: 'moderator' })
  let createArgs
  users.createUser = async (args) => {
    createArgs = args
    return { id: 50, username: args.username, role: args.role }
  }
  invites.accept = async () => true
  const res = mockRes()
  await ctrl.acceptInvite(req({ username: 'newmod', password: 'pw' }), res)
  assert.equal(createArgs.role, 'moderator') // preset role carried through
  assert.equal(createArgs.email, 'mod@x.io') // email comes from the invite, not the body
  assert.equal(createArgs.emailVerified, true) // using the link proves control of the address
  assert.equal(res.body.user.id, 50)
  assert.equal(Object.keys(res.cookies).length, 1, 'a session cookie was set')
})

// ── dup username ────────────────────────────────────────────────────────
test('acceptInvite surfaces a duplicate username as a 409', async () => {
  invites.findValidByToken = async () => ({ id: 3, email: 'a@x.io', role: 'player' })
  users.createUser = async () => {
    throw new Error('dup')
  }
  users.isDuplicateUsername = () => true
  const res = mockRes()
  await ctrl.acceptInvite(req({ username: 'takenname', password: 'pw' }), res)
  assert.equal(res.statusCode, 409)
})

// ── the double-accept race rolls back the created user ──────────────────
test('acceptInvite rolls back the new user and 409s when it loses the accept race', async () => {
  invites.findValidByToken = async () => ({ id: 4, email: 'a@x.io', role: 'player' })
  users.createUser = async () => ({ id: 77, username: 'racer', role: 'player' })
  invites.accept = async () => false // someone else consumed the invite first
  let removed = null
  users.remove = async (id) => {
    removed = id
  }
  const res = mockRes()
  await ctrl.acceptInvite(req({ username: 'racername', password: 'pw' }), res)
  assert.equal(res.statusCode, 409)
  assert.equal(removed, 77, 'the orphaned user is deleted — a spent invite never yields two accounts')
})
