'use strict';

// A device that reconnects after its row was deleted sends the id it still has cached. That id
// is gone, and device_fingerprints.device_id has an FK to devices(id) — so writing it back
// throws FOREIGN KEY constraint failed. The throw is caught, which is why nothing looked broken,
// but the catch abandons the ENTIRE fingerprint block: last_seen is not touched, the reinstall
// link is not made, and the #150 settings restore never runs. That restore exists precisely for
// the post-delete re-pair, so the failure lands exactly where the feature was supposed to help —
// a re-paired panel comes back with its orientation/name/playlist reset.
//
// Observed on production: 37 occurrences, timestamped identically to the "sending unpaired" log
// lines, which is the same event seen from the other side.
//
// The rule pinned here: only ever store a device_id that still resolves. Prefer the incoming id,
// fall back to what is already stored, else NULL — which the column allows, and which is what
// ON DELETE SET NULL already leaves behind.

const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs');
const crypto = require('node:crypto');

const DATA_DIR = path.join(os.tmpdir(), 'st-fp-' + crypto.randomBytes(4).toString('hex'));
fs.mkdirSync(path.join(DATA_DIR, 'db'), { recursive: true });
process.env.DATA_DIR = DATA_DIR;
process.env.SELF_HOSTED = 'true';
process.env.NODE_ENV = 'test';

const { db } = require('../db/database');

const mkDevice = (name) => {
  const id = crypto.randomUUID();
  db.prepare(`INSERT INTO devices (id, name, status, created_at)
              VALUES (?, ?, 'online', strftime('%s','now'))`).run(id, name);
  return id;
};

// The exact statement the register handler runs, with the guard applied.
function writeFingerprint(fingerprint, incomingDeviceId) {
  const existing = db.prepare('SELECT * FROM device_fingerprints WHERE fingerprint = ?').get(fingerprint);
  if (!existing) return null;
  const known = (id) => !!(id && db.prepare('SELECT 1 FROM devices WHERE id = ?').get(id));
  const fpDeviceId = known(incomingDeviceId) ? incomingDeviceId
    : (known(existing.device_id) ? existing.device_id : null);
  db.prepare("UPDATE device_fingerprints SET last_seen = strftime('%s','now'), device_id = ? WHERE fingerprint = ?")
    .run(fpDeviceId, fingerprint);
  return db.prepare('SELECT * FROM device_fingerprints WHERE fingerprint = ?').get(fingerprint);
}

before(() => { db.pragma('foreign_keys = ON'); });
after(() => { try { fs.rmSync(DATA_DIR, { recursive: true, force: true }); } catch { /* */ } });

test('foreign keys are actually enforced, or this whole file proves nothing', () => {
  assert.equal(db.pragma('foreign_keys', { simple: true }), 1);
});

test('the SHIPPED handler carries the guard, not just this test', () => {
  // writeFingerprint() above mirrors the handler's statement; without this check the whole
  // file would still pass against an unfixed deviceSocket.js.
  const src = fs.readFileSync(path.join(__dirname, '..', 'ws', 'deviceSocket.js'), 'utf8');
  const i = src.indexOf('UPDATE device_fingerprints SET last_seen');
  assert.notEqual(i, -1, 'the UPDATE still exists');
  const window = src.slice(Math.max(0, i - 900), i + 300);
  assert.match(window, /SELECT 1 FROM devices WHERE id = \?/,
    'the id is validated against devices before being written');
  assert.doesNotMatch(window, /\.run\(\s*device_id \|\| existing\.device_id/,
    'the unguarded write is gone');
});

test('THE BUG: a reconnect carrying a DELETED device id must not throw', () => {
  const fp = 'fp-' + crypto.randomBytes(4).toString('hex');
  const dev = mkDevice('Panel');
  db.prepare('INSERT INTO device_fingerprints (fingerprint, device_id) VALUES (?, ?)').run(fp, dev);

  db.prepare('DELETE FROM devices WHERE id = ?').run(dev);   // ON DELETE SET NULL clears the link
  assert.equal(db.prepare('SELECT device_id FROM device_fingerprints WHERE fingerprint = ?').get(fp).device_id, null);

  // The player still has the old id cached and sends it on reconnect.
  const row = writeFingerprint(fp, dev);
  assert.ok(row, 'the write completed instead of throwing');
  assert.equal(row.device_id, null, 'a vanished id is not written back');
});

test('last_seen is still updated — the block is no longer abandoned', () => {
  const fp = 'fp-' + crypto.randomBytes(4).toString('hex');
  const dev = mkDevice('Panel2');
  db.prepare('INSERT INTO device_fingerprints (fingerprint, device_id, last_seen) VALUES (?, ?, 1)').run(fp, dev);
  db.prepare('DELETE FROM devices WHERE id = ?').run(dev);

  const row = writeFingerprint(fp, dev);
  assert.ok(row.last_seen > 1, 'the fingerprint is still seen, which is what drives reinstall tracking');
});

test('a LIVE device id is stored, so normal tracking is unaffected', () => {
  const fp = 'fp-' + crypto.randomBytes(4).toString('hex');
  const a = mkDevice('A');
  db.prepare('INSERT INTO device_fingerprints (fingerprint, device_id) VALUES (?, ?)').run(fp, a);
  const b = mkDevice('B');
  assert.equal(writeFingerprint(fp, b).device_id, b, 'the incoming id wins when it resolves');
});

test('an absent incoming id keeps the existing link rather than clearing it', () => {
  const fp = 'fp-' + crypto.randomBytes(4).toString('hex');
  const a = mkDevice('Keeper');
  db.prepare('INSERT INTO device_fingerprints (fingerprint, device_id) VALUES (?, ?)').run(fp, a);
  assert.equal(writeFingerprint(fp, null).device_id, a, 'still linked — this is what reinstall detection reads');
});

test('both ids vanished -> NULL, not a throw', () => {
  const fp = 'fp-' + crypto.randomBytes(4).toString('hex');
  const a = mkDevice('Gone');
  db.prepare('INSERT INTO device_fingerprints (fingerprint, device_id) VALUES (?, ?)').run(fp, a);
  db.prepare('DELETE FROM devices WHERE id = ?').run(a);
  const ghost = crypto.randomUUID();
  assert.equal(writeFingerprint(fp, ghost).device_id, null);
});

test('the UNGUARDED statement really does throw — the bug is what we think it is', () => {
  const fp = 'fp-' + crypto.randomBytes(4).toString('hex');
  const dev = mkDevice('Proof');
  db.prepare('INSERT INTO device_fingerprints (fingerprint, device_id) VALUES (?, ?)').run(fp, dev);
  db.prepare('DELETE FROM devices WHERE id = ?').run(dev);
  assert.throws(
    () => db.prepare("UPDATE device_fingerprints SET last_seen = strftime('%s','now'), device_id = ? WHERE fingerprint = ?")
      .run(dev, fp),
    /FOREIGN KEY constraint failed/,
    'this is the exact error seen 37 times on prod',
  );
});
