'use strict';

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

// ── Test environment ─────────────────────────────────────────────────────────
// Set env vars before any require so modules pick up the right paths.
const testId     = `backup-cleanup-test-${process.pid}`;
const dbPath     = path.join(os.tmpdir(), `${testId}.sqlite`);
const backupPath = path.join(os.tmpdir(), `${testId}-backups`);

process.env.DB_PATH     = dbPath;
process.env.BACKUP_PATH = backupPath;

const { closeDb, setSetting, getDb }                          = require('../db/database');
const {
  BACKUP_DIR,
  createBackup,
  deleteBackup,
  listBackups,
  applyRetention,
  applyScheduledRetention,
  importBackupBuffer,
  checksumFile,
}                                                             = require('../services/backupService');
const {
  validateScheduleSettings,
  computeNextRun,
  runScheduledBackupNow,
}                                                             = require('../services/backupScheduler');
const {
  pruneOrphanedBackupPartials,
  pruneStaleExportFiles,
  pruneSoftDeletedFinancialRecords,
}                                                             = require('../services/cleanupService');

// ── Teardown ─────────────────────────────────────────────────────────────────
test.after(() => {
  closeDb();
  try { fs.unlinkSync(dbPath); }                 catch {}
  try { fs.rmSync(backupPath, { recursive: true, force: true }); } catch {}
});

// ─────────────────────────────────────────────────────────────────────────────
// backupService
// ─────────────────────────────────────────────────────────────────────────────

test('BACKUP_DIR resolves to the BACKUP_PATH env var', () => {
  assert.equal(BACKUP_DIR, path.resolve(backupPath));
});

test('createBackup writes a valid .sqlite file and returns metadata', async () => {
  const meta = await createBackup('bill-tracker-backup');
  assert.ok(meta.id, 'has id');
  assert.ok(meta.filename.endsWith('.sqlite'));
  assert.equal(meta.type, 'manual');
  assert.ok(meta.size_bytes > 0);
  assert.match(meta.checksum, /^[a-f0-9]{64}$/);
  assert.ok(fs.existsSync(path.join(BACKUP_DIR, meta.id)));
});

test('listBackups returns the created backup sorted newest-first', async () => {
  const list = listBackups();
  assert.ok(list.length >= 1);
  assert.ok(list[0].id.startsWith('bill-tracker-backup-') || list[0].id.startsWith('scheduled-backup-'));
});

test('deleteBackup removes the file', async () => {
  const meta = await createBackup('bill-tracker-backup');
  const filePath = path.join(BACKUP_DIR, meta.id);
  assert.ok(fs.existsSync(filePath));
  deleteBackup(meta.id);
  assert.ok(!fs.existsSync(filePath));
});

test('applyRetention keeps only the requested number of backups', async () => {
  for (const b of listBackups()) { try { deleteBackup(b.id); } catch {} }

  await createBackup('bill-tracker-backup');
  await createBackup('bill-tracker-backup');
  await createBackup('bill-tracker-backup');
  assert.equal(listBackups().length, 3);

  const { deleted } = applyRetention(1);
  assert.equal(deleted.length, 2);
  assert.equal(listBackups().length, 1);

  setSetting('backup_schedule_retention_count', '2');
});

test('applyScheduledRetention only prunes scheduled backups', async () => {
  for (const b of listBackups()) { try { deleteBackup(b.id); } catch {} }

  const manual = await createBackup('bill-tracker-backup');
  await createBackup('scheduled-backup');
  await createBackup('scheduled-backup');
  await createBackup('scheduled-backup');

  const { deleted } = applyScheduledRetention(2);
  const backups = listBackups();
  const scheduled = backups.filter(backup => backup.type === 'scheduled');

  assert.equal(deleted.length, 1);
  assert.equal(scheduled.length, 2);
  assert.ok(backups.some(backup => backup.id === manual.id), 'manual backup was not pruned');
});

test('importBackupBuffer accepts a valid SQLite buffer', async () => {
  // Create a real backup, then re-import its bytes
  const src  = await createBackup('bill-tracker-backup');
  const buf  = fs.readFileSync(path.join(BACKUP_DIR, src.id));
  const meta = await importBackupBuffer(buf);
  assert.equal(meta.type, 'imported');
  assert.ok(fs.existsSync(path.join(BACKUP_DIR, meta.id)));
});

test('importBackupBuffer rejects non-SQLite data', async () => {
  const buf = Buffer.from('this is not a sqlite database at all');
  await assert.rejects(
    () => importBackupBuffer(buf),
    err => {
      assert.ok(err.status === 400);
      return true;
    }
  );
});

test('importBackupBuffer rejects a checksum mismatch', async () => {
  const src = await createBackup('bill-tracker-backup');
  const buf = fs.readFileSync(path.join(BACKUP_DIR, src.id));
  await assert.rejects(
    () => importBackupBuffer(buf, { expectedChecksum: 'a'.repeat(64) }),
    err => {
      assert.ok(err.status === 400);
      assert.match(err.message, /checksum/i);
      return true;
    }
  );
});

test('checksumFile returns a 64-char hex string', async () => {
  const src = await createBackup('bill-tracker-backup');
  const sum = checksumFile(path.join(BACKUP_DIR, src.id));
  assert.match(sum, /^[a-f0-9]{64}$/);
});

// ─────────────────────────────────────────────────────────────────────────────
// backupScheduler
// ─────────────────────────────────────────────────────────────────────────────

test('validateScheduleSettings accepts valid daily settings', () => {
  const s = validateScheduleSettings({ enabled: true, frequency: 'daily', time: '03:30', retention_count: '7' });
  assert.equal(s.enabled, true);
  assert.equal(s.frequency, 'daily');
  assert.equal(s.time, '03:30');
  assert.equal(s.retention_count, 7);
});

test('validateScheduleSettings rejects bad frequency', () => {
  assert.throws(
    () => validateScheduleSettings({ enabled: true, frequency: 'hourly', time: '02:00', retention_count: '2' }),
    /frequency/i
  );
});

test('validateScheduleSettings rejects bad time format', () => {
  assert.throws(
    () => validateScheduleSettings({ enabled: true, frequency: 'daily', time: '25:00', retention_count: '2' }),
    /time/i
  );
});

test('validateScheduleSettings rejects retention_count out of range', () => {
  assert.throws(
    () => validateScheduleSettings({ enabled: true, frequency: 'daily', time: '02:00', retention_count: '0' }),
    /retention/i
  );
});

test('computeNextRun returns null when disabled', () => {
  const result = computeNextRun({ enabled: false, frequency: 'daily', time: '02:00' });
  assert.equal(result, null);
});

test('computeNextRun returns a future ISO string when enabled', () => {
  const from = new Date('2026-05-29T10:00:00Z');
  const result = computeNextRun({ enabled: true, frequency: 'daily', time: '02:00' }, from);
  assert.ok(typeof result === 'string');
  assert.ok(new Date(result) > from);
});

test('computeNextRun advances to next day when time has already passed today', () => {
  const from = new Date('2026-05-29T15:00:00Z'); // 3 PM UTC
  const result = computeNextRun({ enabled: true, frequency: 'daily', time: '02:00' }, from);
  const next = new Date(result);
  // Next run should be 2 AM the following day, so > 24h away
  assert.ok(next.getTime() - from.getTime() > 12 * 60 * 60 * 1000);
});

test('runScheduledBackupNow keeps only the configured number of scheduled backups', async () => {
  for (const b of listBackups()) { try { deleteBackup(b.id); } catch {} }

  setSetting('backup_schedule_retention_count', '2');

  await runScheduledBackupNow();
  await runScheduledBackupNow();
  await runScheduledBackupNow();

  const scheduled = listBackups().filter(backup => backup.type === 'scheduled');
  assert.equal(scheduled.length, 2);
});

// ─────────────────────────────────────────────────────────────────────────────
// cleanupService — pruneOrphanedBackupPartials
// ─────────────────────────────────────────────────────────────────────────────

test('pruneOrphanedBackupPartials removes .partial files older than 2 hours', () => {
  fs.mkdirSync(backupPath, { recursive: true });

  const oldPartial = path.join(backupPath, 'old.partial');
  const newPartial = path.join(backupPath, 'new.partial');

  fs.writeFileSync(oldPartial, 'data');
  fs.writeFileSync(newPartial, 'data');

  // Back-date the old file's mtime by 3 hours
  const old = new Date(Date.now() - 3 * 60 * 60 * 1000);
  fs.utimesSync(oldPartial, old, old);

  const result = pruneOrphanedBackupPartials(backupPath);
  assert.equal(result.removed, 1);
  assert.equal(result.errors, 0);
  assert.ok(!fs.existsSync(oldPartial));
  assert.ok(fs.existsSync(newPartial));

  // cleanup
  try { fs.unlinkSync(newPartial); } catch {}
});

test('pruneOrphanedBackupPartials is a no-op for a missing directory', () => {
  const result = pruneOrphanedBackupPartials('/nonexistent/path');
  assert.equal(result.removed, 0);
  assert.equal(result.errors, 0);
});

// ─────────────────────────────────────────────────────────────────────────────
// cleanupService — pruneStaleExportFiles
// ─────────────────────────────────────────────────────────────────────────────

test('pruneStaleExportFiles removes old .sqlite export files', () => {
  const tmpDir  = os.tmpdir();
  const oldFile = path.join(tmpDir, `bill-tracker-user-1-${Date.now()}.sqlite`);
  const newFile = path.join(tmpDir, `bill-tracker-user-2-${Date.now()}.sqlite`);

  fs.writeFileSync(oldFile, 'fake');
  fs.writeFileSync(newFile, 'fake');

  const old = new Date(Date.now() - 3 * 60 * 60 * 1000); // 3 h ago
  fs.utimesSync(oldFile, old, old);

  const result = pruneStaleExportFiles(2);
  assert.ok(result.removed >= 1);
  assert.ok(!fs.existsSync(oldFile));

  try { fs.unlinkSync(newFile); } catch {}
});

test('pruneStaleExportFiles removes .xlsx files older than 24 hours', () => {
  const tmpDir  = os.tmpdir();
  const oldXlsx = path.join(tmpDir, `bill-tracker-user-3-${Date.now()}.xlsx`);
  const newXlsx = path.join(tmpDir, `bill-tracker-user-4-${Date.now()}.xlsx`);

  fs.writeFileSync(oldXlsx, 'fake');
  fs.writeFileSync(newXlsx, 'fake');

  const old = new Date(Date.now() - 25 * 60 * 60 * 1000); // 25 h ago
  fs.utimesSync(oldXlsx, old, old);

  const result = pruneStaleExportFiles(2);
  assert.ok(result.removed >= 1);
  assert.ok(!fs.existsSync(oldXlsx));

  try { fs.unlinkSync(newXlsx); } catch {}
});

test('pruneStaleExportFiles does not remove .xlsx files younger than 24 hours', () => {
  const tmpDir    = os.tmpdir();
  const recentXlsx = path.join(tmpDir, `bill-tracker-user-5-${Date.now()}.xlsx`);

  fs.writeFileSync(recentXlsx, 'fake');
  // mtime stays at now — well within 24 h

  const result = pruneStaleExportFiles(2);
  assert.ok(fs.existsSync(recentXlsx));

  try { fs.unlinkSync(recentXlsx); } catch {}
});

test('pruneStaleExportFiles ignores non-bill-tracker files in tmpdir', () => {
  const tmpDir  = os.tmpdir();
  const other   = path.join(tmpDir, `some-other-tool-export-${Date.now()}.sqlite`);

  fs.writeFileSync(other, 'fake');
  const old = new Date(Date.now() - 48 * 60 * 60 * 1000);
  fs.utimesSync(other, old, old);

  const result = pruneStaleExportFiles(2);
  assert.ok(fs.existsSync(other), 'should not have deleted unrelated file');
  assert.equal(result.errors, 0);

  try { fs.unlinkSync(other); } catch {}
});

// ─────────────────────────────────────────────────────────────────────────────
// cleanupService — pruneSoftDeletedFinancialRecords (QA-B5-04)
// Purging a soft-deleted bill fires the matched_bill_id ON DELETE SET NULL FK.
// Without releasing the match, the transaction would be left match_status='matched'
// with matched_bill_id=NULL — a limbo row excluded from spending yet tied to no bill.
// ─────────────────────────────────────────────────────────────────────────────

function seedMatchedTxn(db, { deletedAt }) {
  const userId = db.prepare(
    "INSERT INTO users (username, password_hash, role, active) VALUES (?, 'x', 'user', 1)",
  ).run(`prune-${Math.random().toString(36).slice(2)}`).lastInsertRowid;
  const billId = db.prepare(
    "INSERT INTO bills (user_id, name, due_day, expected_amount, active, deleted_at) VALUES (?, 'Purge Me', 1, 1000, 1, ?)",
  ).run(userId, deletedAt).lastInsertRowid;
  const txId = db.prepare(
    "INSERT INTO transactions (user_id, source_type, provider_transaction_id, amount, match_status, matched_bill_id, pending, ignored) VALUES (?, 'manual', ?, -1000, 'matched', ?, 0, 0)",
  ).run(userId, `prune-tx-${billId}`, billId).lastInsertRowid;
  return { userId, billId, txId };
}

test('pruneSoftDeletedFinancialRecords releases matches on purged bills (QA-B5-04)', () => {
  const db = getDb();
  db.pragma('foreign_keys = ON');
  const { billId, txId } = seedMatchedTxn(db, { deletedAt: '2020-01-01 00:00:00' });

  const before = db.prepare('SELECT match_status, matched_bill_id FROM transactions WHERE id = ?').get(txId);
  assert.equal(before.match_status, 'matched');
  assert.equal(before.matched_bill_id, billId);

  const result = pruneSoftDeletedFinancialRecords(30);
  assert.ok(result.bills >= 1, 'the old soft-deleted bill was purged');
  assert.ok(result.releasedMatches >= 1, 'the match was released');

  assert.equal(db.prepare('SELECT id FROM bills WHERE id = ?').get(billId), undefined, 'bill is gone');
  const after = db.prepare('SELECT match_status, matched_bill_id FROM transactions WHERE id = ?').get(txId);
  assert.equal(after.match_status, 'unmatched', 'transaction is no longer stuck as matched');
  assert.equal(after.matched_bill_id, null, 'matched_bill_id cleared');
});

test('pruneSoftDeletedFinancialRecords self-heals pre-existing orphans (matched + NULL bill)', () => {
  const db = getDb();
  db.pragma('foreign_keys = ON');
  const userId = db.prepare(
    "INSERT INTO users (username, password_hash, role, active) VALUES (?, 'x', 'user', 1)",
  ).run(`orphan-${Math.random().toString(36).slice(2)}`).lastInsertRowid;
  const txId = db.prepare(
    "INSERT INTO transactions (user_id, source_type, provider_transaction_id, amount, match_status, matched_bill_id, pending, ignored) VALUES (?, 'manual', 'orphan-tx', -500, 'matched', NULL, 0, 0)",
  ).run(userId).lastInsertRowid;

  pruneSoftDeletedFinancialRecords(30);

  const after = db.prepare('SELECT match_status, matched_bill_id FROM transactions WHERE id = ?').get(txId);
  assert.equal(after.match_status, 'unmatched', 'orphan healed back to unmatched');
  assert.equal(after.matched_bill_id, null);
});

test('pruneSoftDeletedFinancialRecords leaves active matches untouched', () => {
  const db = getDb();
  db.pragma('foreign_keys = ON');
  // A recently soft-deleted bill (inside the recovery window) must NOT be purged,
  // and its match must be preserved.
  const recent = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString().replace('T', ' ').slice(0, 19);
  const { billId, txId } = seedMatchedTxn(db, { deletedAt: recent });

  pruneSoftDeletedFinancialRecords(30);

  assert.ok(db.prepare('SELECT id FROM bills WHERE id = ?').get(billId), 'recent soft-deleted bill retained');
  const after = db.prepare('SELECT match_status, matched_bill_id FROM transactions WHERE id = ?').get(txId);
  assert.equal(after.match_status, 'matched', 'match within recovery window preserved');
  assert.equal(after.matched_bill_id, billId);
});
