'use strict';

// Six surfaces resolve a session token THEMSELVES instead of sitting behind requireAuth:
// the three /api/status token routes, the screenshot route, the content-reference gate,
// and the /dashboard socket handshake. They all now call middleware/auth.resolveSessionUser,
// the same resolver requireAuth uses, so they must inherit its checks. This suite walks
// every one of them and asserts, per surface:
//
//   1. a PRE-TOTP token (mfa_pending, i.e. password accepted but the TOTP step not
//      completed) is refused,
//   2. a normal full session token is still accepted, and
//   3. a recovery token behaves as it did before this refactor - accepted by
//      requireAuth-gated routes, refused by these six (it has no users row, so the
//      lookups these replaced already denied it).
//
// The socket handshake is included deliberately: it is the one surface with no HTTP
// equivalent, so a suite that covered only the routes would not hold it to the contract.
//
// Boots the REAL server.js as a subprocess against an isolated DB (same convention as
// api.test.js / totp.test.js). Node built-ins + socket.io-client (devDep) only.

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 jwt = require('jsonwebtoken');
const ioClient = require('socket.io-client');
const { authenticator } = require('otplib');

const { freePort } = require('./helpers/free-port');
let PORT, BASE;
const SECRET = 'test-secret-session-resolution-' + crypto.randomBytes(4).toString('hex');
const DATA_DIR = path.join(os.tmpdir(), 'st-session-test-' + crypto.randomBytes(4).toString('hex'));
const LOG = path.join(os.tmpdir(), 'st-session-' + crypto.randomBytes(4).toString('hex') + '.log');
let proc;
const S = {}; // shared fixtures populated in before()

const PW = 'Passw0rd123';

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

// A 1x1 PNG - enough for the ingest path to produce a real file + thumbnail, so the
// content-reference gate is actually reachable.
const PNG_1X1 = Buffer.from(
  // Must be a STRUCTURALLY VALID PNG. The previous literal had a corrupt IDAT chunk (stored CRC
  // did not match its data) and only decoded because the old libpng was lenient. A stricter decoder
  // rejects it ("vipspng: libpng read error"), no thumbnail is written, and the assertions below
  // fail with a 404 that looks like an auth regression but is not. Every chunk CRC here verifies.
  'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADUlEQVQImWP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==',
  'base64'
);

// Connect to the /dashboard namespace and report whether the handshake was accepted.
function connectDashboard(token) {
  return new Promise((resolve) => {
    const sock = ioClient(`${BASE}/dashboard`, {
      auth: { token }, transports: ['websocket'], reconnection: false, forceNew: true,
    });
    let settled = false;
    const done = (r) => { if (settled) return; settled = true; try { sock.close(); } catch { /* */ } resolve(r); };
    sock.on('connect', () => done({ connected: true, message: null }));
    sock.on('connect_error', (e) => done({ connected: false, message: e.message }));
    setTimeout(() => done({ connected: false, message: 'timeout' }), 5000);
  });
}

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', JWT_SECRET: SECRET },
    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 { /* not yet */ }
    await new Promise(r => setTimeout(r, 250));
  }
  if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));

  // First registered user becomes platform_admin - needed so the /backup happy path is
  // actually authorized, which is what makes its pre-TOTP refusal meaningful.
  S.adminEmail = 'admin' + crypto.randomBytes(4).toString('hex') + '@x.local';
  const reg = await jfetch('/api/auth/register', post(null, { email: S.adminEmail, password: PW }));
  S.adminToken = reg.body.token;
  assert.equal(reg.body.user.role, 'platform_admin', 'first user is platform_admin');

  // Enroll TOTP, then log in again to obtain a genuine mfa_pending token. The pre-existing
  // session token stays valid across enrollment (see totp.test.js), so S.adminToken is
  // still our "full session" fixture for the same account.
  const setup = await jfetch('/api/auth/totp/setup', post(S.adminToken, {}));
  await jfetch('/api/auth/totp/enable', post(S.adminToken, { code: authenticator.generate(setup.body.secret) }));
  const login = await jfetch('/api/auth/login', post(null, { email: S.adminEmail, password: PW }));
  assert.equal(login.body.mfa_required, true, 'login now stops at the TOTP step');
  assert.equal(login.body.token, undefined, 'no full session token before TOTP');
  S.mfaToken = login.body.mfa_token;
  assert.ok(S.mfaToken, 'got a pre-TOTP token');

  // Recovery token, minted exactly as scripts/reset-admin.js does it — including the
  // recovery_grants row, without which the token is refused outright. Backing it properly
  // keeps the assertions below testing what they were written to test (break-glass is
  // refused on these six surfaces because it has no users row / no workspace membership),
  // rather than passing for the unrelated reason that the token itself is invalid.
  {
    const Database = require('better-sqlite3');
    const gdb = new Database(path.join(DATA_DIR, 'db', 'remote_display.db'));
    const jti = crypto.randomBytes(16).toString('hex');
    gdb.prepare('INSERT INTO recovery_grants (jti, expires_at, minted_by, note) VALUES (?,?,?,?)')
      .run(jti, Math.floor(Date.now() / 1000) + 3600, 'test', 'session-token-resolution');
    gdb.close();
    S.recoveryToken = jwt.sign(
      { id: 'recovery-' + jti, email: 'admin@localhost', role: 'admin', recovery: true, jti },
      SECRET, { expiresIn: '1h' }
    );
  }

  // Content with a real file + thumbnail, not referenced by any playlist, so
  // /api/content/:id/{file,thumbnail} falls through to the requester gate.
  const fd = new FormData();
  fd.append('file', new Blob([PNG_1X1], { type: 'image/png' }), 'px.png');
  const up2 = await fetch(BASE + '/api/content', { method: 'POST', headers: { Authorization: 'Bearer ' + S.adminToken }, body: fd });
  const created = await up2.json();
  S.contentId = created.id;
  assert.ok(S.contentId, 'uploaded content for the reference-gate tests');
});

after(() => { try { proc.kill('SIGKILL'); } catch { /* ignore */ } });

// ---------------------------------------------------------------------------
// 1. GET /api/status/backup  (?token=)  - full database download
// ---------------------------------------------------------------------------
test('status/backup: pre-TOTP token refused, full admin session accepted, recovery refused', async () => {
  const mfa = await jfetch(`/api/status/backup?token=${encodeURIComponent(S.mfaToken)}`);
  assert.equal(mfa.status, 401, 'pre-TOTP token must not reach the database backup');
  assert.equal(mfa.body.error, 'mfa_required');

  // Happy path returns the DB file itself, so consume it rather than parsing JSON.
  const ok = await fetch(`${BASE}/api/status/backup?token=${encodeURIComponent(S.adminToken)}`);
  await ok.arrayBuffer();
  assert.equal(ok.status, 200, 'a full platform-admin session still downloads the backup');

  const rec = await jfetch(`/api/status/backup?token=${encodeURIComponent(S.recoveryToken)}`);
  assert.equal(rec.status, 403, 'recovery identity is not a platform admin here (unchanged)');
});

// ---------------------------------------------------------------------------
// 2. GET /api/status/export  (?token=)
// ---------------------------------------------------------------------------
test('status/export: pre-TOTP token refused, full session accepted, recovery refused', async () => {
  const mfa = await jfetch(`/api/status/export?token=${encodeURIComponent(S.mfaToken)}`);
  assert.equal(mfa.status, 401, 'pre-TOTP token must not export account data');
  assert.equal(mfa.body.error, 'mfa_required');

  const ok = await jfetch(`/api/status/export?token=${encodeURIComponent(S.adminToken)}`);
  assert.equal(ok.status, 200, 'a full session still exports');

  const rec = await jfetch(`/api/status/export?token=${encodeURIComponent(S.recoveryToken)}`);
  assert.equal(rec.status, 404, 'recovery identity has no users row (unchanged)');
});

// ---------------------------------------------------------------------------
// 3. POST /api/status/import  (Authorization header)
// ---------------------------------------------------------------------------
test('status/import: pre-TOTP token refused, full session reaches the handler, recovery refused', async () => {
  const mfa = await jfetch('/api/status/import', post(S.mfaToken, { format: 'screentinker-export-v2' }));
  assert.equal(mfa.status, 401, 'pre-TOTP token must not import into a workspace');
  assert.equal(mfa.body.error, 'mfa_required');

  // 400 here is the post-auth validation failing on a body with no payload - i.e. the
  // token WAS accepted. Asserting "not 401/403/404" is the auth boundary we care about.
  const ok = await jfetch('/api/status/import', post(S.adminToken, { nope: true }));
  assert.equal(ok.status, 400, 'a full session passes auth and fails validation instead');

  const rec = await jfetch('/api/status/import', post(S.recoveryToken, { nope: true }));
  assert.equal(rec.status, 404, 'recovery identity has no users row (unchanged)');
});

// ---------------------------------------------------------------------------
// 4. GET /api/devices/:id/screenshot  (?token= and header)
// ---------------------------------------------------------------------------
test('screenshot: pre-TOTP token refused before the device lookup; full session gets past auth', async () => {
  const id = crypto.randomUUID();
  const mfa = await jfetch(`/api/devices/${id}/screenshot?token=${encodeURIComponent(S.mfaToken)}`);
  assert.equal(mfa.status, 401, 'pre-TOTP token must not read device screenshots');
  assert.equal(mfa.body.error, 'mfa_required');

  // No device exists, so 404 "Device not found" is the proof that auth was accepted and
  // the handler moved on to its (deliberately untouched) ownership logic.
  const ok = await jfetch(`/api/devices/${id}/screenshot?token=${encodeURIComponent(S.adminToken)}`);
  assert.equal(ok.status, 404, 'a full session clears auth and reaches the device lookup');

  const rec = await jfetch(`/api/devices/${id}/screenshot?token=${encodeURIComponent(S.recoveryToken)}`);
  assert.equal(rec.status, 401, 'recovery identity has no users row (unchanged)');

  // Same outcome via the Authorization header, not just the query parameter.
  const hdr = await jfetch(`/api/devices/${id}/screenshot`, auth(S.mfaToken));
  assert.equal(hdr.status, 401, 'header path refuses the pre-TOTP token too');
});

// ---------------------------------------------------------------------------
// 5. requesterCanAccessContent -> /api/content/:id/{file,thumbnail}
// ---------------------------------------------------------------------------
test('content gate: pre-TOTP token gets no more than an anonymous caller; full session reads', async () => {
  // Baseline: unreferenced content is not public.
  const anon = await jfetch(`/api/content/${S.contentId}/thumbnail`);
  assert.equal(anon.status, 403, 'unreferenced content is not anonymously readable');

  // The gate returns a boolean, so a refused token surfaces as the same 403 an anonymous
  // caller gets - the point is that it grants NOTHING extra.
  const mfa = await jfetch(`/api/content/${S.contentId}/thumbnail`, auth(S.mfaToken));
  assert.equal(mfa.status, 403, 'pre-TOTP token unlocks no content');
  const mfaFile = await jfetch(`/api/content/${S.contentId}/file`, auth(S.mfaToken));
  assert.equal(mfaFile.status, 403, 'pre-TOTP token unlocks no file either');

  const okThumb = await fetch(`${BASE}/api/content/${S.contentId}/thumbnail`, auth(S.adminToken));
  await okThumb.arrayBuffer();
  assert.equal(okThumb.status, 200, 'a workspace member with a full session still reads the thumbnail');
  const okFile = await fetch(`${BASE}/api/content/${S.contentId}/file`, auth(S.adminToken));
  await okFile.arrayBuffer();
  assert.equal(okFile.status, 200, 'and the file');

  const rec = await jfetch(`/api/content/${S.contentId}/thumbnail`, auth(S.recoveryToken));
  assert.equal(rec.status, 403, 'recovery identity has no workspace membership (unchanged)');
});

// ---------------------------------------------------------------------------
// 6. /dashboard socket handshake
// ---------------------------------------------------------------------------
test('dashboard socket: pre-TOTP token cannot open the namespace; full session can', async () => {
  const mfa = await connectDashboard(S.mfaToken);
  assert.equal(mfa.connected, false, 'pre-TOTP token must not reach the device-command channel');
  assert.equal(mfa.message, 'mfa_required');

  const ok = await connectDashboard(S.adminToken);
  assert.equal(ok.connected, true, 'a full session still connects');

  const rec = await connectDashboard(S.recoveryToken);
  assert.equal(rec.connected, false, 'recovery identity is refused the handshake');

  const bogus = await connectDashboard('not-a-jwt');
  assert.equal(bogus.connected, false, 'garbage token still refused');
  assert.equal(bogus.message, 'Invalid token');
});

// ---------------------------------------------------------------------------
// Cross-checks: the audience split, and requireAuth non-regression.
// ---------------------------------------------------------------------------
test('audience split: the pre-TOTP token works ONLY at /totp/verify, and a session token does not', async () => {
  // A full session token presented as an mfa_token is rejected (no pre-TOTP audience).
  const wrongWay = await jfetch('/api/auth/totp/verify', post(null, { mfa_token: S.adminToken, code: '000000' }));
  assert.equal(wrongWay.status, 401, 'a session token is not accepted as an mfa_token');
  assert.equal(wrongWay.body.error, 'mfa session expired');

  // And the pre-TOTP token is still accepted there: a wrong code gets past token
  // validation to the code check (401 "Invalid code"), not rejected as a bad token.
  const rightWay = await jfetch('/api/auth/totp/verify', post(null, { mfa_token: S.mfaToken, code: '000000' }));
  assert.equal(rightWay.status, 401);
  assert.equal(rightWay.body.error, 'Invalid code', 'pre-TOTP token is still redeemable at /totp/verify');
});

test('requireAuth non-regression: full session works, pre-TOTP 401s, recovery still reaches a gated route', async () => {
  assert.equal((await jfetch('/api/auth/me', auth(S.adminToken))).status, 200, 'full session');
  const mfa = await jfetch('/api/auth/me', auth(S.mfaToken));
  assert.equal(mfa.status, 401, 'pre-TOTP token');
  assert.equal(mfa.body.error, 'mfa_required');
  // Break-glass must keep working where it worked before - a requireAuth-gated route.
  assert.equal((await jfetch('/api/devices', auth(S.recoveryToken))).status, 200, 'recovery still authenticates');
});
