'use strict';

// Serving two APKs, and letting a display move between them.
//
// The passive opt-in shipped in 1.9.26 only stopped a sideloaded build being reverted. It did not
// let the server DISTRIBUTE a beta: there was one APK slot, and latest_version was the server's own
// VERSION, so a beta build had to be installed by hand on every display.
//
// Two things have to hold or this becomes an OTA loop rather than a feature:
//
//   1. The version advertised must match the bytes served. The check and the download resolve the
//      channel the same way and fall back to stable identically, and a beta with no declared
//      version does not activate at all.
//   2. Switching back must actually move the display. Stable is semver-OLDER than the beta it
//      replaces, so the ordinary "never downgrade" rule strands it — unticking the box would be
//      another silent no-op.

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

const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-chan-'));
process.env.DATA_DIR = tmp;

const apkCache = require('../lib/apk-cache');
const breaker = require('../lib/ota-breaker');

const STABLE = path.join(tmp, 'ScreenTinker.apk');
const BETA = path.join(tmp, 'ScreenTinker-beta.apk');

function writeStable() { fs.writeFileSync(STABLE, Buffer.alloc(100, 1)); }
function writeBeta(version) {
  fs.writeFileSync(BETA, Buffer.alloc(250, 2));
  if (version === null) { try { fs.unlinkSync(BETA + '.version'); } catch (_) {} }
  else fs.writeFileSync(BETA + '.version', version + '\n');
}
function clearBeta() {
  try { fs.unlinkSync(BETA); } catch (_) {}
  try { fs.unlinkSync(BETA + '.version'); } catch (_) {}
}

const ask = (client, latest, beta, wasOnBeta = false) =>
  breaker.decide(client, latest, null, Date.now(), beta, wasOnBeta);

test('with no beta published, the beta channel simply is not available', () => {
  writeStable(); clearBeta(); apkCache.refresh();
  assert.equal(apkCache.betaAvailable(), false);
  // Ticking the box on a server with no beta build must be a no-op, not a broken display.
  assert.equal(apkCache.forChannel('beta').path, STABLE, 'must fall back to stable');
});

test('a beta APK with NO declared version does not activate', () => {
  // Failing closed: the server cannot know what version those bytes are, and advertising a
  // version that does not match what is served is how an OTA loop starts.
  writeStable(); writeBeta(null); apkCache.refresh();
  assert.equal(apkCache.betaAvailable(), false, 'an undeclared beta must be ignored entirely');
  assert.equal(apkCache.forChannel('beta').path, STABLE);
});

test('a beta APK with a junk version file is treated as absent, not trusted', () => {
  writeStable(); fs.writeFileSync(BETA, Buffer.alloc(250, 2));
  fs.writeFileSync(BETA + '.version', 'latest\n');
  apkCache.refresh();
  assert.equal(apkCache.betaAvailable(), false);
});

test('a properly declared beta activates and is served on the beta channel only', () => {
  writeStable(); writeBeta('1.9.27-rc1'); apkCache.refresh();
  assert.equal(apkCache.betaAvailable(), true);
  assert.equal(apkCache.getBeta().version, '1.9.27-rc1');
  assert.equal(apkCache.forChannel('beta').path, BETA);
  assert.equal(apkCache.forChannel('stable').path, STABLE, 'stable displays must be unaffected');
  assert.equal(apkCache.get().path, STABLE);
});

test('the two slots report their own sizes, so apk_size matches the bytes served', () => {
  writeStable(); writeBeta('1.9.27-rc1'); apkCache.refresh();
  assert.equal(apkCache.forChannel('stable').size, 100);
  assert.equal(apkCache.forChannel('beta').size, 250);
  assert.notEqual(apkCache.get().size, apkCache.getBeta().size);
});

test('an opted-in display is offered the beta build over the current stable', () => {
  // The check compares against the beta's declared version, not the server's.
  const v = ask('1.9.26', '1.9.27-rc1', false);
  assert.equal(v.update_available, true);
  assert.equal(v.reason, 'offer');
});

test('once on the beta build, an opted-in display is up to date', () => {
  assert.equal(ask('1.9.27-rc1', '1.9.27-rc1', false).reason, 'up-to-date');
});

test('THE SWITCH BACK: unticking beta moves a display off the beta build', () => {
  // Stable 1.9.26 is semver-OLDER than 1.9.27-rc1, so the plain "never downgrade" rule would
  // strand it and unticking the box would appear to do nothing. wasOnBeta is the evidence that
  // we actually served this display the beta channel.
  const v = ask('1.9.27-rc1', '1.9.26', false, true);
  assert.equal(v.update_available, true, 'the display must be offered the release build');
  assert.equal(v.reason, 'channel-return');
});

test('a display we never served beta to is NOT pulled back, even on a pre-release', () => {
  // #144 protects a tester who is ahead of the server on their own build. Publishing a beta must
  // not drag every such display backwards — that is the exact harm the opt-in exists to prevent.
  const v = ask('1.9.4-beta1', '1.9.3', false, false);
  assert.equal(v.update_available, false);
  assert.equal(v.reason, 'client-newer');
});

test('a display AHEAD on a real release is still never downgraded', () => {
  // This is a rolled-back server, not a channel switch. Pushing it backwards would be wrong.
  const v = ask('1.9.27', '1.9.26', false);
  assert.equal(v.update_available, false);
  assert.equal(v.reason, 'client-newer');
});

test('a still-opted-in display on a newer prerelease is NOT dragged back to stable', () => {
  // Only unticking the box should return it. While opted in it keeps its beta build, even though
  // we have served it beta before.
  const v = ask('1.9.27-rc1', '1.9.26', true, true);
  assert.equal(v.update_available, false);
  assert.equal(v.reason, 'client-newer');
});

test.after(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });
