/* test/activeSessions.test.js
 *
 * Tests for the active-sessions list and revoke endpoints.
 *
 *  GET    /auth/sessions                — list active sessions
 *  DELETE /auth/sessions/:sid           — revoke one
 *  POST   /auth/sessions/revoke-others  — revoke all but current
 *
 * Hardening goals:
 *   - A user can only see their OWN sessions
 *   - A user can only revoke their OWN sessions (cannot revoke
 *     another user's session by guessing sids)
 *   - Cannot revoke the CURRENT session via DELETE — must use /logout
 *     (avoids the foot-gun of "I clicked revoke on my own row and
 *     now I'm signed out of the action that just signed me out")
 */
'use strict';

const assert = require('assert');
const http  = require('http');
const Database = require('better-sqlite3');

const HOST = '127.0.0.1';
const PORT = 3000;

const TESTS = [];
function test(name, fn) { TESTS.push({ name, fn, requiresServer: true }); }

// ---- HTTP helper -----------------------------------------------------
let ipCounter = 0;
function fakeIp() {
  ipCounter++;
  return '10.99.' + Math.floor(ipCounter / 256) + '.' + (ipCounter % 256);
}

function request(opts) {
  const { method = 'GET', path = '/', headers = {}, body = null } = opts;
  return new Promise((resolve, reject) => {
    const req = http.request({
      host: HOST, port: PORT, path, method,
      agent: new http.Agent({ keepAlive: false }),
      headers: { 'User-Agent': 'sess-test/1.0', 'Connection': 'close', 'X-Forwarded-Proto': 'https', 'X-Forwarded-For': fakeIp(), ...headers },
    }, (res) => {
      let chunks = [];
      res.on('data', c => chunks.push(c));
      res.on('end', () => resolve({
        status: res.statusCode,
        headers: res.headers,
        body: Buffer.concat(chunks).toString('utf8'),
      }));
    });
    req.on('error', reject);
    if (body) req.write(body);
    req.end();
  });
}

function nciiSid(headers) {
  const sc = headers['set-cookie'];
  if (!sc) return null;
  const arr = Array.isArray(sc) ? sc : [sc];
  const c = arr.find(c => /^ncii\.sid=/.test(c));
  return c ? c.split(';')[0] : null;
}
function csrfToken(headers) {
  const sc = headers['set-cookie'];
  if (!sc) return null;
  const arr = Array.isArray(sc) ? sc : [sc];
  const c = arr.find(c => /^ncsrf=/.test(c));
  return c ? c.split(';')[0].split('=')[1] : null;
}
async function getCsrf() {
  const r = await request({ path: '/login' });
  return csrfToken(r.headers);
}
async function register(suffix) {
  const email = `sess-${suffix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@test.local`;
  const csrf = await getCsrf();
  const reg = await request({
    method: 'POST', path: '/auth/register',
    headers: { 'content-type': 'application/json', 'x-csrf-token': csrf },
    body: JSON.stringify({ name: 'Sess Test', email, password: 'TestPass!1234' }),
  });
  return { email, status: reg.status, body: reg.body, sid: nciiSid(reg.headers), csrf: csrfToken(reg.headers) };
}

// Pull the raw sid from the cookie string. This is what
// `req.sessionID` reports server-side (express-session strips
// the `s:` prefix and the signature before assigning).
function rawSid(cookieValue) {
  if (!cookieValue) return null;
  // cookie is "ncii.sid=s%3A<random>.<signature>" — decode the URI,
  // strip the "s:" prefix and the signature, return just the random part.
  const decoded = decodeURIComponent(cookieValue.replace(/^ncii\.sid=/, ''));
  // decoded looks like "s:9q8Hq0RFl2UhN03fFuSa3gjk71yWvZVA.jVjAZJt..."
  // req.sessionID is "9q8Hq0RFl2UhN03fFuSa3gjk71yWvZVA" (no s:, no signature)
  let v = decoded;
  if (v.startsWith('s:')) v = v.slice(2);
  const idx = v.indexOf('.');
  return idx >= 0 ? v.slice(0, idx) : v;
}

// =====================================================================
// 1. GET /auth/sessions
// =====================================================================

test('GET /auth/sessions: requires login', async () => {
  const r = await request({ path: '/auth/sessions', headers: { 'accept': 'application/json' } });
  assert.ok([401, 403].includes(r.status), 'expected 401/403, got ' + r.status);
});

test('GET /auth/sessions: returns the current session marked is_current', async () => {
  const u = await register('list1');
  if (u.status !== 200) return;
  const r = await request({
    path: '/auth/sessions',
    headers: { 'cookie': u.sid, 'accept': 'application/json' },
  });
  assert.strictEqual(r.status, 200);
  const body = JSON.parse(r.body);
  assert.ok(body.sessions.length >= 1, 'at least one session');
  const current = body.sessions.find(s => s.is_current);
  assert.ok(current, 'current session must be marked is_current');
  assert.ok(current.expires_at > Date.now(), 'expires_at is in the future');
  assert.ok(current.device, 'device info present');
  assert.ok(current.device.label, 'device label parsed');
});

test('GET /auth/sessions: device label parses User-Agent', async () => {
  // Register with a Safari-on-macOS UA so the session's created_ua
  // is set correctly. (The created_ua is captured at register/login,
  // not at every request.)
  const csrf = await getCsrf();
  const email = 'sess-ua-' + Date.now() + '@test.local';
  const reg = await request({
    method: 'POST', path: '/auth/register',
    headers: { 'content-type': 'application/json', 'x-csrf-token': csrf, 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15' },
    body: JSON.stringify({ name: 'UA Test', email, password: 'TestPass!1234' }),
  });
  if (reg.status !== 200) return;
  const sid = nciiSid(reg.headers);
  const r = await request({
    path: '/auth/sessions',
    headers: { 'cookie': sid, 'accept': 'application/json' },
  });
  const body = JSON.parse(r.body);
  const current = body.sessions.find(s => s.is_current);
  assert.ok(current, 'current session returned');
  assert.ok(current.user_agent && current.user_agent.includes('Safari'), 'UA recorded');
  // The device label should reflect the parsed User-Agent
  assert.ok(current.device.label && /Safari|macOS/.test(current.device.label),
    'device label should mention Safari or macOS, got: ' + current.device.label);
});

// =====================================================================
// 2. DELETE /auth/sessions/:sid
// =====================================================================

test('DELETE /auth/sessions/:sid: requires login', async () => {
  const r = await request({
    method: 'DELETE', path: '/auth/sessions/abc',
    headers: { 'accept': 'application/json' },
  });
  assert.ok([401, 403].includes(r.status));
});

test('DELETE /auth/sessions/:sid: cannot revoke the current session via this endpoint', async () => {
  const u = await register('del1');
  if (u.status !== 200) return;
  const mySid = rawSid(u.sid);
  const r = await request({
    method: 'DELETE', path: '/auth/sessions/' + mySid,
    headers: { 'cookie': u.sid, 'content-type': 'application/json', 'x-csrf-token': u.csrf, 'accept': 'application/json' },
  });
  assert.strictEqual(r.status, 400, 'must reject self-revoke with 400, got ' + r.status);
  assert.ok(/CANNOT_REVOKE_CURRENT/.test(r.body), 'error code in body');
});

test('DELETE /auth/sessions/:sid: returns 404 for unknown sid', async () => {
  const u = await register('del2');
  if (u.status !== 200) return;
  const r = await request({
    method: 'DELETE', path: '/auth/sessions/thisSidDoesNotExist12345',
    headers: { 'cookie': u.sid, 'content-type': 'application/json', 'x-csrf-token': u.csrf, 'accept': 'application/json' },
  });
  assert.strictEqual(r.status, 404);
});

test('DELETE /auth/sessions/:sid: cannot revoke another user\'s session', async () => {
  // Register two users. A tries to revoke B's session.
  const u1 = await register('del3a');
  const u2 = await register('del3b');
  if (u1.status !== 200 || u2.status !== 200) return;
  const u2Sid = rawSid(u2.sid);
  const r = await request({
    method: 'DELETE', path: '/auth/sessions/' + u2Sid,
    headers: { 'cookie': u1.sid, 'content-type': 'application/json', 'x-csrf-token': u1.csrf, 'accept': 'application/json' },
  });
  // Either 404 (we mask the existence) or 403 — both are safe
  assert.ok([403, 404].includes(r.status), 'expected 403/404, got ' + r.status);
  // u2's session is still alive
  const me = await request({
    path: '/auth/me',
    headers: { 'cookie': u2.sid, 'accept': 'application/json' },
  });
  assert.strictEqual(me.status, 200, 'u2 session must still be valid');
});

test('DELETE /auth/sessions/:sid: success — kills another session for the same user', async () => {
  // We can't easily mint a 2nd session via the live server (login is
  // gated on email_verified). So we directly insert a second session row
  // into the session store, then verify the DELETE endpoint can remove it.
  const u = await register('del4');
  if (u.status !== 200) return;
  // Open a second session row in the store
  const sid2 = 'fakeSidForSecondDevice' + Date.now();
  const db = new Database('data/sessions.sqlite');
  const userId = JSON.parse(new Database('data/ncii.sqlite', { readonly: true })
    .prepare('SELECT id FROM users WHERE email = ?').get(u.email).id || '{}'); // n/a
  // Just look up the user ID by email
  const nciiDb = new Database('data/ncii.sqlite', { readonly: true });
  const realId = nciiDb.prepare('SELECT id FROM users WHERE email = ?').get(u.email).id;
  nciiDb.close();
  db.prepare(`
    INSERT INTO sessions (sid, expired, sess) VALUES (?, ?, ?)
  `).run(sid2, Date.now() + 86400000, JSON.stringify({
    cookie: { originalMaxAge: 86400000, expires: new Date(Date.now() + 86400000).toISOString() },
    userId: realId,
    created_ip: '10.0.0.99',
    created_ua: 'SecondDevice/1.0',
    created_at: Date.now(),
    password_at: Date.now(),
  }));
  db.close();
  // Now revoke it
  const r = await request({
    method: 'DELETE', path: '/auth/sessions/' + sid2,
    headers: { 'cookie': u.sid, 'content-type': 'application/json', 'x-csrf-token': u.csrf, 'accept': 'application/json' },
  });
  assert.strictEqual(r.status, 200, 'delete should succeed, got ' + r.status);
  // Verify it's gone from the DB
  const db2 = new Database('data/sessions.sqlite', { readonly: true });
  const row = db2.prepare('SELECT sid FROM sessions WHERE sid = ?').get(sid2);
  db2.close();
  assert.strictEqual(row, undefined, 'session must be deleted from store');
});

// =====================================================================
// 3. POST /auth/sessions/revoke-others
// =====================================================================

test('POST /auth/sessions/revoke-others: requires login', async () => {
  const r = await request({
    method: 'POST', path: '/auth/sessions/revoke-others',
    headers: { 'accept': 'application/json' },
  });
  assert.ok([401, 403].includes(r.status));
});

test('POST /auth/sessions/revoke-others: removes all OTHER sessions, keeps current', async () => {
  const u = await register('ro1');
  if (u.status !== 200) return;
  const realId = new Database('data/ncii.sqlite', { readonly: true })
    .prepare('SELECT id FROM users WHERE email = ?').get(u.email).id;
  // Insert 3 extra sessions
  const db = new Database('data/sessions.sqlite');
  const extras = [];
  for (let i = 0; i < 3; i++) {
    const sid = 'extraSid' + Date.now() + '-' + i;
    extras.push(sid);
    db.prepare(`INSERT INTO sessions (sid, expired, sess) VALUES (?, ?, ?)`)
      .run(sid, Date.now() + 86400000, JSON.stringify({
        cookie: { originalMaxAge: 86400000 },
        userId: realId, password_at: Date.now(),
        created_ip: '10.0.0.' + (i + 1),
        created_ua: 'Extra' + i,
        created_at: Date.now(),
      }));
  }
  db.close();
  // Call revoke-others
  const r = await request({
    method: 'POST', path: '/auth/sessions/revoke-others',
    headers: { 'cookie': u.sid, 'content-type': 'application/json', 'x-csrf-token': u.csrf, 'accept': 'application/json' },
  });
  assert.strictEqual(r.status, 200);
  const body = JSON.parse(r.body);
  assert.ok(body.revoked >= 3, 'must have revoked at least 3, got ' + body.revoked);
  // Current session must still work
  const me = await request({
    path: '/auth/me',
    headers: { 'cookie': u.sid, 'accept': 'application/json' },
  });
  assert.strictEqual(me.status, 200, 'current session must still be valid');
  // Extras must be gone
  const db2 = new Database('data/sessions.sqlite', { readonly: true });
  for (const sid of extras) {
    const row = db2.prepare('SELECT sid FROM sessions WHERE sid = ?').get(sid);
    assert.strictEqual(row, undefined, 'extra session must be deleted: ' + sid);
  }
  db2.close();
});

test('POST /auth/sessions/revoke-others: does NOT touch other users\' sessions', async () => {
  const u1 = await register('ro2a');
  const u2 = await register('ro2b');
  if (u1.status !== 200 || u2.status !== 200) return;
  const realId2 = new Database('data/ncii.sqlite', { readonly: true })
    .prepare('SELECT id FROM users WHERE email = ?').get(u2.email).id;
  // Insert a session for u2
  const db = new Database('data/sessions.sqlite');
  const u2ExtraSid = 'u2Extra' + Date.now();
  db.prepare(`INSERT INTO sessions (sid, expired, sess) VALUES (?, ?, ?)`)
    .run(u2ExtraSid, Date.now() + 86400000, JSON.stringify({
      cookie: { originalMaxAge: 86400000 },
      userId: realId2, password_at: Date.now(),
    }));
  db.close();
  // u1 calls revoke-others
  const r = await request({
    method: 'POST', path: '/auth/sessions/revoke-others',
    headers: { 'cookie': u1.sid, 'content-type': 'application/json', 'x-csrf-token': u1.csrf, 'accept': 'application/json' },
  });
  assert.strictEqual(r.status, 200);
  // u2's extra session must still exist
  const db2 = new Database('data/sessions.sqlite', { readonly: true });
  const row = db2.prepare('SELECT sid FROM sessions WHERE sid = ?').get(u2ExtraSid);
  db2.close();
  assert.ok(row, 'u2 session must NOT be touched by u1 revoke-others');
});

module.exports = TESTS;
