'use strict';

// A pairing code must be claimable for as long as the screen is DISPLAYING it.
//
// Expiry was measured from `devices.created_at`, but the row is created once, on the
// device's first registration, and is never recreated: a player persists its device_id and
// its code and re-registers with them forever. So 15 minutes after first boot the row was
// permanently unclaimable, while the screen kept showing a code and the device kept
// heartbeating. The on-screen instruction ("restart the display to get a new code") could
// not help, because restarting reuses the stored identity and produces the same code.
//
// Observed in production: an unclaimed web player, still heartbeating, whose row was
// created 4 days 20 hours earlier and had been unpairable for all but the first 15 minutes
// of that.
//
// The fix keys expiry on LIVENESS instead: a device that has checked in recently is
// pairable; one that has been gone longer than the TTL is not. That matches what an
// operator sees — if the code is on the screen, typing it works — while still killing the
// code for a device that has actually gone away.

const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs');
const crypto = require('node:crypto');
const Database = require('better-sqlite3');

const { freePort } = require('./helpers/free-port');
let PORT, BASE, proc, db;
const DATA_DIR = path.join(os.tmpdir(), 'st-pairlive-' + crypto.randomBytes(4).toString('hex'));
const LOG = path.join(os.tmpdir(), 'st-pairlive-' + crypto.randomBytes(4).toString('hex') + '.log');
const PW = 'Passw0rd123';
const S = {};
const TTL_SEC = require('../lib/pair-lockout').PAIRING_TTL_SEC;

const jfetch = async (p, opts = {}) => {
  const res = await fetch(BASE + p, opts);
  let body = null; try { body = await res.json(); } catch { /* */ }
  return { status: res.status, body };
};
const post = (tok, obj) => ({
  method: 'POST',
  headers: { ...(tok ? { Authorization: 'Bearer ' + tok } : {}), 'Content-Type': 'application/json' },
  body: JSON.stringify(obj),
});

// Seed an unclaimed device exactly as a registered-but-unpaired player leaves it.
// createdMinAgo models how long ago the row first appeared; seenMinAgo models the last
// heartbeat, i.e. whether the screen is still alive and showing the code.
function seedDevice({ createdMinAgo, seenMinAgo }) {
  const id = crypto.randomUUID();
  const code = String(100000 + Math.floor(Math.random() * 900000));
  const now = Math.floor(Date.now() / 1000);
  db.prepare(`INSERT INTO devices (id, pairing_code, status, app_version, created_at, last_heartbeat)
              VALUES (?, ?, 'provisioning', '1.1.0-web', ?, ?)`)
    .run(id, code, now - createdMinAgo * 60, seenMinAgo === null ? null : now - seenMinAgo * 60);
  return { id, code };
}
// NOTE: /api/provision is rate-limited to 5 requests/minute per IP (server.js), so this
// suite deliberately spends at most five pair attempts.
const pair = (code) => jfetch('/api/provision/pair', post(S.token, { pairing_code: code, name: 'Test Screen' }));

before(async () => {
  PORT = await freePort();
  BASE = `http://127.0.0.1:${PORT}`;
  const logFd = fs.openSync(LOG, 'w');
  proc = spawn('node', ['server.js'], {
    cwd: path.join(__dirname, '..'),
    env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' },
    stdio: ['ignore', logFd, logFd],
  });
  let up = false;
  for (let i = 0; i < 80; i++) {
    try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* */ }
    await new Promise(r => setTimeout(r, 250));
  }
  if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
  db = new Database(path.join(DATA_DIR, 'db', 'remote_display.db'));

  const email = 'u' + crypto.randomBytes(5).toString('hex') + '@x.local';
  const reg = await jfetch('/api/auth/register', post(null, { email, password: PW }));
  S.token = reg.body.token;
  assert.ok(S.token, 'registered an operator to pair as');
});
after(() => { try { db && db.close(); } catch { /* */ } try { proc.kill('SIGKILL'); } catch { /* */ } });

test('a freshly-registered screen pairs', async () => {
  const d = seedDevice({ createdMinAgo: 1, seenMinAgo: 0 });
  const r = await pair(d.code);
  assert.equal(r.status, 200, `a new screen must pair (got ${r.status} ${JSON.stringify(r.body)})`);
});

test('THE BUG: a screen that is still alive and showing its code pairs, however old the row is', async () => {
  // The exact production shape: row created days ago, device heartbeating right now.
  const d = seedDevice({ createdMinAgo: 60 * 24 * 5, seenMinAgo: 0 });
  const r = await pair(d.code);
  assert.equal(r.status, 200,
    `a live screen displaying its code must be pairable regardless of row age (got ${r.status} ${JSON.stringify(r.body)})`);
});

test('a screen that has been gone longer than the TTL is refused', async () => {
  // The property the expiry exists for: an abandoned code must not stay claimable.
  const d = seedDevice({ createdMinAgo: 60, seenMinAgo: Math.ceil(TTL_SEC / 60) + 5 });
  const r = await pair(d.code);
  assert.equal(r.status, 410, 'an abandoned code must expire');
  assert.match(r.body.error, /expired/i);
});

test('a device that never checked in falls back to its creation time', async () => {
  const stale = seedDevice({ createdMinAgo: Math.ceil(TTL_SEC / 60) + 5, seenMinAgo: null });
  assert.equal((await pair(stale.code)).status, 410, 'never-seen and old -> expired');
});

test('an unknown code is still a 404, not an expiry', async () => {
  const r = await pair('000001');
  assert.equal(r.status, 404);
});
