/* test/geo.test.js
 *
 * Tests for the offline IP geo lookup + session integration.
 *
 * The geo lookup uses the MaxMind GeoLite2 DB bundled with
 * `geoip-lite`. No network calls — all lookups are in-process.
 *
 * Categories:
 *   1. Pure service (services/geo.js)
 *      - lookup() returns { country, country_name, region, city, ll } for known IPs
 *      - lookup() returns null for loopback / invalid / IPv6 link-local
 *      - label() composes 'City, Country' from the lookup
 *      - flag() returns the right regional indicator emoji
 *   2. Session integration (auth.js sets created_geo on login)
 *      - GET /auth/sessions returns sessions with `geo` field
 *      - A 2nd session from a different IP has a different geo
 *   3. Defensive: geo never blocks login (try/catch around the lookup)
 */
'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': 'geo-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, ip) {
  const email = `geo-${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, 'X-Forwarded-For': ip },
    body: JSON.stringify({ name: 'Geo Test', email, password: 'TestPass!1234' }),
  });
  return { email, status: reg.status, body: reg.body, sid: nciiSid(reg.headers) };
}

// =====================================================================
// 1. Pure service
// =====================================================================

test('lookup: known public IP returns country', () => {
  const geo = require('../services/geo');
  // 8.8.8.8 is Google Public DNS — should resolve to US
  const r = geo.lookup('8.8.8.8');
  assert.ok(r, 'lookup must return non-null for 8.8.8.8');
  assert.strictEqual(r.country, 'US');
  assert.strictEqual(r.country_name, 'United States');
});

test('lookup: loopback returns null', () => {
  const geo = require('../services/geo');
  assert.strictEqual(geo.lookup('127.0.0.1'), null);
});

test('lookup: private RFC1918 ranges return null', () => {
  const geo = require('../services/geo');
  assert.strictEqual(geo.lookup('10.0.0.1'), null);
  assert.strictEqual(geo.lookup('192.168.1.1'), null);
  assert.strictEqual(geo.lookup('172.16.0.1'), null);
});

test('lookup: IPv6 loopback returns null', () => {
  const geo = require('../services/geo');
  assert.strictEqual(geo.lookup('::1'), null);
});

test('lookup: malformed input returns null (no throw)', () => {
  const geo = require('../services/geo');
  assert.strictEqual(geo.lookup('not-an-ip'), null);
  assert.strictEqual(geo.lookup(''), null);
  assert.strictEqual(geo.lookup(null), null);
  assert.strictEqual(geo.lookup(undefined), null);
  assert.strictEqual(geo.lookup(12345), null);
});

test('lookup: zone identifier stripped (fe80::1%eth0)', () => {
  const geo = require('../services/geo');
  const r = geo.lookup('fe80::1%eth0');
  // Should not throw; result may be null (link-local) but call is safe
  assert.ok(r === null || typeof r === 'object');
});

test('label: composes City, Country', () => {
  const geo = require('../services/geo');
  const r = geo.label({ country: 'US', country_name: 'United States', city: 'Mountain View', region: 'CA' });
  assert.strictEqual(r, 'Mountain View, United States');
});

test('label: falls back to country when no city', () => {
  const geo = require('../services/geo');
  const r = geo.label({ country: 'DE', country_name: 'Germany' });
  assert.strictEqual(r, 'Germany');
});

test('label: null input returns null', () => {
  const geo = require('../services/geo');
  assert.strictEqual(geo.label(null), null);
  assert.strictEqual(geo.label({}), null);
});

test('flag: returns regional indicator emoji for valid code', () => {
  const geo = require('../services/geo');
  assert.strictEqual(geo.flag('US'), '\u{1F1FA}\u{1F1F8}'); // 🇺🇸
  assert.strictEqual(geo.flag('LK'), '\u{1F1F1}\u{1F1F0}'); // 🇱🇰
  assert.strictEqual(geo.flag('DE'), '\u{1F1E9}\u{1F1EA}'); // 🇩🇪
});

test('flag: empty for non-2-char input', () => {
  const geo = require('../services/geo');
  assert.strictEqual(geo.flag(''), '');
  assert.strictEqual(geo.flag('U'), '');
  assert.strictEqual(geo.flag('USA'), '');  // 3 chars
  assert.strictEqual(geo.flag('US1'), '');
  // Note: any 2 ASCII letters produce a valid regional-indicator
  // pair by Unicode spec (🇽🇽 for 'XX' etc). That's harmless —
  // geo never returns unassigned country codes.
});

// =====================================================================
// 2. Session integration
// =====================================================================

test('GET /auth/sessions: includes geo field per session', async () => {
  const u = await register('gi1', '8.8.8.8');
  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 sess = body.sessions[0];
  assert.ok(sess.geo, 'session must include geo field');
  assert.strictEqual(sess.geo.country, 'US');
});

test('GET /auth/sessions: 2nd session from different IP has different geo', async () => {
  // Register with US IP
  const u = await register('gi2', '8.8.8.8');
  if (u.status !== 200) return;
  // Insert a 2nd session row directly (login is gated on email_verified)
  const realId = new Database('data/ncii.sqlite', { readonly: true })
    .prepare('SELECT id FROM users WHERE email = ?').get(u.email).id;
  const sid2 = 'extraGeoSid' + Date.now();
  const db = new Database('data/sessions.sqlite');
  db.prepare(`INSERT INTO sessions (sid, expired, sess) VALUES (?, ?, ?)`)
    .run(sid2, Date.now() + 86400000, JSON.stringify({
      cookie: { originalMaxAge: 86400000 },
      userId: realId, password_at: Date.now(),
      created_ip: '1.1.1.1',  // Australia per geoip-lite
      created_ua: 'Test',
      created_at: Date.now(),
    }));
  db.close();
  const r = await request({
    path: '/auth/sessions',
    headers: { 'cookie': u.sid, 'accept': 'application/json' },
  });
  const body = JSON.parse(r.body);
  assert.strictEqual(body.sessions.length, 2);
  const us1 = body.sessions.find(s => s.ip === '8.8.8.8');
  const au1 = body.sessions.find(s => s.ip === '1.1.1.1');
  assert.ok(us1 && au1, 'both sessions must be present');
  assert.strictEqual(us1.geo.country, 'US');
  assert.strictEqual(au1.geo.country, 'AU');
});

test('GET /auth/sessions: backfills geo for sessions that predate the geo feature', async () => {
  // Register with a known IP
  const u = await register('gi3', '8.8.8.8');
  if (u.status !== 200) return;
  // Insert a session with NO created_geo (simulates pre-feature session)
  const realId = new Database('data/ncii.sqlite', { readonly: true })
    .prepare('SELECT id FROM users WHERE email = ?').get(u.email).id;
  const oldSid = 'oldSession' + Date.now();
  const db = new Database('data/sessions.sqlite');
  db.prepare(`INSERT INTO sessions (sid, expired, sess) VALUES (?, ?, ?)`)
    .run(oldSid, Date.now() + 86400000, JSON.stringify({
      cookie: { originalMaxAge: 86400000 },
      userId: realId, password_at: Date.now(),
      created_ip: '8.8.4.4',  // also Google, US
      created_ua: 'Test',
      created_at: Date.now(),
      // NO created_geo
    }));
  db.close();
  const r = await request({
    path: '/auth/sessions',
    headers: { 'cookie': u.sid, 'accept': 'application/json' },
  });
  const body = JSON.parse(r.body);
  const old = body.sessions.find(s => s.sid === oldSid);
  assert.ok(old, 'old session must be listed');
  assert.ok(old.geo, 'geo must be backfilled');
  assert.strictEqual(old.geo.country, 'US');
});

test('GET /auth/sessions: loopback IP gives geo=null gracefully', async () => {
  // Register through a public IP first (so we get a session)
  const u = await register('gi4', '8.8.8.8');
  if (u.status !== 200) return;
  // Insert a session with loopback IP
  const realId = new Database('data/ncii.sqlite', { readonly: true })
    .prepare('SELECT id FROM users WHERE email = ?').get(u.email).id;
  const loopSid = 'loopbackSid' + Date.now();
  const db = new Database('data/sessions.sqlite');
  db.prepare(`INSERT INTO sessions (sid, expired, sess) VALUES (?, ?, ?)`)
    .run(loopSid, Date.now() + 86400000, JSON.stringify({
      cookie: { originalMaxAge: 86400000 },
      userId: realId, password_at: Date.now(),
      created_ip: '127.0.0.1',
      created_ua: 'Test',
      created_at: Date.now(),
    }));
  db.close();
  const r = await request({
    path: '/auth/sessions',
    headers: { 'cookie': u.sid, 'accept': 'application/json' },
  });
  const body = JSON.parse(r.body);
  const loop = body.sessions.find(s => s.sid === loopSid);
  assert.ok(loop, 'loopback session must be listed');
  assert.strictEqual(loop.geo, null, 'loopback should give null geo, not throw');
});

module.exports = TESTS;
