'use strict';

// Phase 1 — authService is the untested auth core: password verification, session
// storage, rotation, and invalidation. These pin the security invariants that a
// future refactor must not silently break: passwords round-trip through bcrypt,
// sessions are stored HASHED (never the raw token), the session lifecycle
// (create/lookup/expire/rotate/invalidate/prune) behaves, and rotation enforces
// ownership.
const test = require('node:test');
const assert = require('node:assert/strict');
const os = require('node:os');
const path = require('node:path');
const fs = require('node:fs');
const crypto = require('node:crypto');

const dbPath = path.join(os.tmpdir(), `bill-tracker-authsvc-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;

const { getDb, closeDb } = require('../db/database');
const auth = require('../services/authService');

const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
let db, userId, otherUserId;

test.before(async () => {
  db = getDb();
  const hash = await auth.hashPassword('correct-horse-battery');
  userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('alice', ?, 'user', 1)").run(hash).lastInsertRowid;
  otherUserId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('bob', 'x', 'user', 1)").run().lastInsertRowid;
});
test.after(() => {
  closeDb();
  for (const s of ['', '-wal', '-shm']) { try { fs.rmSync(dbPath + s); } catch {} }
});

test('hashPassword + login: correct password authenticates, wrong password fails, unknown user is null', async () => {
  const ok = await auth.login('alice', 'correct-horse-battery');
  assert.ok(ok?.sessionId, 'correct password yields a session');
  assert.equal(ok.user.id, userId);

  const bad = await auth.login('alice', 'wrong');
  assert.deepEqual({ error: bad?.error, userId: bad?.userId }, { error: 'bad_password', userId }, 'wrong password → bad_password + userId (for lockout logging)');

  assert.equal(await auth.login('nobody', 'whatever'), null, 'unknown user → null (no enumeration)');
});

test('sessions are stored HASHED, never as the raw token', async () => {
  const { sessionId } = await auth.createSession(userId);
  const hashedRow = db.prepare('SELECT id FROM sessions WHERE id = ?').get(sha256(sessionId));
  const rawRow    = db.prepare('SELECT id FROM sessions WHERE id = ?').get(sessionId);
  assert.ok(hashedRow, 'the DB stores sha256(sessionId)');
  assert.equal(rawRow, undefined, 'the raw session token is never persisted');
});

test('getSessionUser: valid → user, bogus → null, expired → null', async () => {
  const { sessionId } = await auth.createSession(userId);
  assert.equal(auth.getSessionUser(sessionId)?.id, userId);
  assert.equal(auth.getSessionUser('bogus-token'), null);

  db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)')
    .run(sha256('expired-raw'), userId, '2000-01-01 00:00:00');
  assert.equal(auth.getSessionUser('expired-raw'), null, 'an expired session is not honoured');
});

test('rotateSessionId: old dies, new lives, and ownership is enforced', async () => {
  const { sessionId: oldId } = await auth.createSession(userId);
  const newId = auth.rotateSessionId(oldId, userId);
  assert.ok(newId && newId !== oldId, 'a fresh session id is issued');
  assert.equal(auth.getSessionUser(oldId), null, 'the old session is invalidated');
  assert.equal(auth.getSessionUser(newId)?.id, userId, 'the new session is valid');

  // Ownership: cannot rotate a session that isn't yours, and a dead session can't rotate.
  const { sessionId: victimId } = await auth.createSession(userId);
  assert.equal(auth.rotateSessionId(victimId, otherUserId), null, 'rotation requires the session to belong to the user');
  assert.equal(auth.rotateSessionId(oldId, userId), null, 'an already-invalidated session cannot be rotated');
});

test('invalidateOtherSessions keeps the current session and kills the rest', async () => {
  // Fresh user so other tests\' sessions don\'t interfere with the count.
  const uid = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('carol', 'x', 'user', 1)").run().lastInsertRowid;
  const a = await auth.createSession(uid);
  const b = await auth.createSession(uid);
  auth.invalidateOtherSessions(uid, a.sessionId);
  assert.equal(auth.getSessionUser(a.sessionId)?.id, uid, 'kept session survives');
  assert.equal(auth.getSessionUser(b.sessionId), null, 'other sessions are invalidated');
});

test('pruneExpiredSessions removes only expired rows', async () => {
  const uid = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('dave', 'x', 'user', 1)").run().lastInsertRowid;
  const { sessionId: live } = await auth.createSession(uid);
  db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)')
    .run(sha256('dead-raw'), uid, '2000-01-01 00:00:00');

  auth.pruneExpiredSessions();
  assert.equal(auth.getSessionUser(live)?.id, uid, 'live session survives the prune');
  assert.equal(db.prepare('SELECT id FROM sessions WHERE id = ?').get(sha256('dead-raw')), undefined, 'expired session pruned');
});

test('recordFailedLogin writes a success=0 history row for the known account', () => {
  const uid = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('erin', 'x', 'user', 1)").run().lastInsertRowid;
  auth.recordFailedLogin(uid, '203.0.113.7', 'Mozilla/5.0 test');
  const row = db.prepare('SELECT success FROM user_login_history WHERE user_id = ? ORDER BY id DESC LIMIT 1').get(uid);
  assert.equal(row?.success, 0, 'failed attempt recorded with success=0');
  auth.recordFailedLogin(null, '203.0.113.7', 'ua'); // no-op on missing userId, must not throw
});
