'use strict';

// Password login must be bounded per ACCOUNT, not only per IP. The IP limiter in server.js
// bounds one noisy source; it bounds nothing against a distributed one, and it is only as
// accurate as the deployment's proxy configuration.
//
// Two properties matter and are easy to get wrong:
//   - a locked account must answer EXACTLY like a wrong password, or the endpoint becomes
//     an account-existence oracle (a distinct 429 says "this account is real");
//   - a correct password must clear the counter immediately — including for accounts that
//     then go on to a TOTP or email-verification step, which return before issueSession.

const os = require('node:os');
const path = require('node:path');
const fs = require('node:fs');
const crypto = require('node:crypto');
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'st-loginlock-'));
process.env.DATA_DIR = TMP;
process.env.SELF_HOSTED = 'true';
process.env.NODE_ENV = 'test';

const { test } = require('node:test');
const assert = require('node:assert/strict');
const lockout = require('../lib/login-lockout');

// ---------------------------------------------------------------------------
// Unit: the counter itself (mirrors test/pair-lockout.test.js — unique key per
// test, because the Map is module-level)
// ---------------------------------------------------------------------------
const k = () => 'user-' + crypto.randomBytes(6).toString('hex');

test('an account is not locked until it crosses the threshold', () => {
  const key = k();
  for (let i = 0; i < lockout.MAX_FAILS - 1; i++) lockout.recordFailure(key);
  assert.equal(lockout.isLocked(key), false, `${lockout.MAX_FAILS - 1} failures must not lock`);
});

test('crossing the threshold locks the account for the full window', () => {
  const key = k();
  const t0 = 1_000_000;
  for (let i = 0; i < lockout.MAX_FAILS; i++) lockout.recordFailure(key, t0);
  assert.equal(lockout.isLocked(key, t0), true, 'locked at the threshold');
  assert.equal(lockout.isLocked(key, t0 + lockout.LOCKOUT_MS - 1), true, 'still locked inside the window');
  assert.equal(lockout.isLocked(key, t0 + lockout.LOCKOUT_MS + 1), false, 'released after the window');
});

test('a correct password clears the counter', () => {
  const key = k();
  for (let i = 0; i < lockout.MAX_FAILS - 1; i++) lockout.recordFailure(key);
  lockout.reset(key);
  for (let i = 0; i < lockout.MAX_FAILS - 1; i++) lockout.recordFailure(key);
  assert.equal(lockout.isLocked(key), false, 'reset gave the account its full budget back');
});

test('accounts are independent — one locked account does not lock another', () => {
  const a = k(), b = k();
  for (let i = 0; i < lockout.MAX_FAILS; i++) lockout.recordFailure(a);
  assert.equal(lockout.isLocked(a), true);
  assert.equal(lockout.isLocked(b), false, 'a different account is unaffected');
});

test('an unknown key is never locked', () => {
  assert.equal(lockout.isLocked(k()), false);
});

// ---------------------------------------------------------------------------
// Route: the lockout is wired into POST /api/auth/login, and is not an oracle
// ---------------------------------------------------------------------------
const http = require('node:http');
const express = require('express');
const { db } = require('../db/database');
const bcrypt = require('bcryptjs');

let server, base;
const PW = 'Passw0rd123';

function post(body) {
  const data = JSON.stringify(body);
  return new Promise((resolve, reject) => {
    const req = http.request(base + '/login', {
      method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
    }, (res) => {
      let out = '';
      res.on('data', (c) => (out += c));
      res.on('end', () => resolve({ status: res.statusCode, body: out ? JSON.parse(out) : null }));
    });
    req.on('error', reject);
    req.end(data);
  });
}

test('route: a locked account is indistinguishable from a wrong password', async (t) => {
  const app = express();
  app.use(express.json());
  app.use('/', require('../routes/auth'));
  server = http.createServer(app);
  await new Promise((r) => server.listen(0, r));
  base = `http://127.0.0.1:${server.address().port}`;
  t.after(() => new Promise((r) => server.close(r)));

  const email = 'lock' + crypto.randomBytes(5).toString('hex') + '@x.local';
  const id = crypto.randomUUID();
  db.prepare("INSERT INTO users (id, email, password_hash, auth_provider, plan_id, email_verified) VALUES (?,?,?,'local','free',1)")
    .run(id, email, bcrypt.hashSync(PW, 10));

  // Baseline: what a wrong password looks like.
  const wrong = await post({ email, password: 'nope' });
  assert.equal(wrong.status, 401);
  assert.equal(wrong.body.error, 'Invalid email or password');

  // Drive it past the threshold.
  for (let i = 0; i < lockout.MAX_FAILS + 2; i++) await post({ email, password: 'nope' });
  assert.equal(lockout.isLocked(id), true, 'the account is locked after repeated failures');

  // The CORRECT password is now refused — and the response is byte-identical to a wrong
  // one, so an attacker learns nothing about whether the account exists or is locked.
  const locked = await post({ email, password: PW });
  assert.equal(locked.status, wrong.status, 'locked status must match the wrong-password status');
  assert.deepEqual(locked.body, wrong.body, 'locked body must match the wrong-password body exactly');

  // A non-existent account still answers the same way.
  const ghost = await post({ email: 'ghost' + crypto.randomBytes(4).toString('hex') + '@x.local', password: 'nope' });
  assert.equal(ghost.status, wrong.status);
  assert.deepEqual(ghost.body, wrong.body, 'unknown account is indistinguishable too');
});

test('route: a correct password clears the counter before any TOTP/verify step', async (t) => {
  const app = express();
  app.use(express.json());
  app.use('/', require('../routes/auth'));
  const srv = http.createServer(app);
  await new Promise((r) => srv.listen(0, r));
  base = `http://127.0.0.1:${srv.address().port}`;
  t.after(() => new Promise((r) => srv.close(r)));

  const email = 'clear' + crypto.randomBytes(5).toString('hex') + '@x.local';
  const id = crypto.randomUUID();
  db.prepare("INSERT INTO users (id, email, password_hash, auth_provider, plan_id, email_verified) VALUES (?,?,?,'local','free',1)")
    .run(id, email, bcrypt.hashSync(PW, 10));

  for (let i = 0; i < lockout.MAX_FAILS - 1; i++) await post({ email, password: 'nope' });
  const ok = await post({ email, password: PW });
  assert.equal(ok.status, 200, 'the correct password still logs in');
  assert.equal(lockout.isLocked(id), false);

  // Full budget restored: another MAX_FAILS-1 failures must still not lock.
  for (let i = 0; i < lockout.MAX_FAILS - 1; i++) await post({ email, password: 'nope' });
  assert.equal(lockout.isLocked(id), false, 'the successful login reset the counter');
});
