/**
 * Tests for dashcaddy-api/license-keygen.js
 *
 * Covers the programmatic API used by the Stripe webhook bridge and the
 * on-disk counter allocator. The CLI path is exercised through the
 * dedicated CLI regression describe block at the bottom of this file.
 *
 *   - module.exports shape: verifyCode, parseCode, generateCode,
 *     generateCodes, loadSecret, VALID_DURATIONS, VERSION
 *   - generateCodes() validation: secret, duration, count
 *   - generateCodes() counter allocator: init, increment, override via
 *     startId, override via counterFile, atomic .tmp shape
 *   - generateCodes() monotonic counter: 100-call ordering, range checks
 *   - loadSecret() success and missing-file error
 *   - generateCode() round-trip: codes verify back via verifyCode()
 *   - CLI integration: omitted --start-id uses auto-counter, explicit
 *     --start-id skips counter write, --lifetime/--duration mutual exclusion
 */

'use strict';

const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFileSync } = require('child_process');

const keygen = require('../license-keygen');
const {
  verifyCode,
  parseCode,
  generateCode,
  generateCodes,
  loadSecret,
  VALID_DURATIONS,
  VERSION,
} = keygen;

function _tmpDir(prefix) {
  return fs.mkdtempSync(path.join(os.tmpdir(), `dashcaddy-${prefix}-`));
}

function _cleanup(dir) {
  try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* best effort */ }
}

const TEST_SECRET = 'a'.repeat(64); // 32 bytes hex

// ── Public surface ──────────────────────────────────────────────────────────

describe('license-keygen: module.exports', () => {
  test('exports verifyCode, parseCode, generateCode, generateCodes, loadSecret, VALID_DURATIONS, VERSION', () => {
    expect(typeof verifyCode).toBe('function');
    expect(typeof parseCode).toBe('function');
    expect(typeof generateCode).toBe('function');
    expect(typeof generateCodes).toBe('function');
    expect(typeof loadSecret).toBe('function');
    expect(Array.isArray(VALID_DURATIONS)).toBe(true);
    expect(VALID_DURATIONS).toEqual([30, 90, 180, 365]);
    expect(VERSION).toBe(1);
  });
});

// ── generateCode / parseCode / verifyCode round-trip ────────────────────────

describe('license-keygen: generateCode round-trip', () => {
  test('generated code verifies back via verifyCode()', () => {
    const code = generateCode(TEST_SECRET, 90, 42);
    expect(code).toMatch(/^DC-([0-9A-Z]{5})(-[0-9A-Z]{5}){4}$/);
    const result = verifyCode(TEST_SECRET, code);
    expect(result.valid).toBe(true);
    expect(result.durationDays).toBe(90);
    expect(result.codeId).toBe(42);
  });

  test('verifyCode rejects a code from a different secret', () => {
    const code = generateCode(TEST_SECRET, 30, 1);
    const result = verifyCode('b'.repeat(64), code);
    expect(result.valid).toBe(false);
    expect(result.reason).toMatch(/signature/i);
  });

  test('parseCode returns version, duration, codeId, timestamp', () => {
    const code = generateCode(TEST_SECRET, 365, 9999);
    const parsed = parseCode(code);
    expect(parsed.version).toBe(VERSION);
    expect(parsed.durationDays).toBe(365);
    expect(parsed.codeId).toBe(9999);
    expect(typeof parsed.createdTs).toBe('number');
  });
});

// ── generateCodes: validation ───────────────────────────────────────────────

describe('license-keygen: generateCodes validation', () => {
  test('throws on missing secret', () => {
    expect(() => generateCodes({ secret: '', durationDays: 30 })).toThrow(/secret is required/);
    expect(() => generateCodes({ secret: 123, durationDays: 30 })).toThrow(/secret is required/);
    expect(() => generateCodes({ durationDays: 30 })).toThrow(/secret is required/);
  });

  test('throws on invalid duration', () => {
    expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 7 })).toThrow(/invalid duration/);
    expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 31 })).toThrow(/invalid duration/);
    expect(() => generateCodes({ secret: TEST_SECRET, durationDays: -1 })).toThrow(/invalid duration/);
  });

  test('accepts LIFETIME (durationDays: 0)', () => {
    const tmp = _tmpDir('kg-lifetime');
    try {
      const codes = generateCodes({
        secret: TEST_SECRET,
        durationDays: 0,
        counterFile: path.join(tmp, '.counter'),
      });
      expect(codes).toHaveLength(1);
      expect(codes[0].durationDays).toBe(0);
    } finally { _cleanup(tmp); }
  });

  test('throws on invalid count', () => {
    expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 30, count: 0 })).toThrow(/invalid count/);
    expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 30, count: -1 })).toThrow(/invalid count/);
    expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 30, count: 10001 })).toThrow(/invalid count/);
    expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 30, count: 1.5 })).toThrow(/invalid count/);
  });
});

// ── generateCodes: counter allocator ────────────────────────────────────────

describe('license-keygen: generateCodes counter', () => {
  let tmp;
  beforeEach(() => { tmp = _tmpDir('kg-counter'); });
  afterEach(() => { _cleanup(tmp); });

  test('initializes counter at 1 when file is missing', () => {
    const codes = generateCodes({
      secret: TEST_SECRET,
      durationDays: 30,
      counterFile: path.join(tmp, '.counter'),
    });
    expect(codes[0].codeId).toBe(1);
    expect(fs.readFileSync(path.join(tmp, '.counter'), 'utf8').trim()).toBe('1');
  });

  test('increments counter on subsequent calls', () => {
    const counterFile = path.join(tmp, '.counter');
    for (let i = 1; i <= 3; i++) {
      const codes = generateCodes({
        secret: TEST_SECRET,
        durationDays: 30,
        counterFile,
      });
      expect(codes[0].codeId).toBe(i);
    }
    expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('3');
  });

  test('respects startId override and does NOT touch the counter file', () => {
    const counterFile = path.join(tmp, '.counter');
    fs.writeFileSync(counterFile, '100');
    const codes = generateCodes({
      secret: TEST_SECRET,
      durationDays: 30,
      count: 3,
      startId: 500,
      counterFile,
    });
    expect(codes.map(c => c.codeId)).toEqual([500, 501, 502]);
    // Counter file unchanged — overrideStartId path skips the write.
    expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('100');
  });

  test('no leftover .tmp files after a successful call', () => {
    const counterFile = path.join(tmp, '.counter');
    generateCodes({ secret: TEST_SECRET, durationDays: 30, counterFile });
    const entries = fs.readdirSync(tmp);
    expect(entries.filter(e => e.includes('.tmp'))).toEqual([]);
  });

  test('counter file uses per-call unique tmp suffix (no .tmp collisions)', () => {
    const counterFile = path.join(tmp, '.counter');
    const origWrite = fs.writeFileSync;
    const tmpNames = [];
    fs.writeFileSync = (p, data, opts) => {
      if (typeof p === 'string' && p.startsWith(counterFile) && p.includes('.tmp')) {
        tmpNames.push(p);
      }
      return origWrite.call(fs, p, data, opts);
    };
    try {
      generateCodes({ secret: TEST_SECRET, durationDays: 30, counterFile });
      generateCodes({ secret: TEST_SECRET, durationDays: 30, counterFile });
      expect(tmpNames).toHaveLength(2);
      expect(new Set(tmpNames).size).toBe(2);
    } finally {
      fs.writeFileSync = origWrite;
    }
  });
});

// ── generateCodes: monotonic counter ────────────────────────────────────────
//
// generateCodes() is synchronous. Node's single-threaded event loop means
// two synchronous calls cannot interleave, so the counter is monotonically
// incremented without any explicit locking. The atomic write helper
// protects against process crashes between writeFileSync and renameSync.
// These tests verify that ordering and atomicity hold across many calls.

describe('license-keygen: generateCodes monotonic counter', () => {
  let tmp;
  beforeEach(() => { tmp = _tmpDir('kg-mono'); });
  afterEach(() => { _cleanup(tmp); });

  test('100 sequential calls produce 100 unique codeIds in monotonic order', () => {
    const counterFile = path.join(tmp, '.counter');
    const codes = [];
    for (let i = 0; i < 100; i++) {
      codes.push(generateCodes({
        secret: TEST_SECRET,
        durationDays: 30,
        counterFile,
      })[0]);
    }
    const ids = codes.map(c => c.codeId);
    expect(ids).toHaveLength(100);
    expect(new Set(ids).size).toBe(100);
    for (let i = 1; i < ids.length; i++) {
      expect(ids[i]).toBe(ids[i - 1] + 1);
    }
    expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('100');
  });

  test('100 sequential calls each requesting 5 codes produce 500 unique IDs', () => {
    const counterFile = path.join(tmp, '.counter');
    const batches = [];
    for (let i = 0; i < 100; i++) {
      batches.push(generateCodes({
        secret: TEST_SECRET,
        durationDays: 30,
        count: 5,
        counterFile,
      }));
    }
    const allIds = batches.flat().map(c => c.codeId);
    expect(allIds).toHaveLength(500);
    expect(new Set(allIds).size).toBe(500);
    batches.forEach((batch, i) => {
      const start = i * 5 + 1;
      expect(batch.map(c => c.codeId)).toEqual([start, start + 1, start + 2, start + 3, start + 4]);
    });
    expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('500');
  });

  test('startId override is range-checked (negative throws)', () => {
    expect(() => generateCodes({
      secret: TEST_SECRET,
      durationDays: 30,
      startId: -1,
      counterFile: path.join(tmp, '.counter'),
    })).toThrow(/out of range/);
  });

  test('startId override is range-checked (over 32-bit throws)', () => {
    expect(() => generateCodes({
      secret: TEST_SECRET,
      durationDays: 30,
      startId: 0x100000000,
      counterFile: path.join(tmp, '.counter'),
    })).toThrow(/out of range/);
  });

  test('startId override is rejected for non-integer values', () => {
    // Codex round 2: Number.isInteger(overrideStartId) returned false for
    // floats/NaN/null/strings, silently falling through to auto-counter.
    // The Object.prototype.hasOwnProperty check above fixes the dispatch.
    const counterFile = path.join(tmp, '.counter');
    fs.writeFileSync(counterFile, '99');
    for (const bad of [1.5, NaN, null, '100', undefined, false]) {
      const prevValue = fs.readFileSync(counterFile, 'utf8').trim();
      expect(() => generateCodes({
        secret: TEST_SECRET,
        durationDays: 30,
        startId: bad,
        counterFile,
      })).toThrow(/out of range|non-integer/);
      // Counter file must NOT be touched when the call throws.
      expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe(prevValue);
    }
  });

  test('count that would push codeId past 32-bit throws', () => {
    const counterFile = path.join(tmp, '.counter');
    fs.writeFileSync(counterFile, String(0xFFFFFFFF - 5));
    expect(() => generateCodes({
      secret: TEST_SECRET,
      durationDays: 30,
      count: 10,
      counterFile,
    })).toThrow(/32-bit limit/);
  });
});

// ── generateCodes: counterFile override ─────────────────────────────────────

describe('license-keygen: generateCodes counterFile override', () => {
  let tmp;
  beforeEach(() => { tmp = _tmpDir('kg-cf'); });
  afterEach(() => { _cleanup(tmp); });

  test('counterFile option overrides LICENSE_COUNTER_FILE env', () => {
    const cf = path.join(tmp, '.counter');
    const prev = process.env.LICENSE_COUNTER_FILE;
    try {
      process.env.LICENSE_COUNTER_FILE = path.join(tmp, 'env-counter');
      generateCodes({ secret: TEST_SECRET, durationDays: 30, counterFile: cf });
      expect(fs.existsSync(cf)).toBe(true);
      expect(fs.existsSync(path.join(tmp, 'env-counter'))).toBe(false);
    } finally {
      if (prev === undefined) delete process.env.LICENSE_COUNTER_FILE;
      else process.env.LICENSE_COUNTER_FILE = prev;
    }
  });

  test('LICENSE_COUNTER_FILE env overrides the default __dirname counter', () => {
    const tmpForEnv = _tmpDir('kg-env');
    try {
      const target = path.join(tmpForEnv, 'env-counter');
      const prev = process.env.LICENSE_COUNTER_FILE;
      process.env.LICENSE_COUNTER_FILE = target;
      try {
        const codes = generateCodes({ secret: TEST_SECRET, durationDays: 30 });
        expect(codes[0].codeId).toBeLessThanOrEqual(1); // fresh env
        expect(fs.existsSync(target)).toBe(true);
      } finally {
        if (prev === undefined) delete process.env.LICENSE_COUNTER_FILE;
        else process.env.LICENSE_COUNTER_FILE = prev;
      }
    } finally { _cleanup(tmpForEnv); }
  });
});

// ── loadSecret ──────────────────────────────────────────────────────────────

describe('license-keygen: loadSecret', () => {
  let tmp;
  beforeEach(() => { tmp = _tmpDir('kg-secret'); });
  afterEach(() => { _cleanup(tmp); });

  test('returns trimmed contents of an existing secret file', () => {
    const file = path.join(tmp, '.license-secret');
    fs.writeFileSync(file, '  abc123  \n');
    expect(loadSecret(file)).toBe('abc123');
  });

  test('throws on missing file with helpful message', () => {
    const file = path.join(tmp, 'does-not-exist');
    expect(() => loadSecret(file)).toThrow(/not found/i);
    expect(() => loadSecret(file)).toThrow(/--init-secret/i);
  });
});

// ── generateCodes: failure modes ────────────────────────────────────────────

describe('license-keygen: generateCodes failure modes', () => {
  let tmp;
  beforeEach(() => { tmp = _tmpDir('kg-fail'); });
  afterEach(() => { _cleanup(tmp); });

  test('throws when counter file exists but contains non-numeric data', () => {
    const counterFile = path.join(tmp, '.counter');
    fs.writeFileSync(counterFile, 'not-a-number');
    expect(() =>
      generateCodes({ secret: TEST_SECRET, durationDays: 30, counterFile }),
    ).toThrow(/non-numeric/);
  });
});

// ── CLI regression: spawn the real binary and verify argument handling ───────
//
// Codex round 4 caught a regression: main() always passed
// `startId: overrideStartId` to generateCodes(), even when --start-id was
// omitted. The new hasOwnProperty-based validation then rejected the call
// because startId was an explicit (undefined) value. The fix is to omit
// the startId property from the options object when --start-id is absent.
// These tests exercise the actual CLI binary to make sure the local fix
// wires up correctly.

const KEYGEN_BIN = path.resolve(__dirname, '..', 'license-keygen.js');

function _runCli(args, env) {
  return execFileSync('node', [KEYGEN_BIN, ...args], {
    env: { ...process.env, ...env },
    encoding: 'utf8',
  });
}

describe('license-keygen: CLI regression', () => {
  let tmp;
  beforeEach(() => { tmp = _tmpDir('kg-cli'); });
  afterEach(() => { _cleanup(tmp); });

  function _setupSecret() {
    fs.writeFileSync(path.join(tmp, '.license-secret'), TEST_SECRET);
    return path.join(tmp, '.license-secret');
  }

  test('omitted --start-id uses the auto-counter path (CLI integration)', () => {
    const secretFile = _setupSecret();
    const counterFile = path.join(tmp, '.license-counter');

    // First call: no --start-id, expects counter to be created at 1.
    const out1 = _runCli(['--duration', '30', '--count', '1', '--json'], {
      LICENSE_COUNTER_FILE: counterFile,
      LICENSE_SECRET_FILE: secretFile,
    });
    const codes1 = JSON.parse(out1.split('Generated')[0]);

    expect(codes1.length).toBe(1);
    expect(codes1[0].durationDays).toBe(30);
    expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('1');

    // Second call: counter should auto-increment to 2.
    const out2 = _runCli(['--duration', '30', '--count', '1', '--json'], {
      LICENSE_COUNTER_FILE: counterFile,
      LICENSE_SECRET_FILE: secretFile,
    });
    const codes2 = JSON.parse(out2.split('Generated')[0]);

    expect(codes2[0].codeId).toBeGreaterThan(codes1[0].codeId);
  });

  test('--start-id override skips counter file update (CLI integration)', () => {
    const secretFile = _setupSecret();
    const counterFile = path.join(tmp, '.license-counter');
    fs.writeFileSync(counterFile, '99');

    const out = _runCli(['--duration', '30', '--start-id', '500', '--count', '2', '--json'], {
      LICENSE_COUNTER_FILE: counterFile,
      LICENSE_SECRET_FILE: secretFile,
    });
    const codes = JSON.parse(out.split('Generated')[0]);

    expect(codes.length).toBe(2);
    expect(codes[0].codeId).toBe(500);
    expect(codes[1].codeId).toBe(501);
    // Counter file remains untouched at '99' (override skips auto-update).
    expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('99');
  });

  test('--lifetime and --duration are mutually exclusive (CLI integration)', () => {
    const secretFile = _setupSecret();
    expect(() =>
      _runCli(['--duration', '30', '--lifetime', '--count', '1'], {
        LICENSE_COUNTER_FILE: path.join(tmp, '.license-counter'),
        LICENSE_SECRET_FILE: secretFile,
      }),
    ).toThrow(/mutually exclusive/);
  });

  test('--tier pro without --duration or --lifetime still requires one of them', () => {
    const secretFile = _setupSecret();
    expect(() =>
      _runCli(['--tier', 'pro', '--count', '1'], {
        LICENSE_COUNTER_FILE: path.join(tmp, '.license-counter'),
        LICENSE_SECRET_FILE: secretFile,
      }),
    ).toThrow(/--duration is required/);
  });
});
