import { AppEnv, HttpError, requireValue, row, rows, stmt, auditEvent } from './core';
import { encrypt, decrypt, sha } from './security';
import { now, uid } from './seed';
export const backupTables = ['records', 'admins', 'invitations', 'media', 'bank_account', 'bank_imports', 'bank_transactions', 'donations', 'bank_matches', 'ledger', 'content_versions', 'audit', 'recovery_codes', 'operational', 'backup_jobs', 'restore_jobs'];
export type Snapshot = {
    format: number;
    startedAt: string;
    keyId: string;
    tables: Record<string, any[]>;
    files: {
        key: string;
        checksum: string;
        size: number;
    }[];
    missingFiles?: string[];
    applicationKey?: string;
};
function configured(e: AppEnv) { requireValue(e.BACKUP_SERVICE_URL && e.BACKUP_SERVICE_URL.startsWith('https://') && e.BACKUP_SERVICE_TOKEN && e.BACKUP_KEY, 'Backup belum aktif. Hubungkan penyimpanan cadangan terpisah dan kunci enkripsi terlebih dahulu.', 503); }
async function remote(e: AppEnv, path: string, method = 'GET', body?: string, expiresAt?: string) { configured(e); const res = await fetch(`${e.BACKUP_SERVICE_URL!.replace(/\/$/, '')}/archive/${path}`, { method, headers: { Authorization: `Bearer ${e.BACKUP_SERVICE_TOKEN}`, 'Content-Type': 'application/octet-stream', ...(expiresAt ? { 'x-backup-expires': expiresAt } : {}) }, body }); requireValue(res.ok, 'Layanan cadangan tidak dapat diakses. Periksa konfigurasi.', 503); return res; }
export async function snapshot(e: AppEnv): Promise<Snapshot> { const startedAt = now(); const read = await e.DB.batch(backupTables.map(t => e.DB.prepare(`SELECT * FROM "${t}"`))); const tables: Record<string, any[]> = {}; for (let i = 0; i < backupTables.length; i++)
    tables[backupTables[i]] = read[i].results || []; const size = JSON.stringify(tables).length; requireValue(size < 15000000, 'Ukuran database melampaui batas backup aplikasi. Aktifkan ekspor D1 melalui layanan backup khusus.', 503); return { format: 1, startedAt, keyId: await sha(e.DATA_KEY || ''), tables, files: [] }; }
export async function runBackup(e: AppEnv, actor: string, allowMissing = false) {
    configured(e);
    const last = await row(e, "SELECT id FROM backup_jobs WHERE status='running' AND started_at>?", new Date(Date.now() - 3600000).toISOString());
    requireValue(!last, 'Backup lain sedang berjalan.', 409);
    const id=uid(),start=now();const lock=await stmt(e,"INSERT INTO operational(key,value,updated_at) VALUES ('backup_lock',?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at WHERE operational.updated_at<?",id,start,new Date(Date.now()-3600000).toISOString()).run();requireValue(lock.meta.changes===1,'Backup lain sedang berjalan.',409);
    const config = await row(e, "SELECT value FROM operational WHERE key='backup_settings'");
    const retention = config ? JSON.parse(config.value).retention : 30;
    const expires = new Date(Date.now() + retention * 86400000).toISOString();
    await stmt(e, 'INSERT INTO backup_jobs(id,started_at,status,scope,expires_at,actor) VALUES (?,?,\'running\',\'Database dan seluruh media, termasuk dokumen privat\',?,?)', id, start, expires, actor).run();
    try {
        const data = await snapshot(e);
        data.applicationKey = e.DATA_KEY;
        data.missingFiles = [];
        for (const media of data.tables.media) {
            const obj = await e.BUCKET.get(media.key);
            if (!obj && allowMissing) {
                data.missingFiles.push(media.id);
                continue;
            }
            requireValue(obj, `File yang dibutuhkan backup tidak ditemukan: ${media.id}`, 503);
            const bytes = new Uint8Array(await obj.arrayBuffer());
            const b64 = bytesToBase64(bytes);
            const checksum = await sha(b64);
            await remote(e, `${id}/file-${media.id}`, 'PUT', await encrypt(b64, e.BACKUP_KEY!), expires);
            data.files.push({ key: media.key, checksum, size: media.size });
        }
        const key = `${id}/manifest`;
        await remote(e, key, 'PUT', await encrypt(JSON.stringify(data), e.BACKUP_KEY!), expires);
        await stmt(e, "UPDATE backup_jobs SET status=?,finished_at=?,manifest_key=?,error=? WHERE id=?", data.missingFiles.length ? 'partial' : 'succeeded', now(), key, data.missingFiles.length ? `Checkpoint mencatat ${data.missingFiles.length} file yang tidak tersedia. Bukan cadangan lengkap.` : null, id).run();
        await auditEvent(e, actor, 'backup_completed', id, `${data.files.length} file; ${backupTables.length} tabel; arsip terenkripsi di penyimpanan terpisah`);
        return { ok: true, id, partial: data.missingFiles.length > 0 };
    }
    catch (err) {
        await stmt(e, "UPDATE backup_jobs SET status='failed',finished_at=?,error=? WHERE id=?", now(), 'Backup gagal. Periksa koneksi penyimpanan dan kelengkapan file.', id).run();
        throw err;
    } finally {await stmt(e,"DELETE FROM operational WHERE key='backup_lock' AND value=?",id).run();}
}
export function bytesToBase64(bytes: Uint8Array) { let s = ''; for (let i = 0; i < bytes.length; i += 8192)
    s += String.fromCharCode(...bytes.subarray(i, i + 8192)); return btoa(s); }
export function base64ToBytes(s: string) { return Uint8Array.from(atob(s), c => c.charCodeAt(0)); }
async function loadSnapshot(e: AppEnv, id: string) { const job = await row(e, "SELECT * FROM backup_jobs WHERE id=? AND status='succeeded'", id); requireValue(job, 'Backup berhasil tidak ditemukan.', 404); const content = await (await remote(e, job.manifest_key)).text(); const data = JSON.parse(await decrypt(content, e.BACKUP_KEY!)) as Snapshot; requireValue(data.format === 1 && data.keyId === await sha(e.DATA_KEY || ''), 'Format atau kunci aplikasi tidak sesuai. Gunakan lingkungan pemulihan dengan kunci yang sama.', 409); return { job, data }; }
export async function restorePreview(e: AppEnv, id: string) { const { job, data } = await loadSnapshot(e, id); const current = await row(e, 'SELECT count(*) count FROM ledger WHERE created_at>?', data.startedAt); return { id, startedAt: data.startedAt, counts: Object.fromEntries(Object.entries(data.tables).map(([k, v]) => [k, v.length])), files: data.files.length, laterFinancialEntries: current.count, mode: 'Pemulihan menambahkan data dan file yang hilang. Data yang masih ada dan transaksi setelah backup dipertahankan. Untuk pemulihan penuh, uji di database terpisah dahulu dan gunakan panduan pemulihan.', scope: job.scope }; }
export async function restoreSnapshot(e: AppEnv, data: Snapshot) {
    requireValue(data.format === 1, 'Format backup tidak sesuai.');
    const allStatements = [e.DB.prepare('PRAGMA defer_foreign_keys=ON')];
    const deferred = [];
    for (const table of backupTables) {
        if (['backup_jobs', 'restore_jobs'].includes(table))
            continue;
        const tableInfo = await rows(e, `PRAGMA table_info("${table}")`);
        const allowed = new Set(tableInfo.map(x => x.name));
        for (const original of data.tables[table] || []) {
            const columns = Object.keys(original).filter(k => allowed.has(k));
            const value = { ...original };
            if (table === 'bank_account') {
                value.confirmed = 0;
                value.confirmed_by = null;
            }
            if (table === 'donations' && ['verified', 'refunded'].includes(value.status)) {
                if (!await row(e, 'SELECT id FROM donations WHERE id=?', value.id)) {
                    deferred.push(original);
                    value.status = 'waiting_verification';
                    value.verified_at = null;
                    value.receipt_snapshot = null;
                }
            }
            const sql = `INSERT OR IGNORE INTO "${table}" (${columns.map(c => `"${c}"`).join(',')}) VALUES (${columns.map(() => '?').join(',')})`;
            allStatements.push(e.DB.prepare(sql).bind(...columns.map(k => value[k] ?? null)));
        }
    }
    // All SELECTs above finish before a single transaction inserts relational data.
    allStatements.push(...deferred.map(d => stmt(e, 'UPDATE donations SET status=?,verified_at=?,receipt_snapshot=? WHERE id=? AND verified_at IS NULL', d.status, d.verified_at, d.receipt_snapshot, d.id)));
    requireValue(allStatements.length <= 5000, 'Backup membutuhkan pemulihan operator melalui D1; gunakan panduan untuk data lebih besar.', 503);
    if (allStatements.length)
        await e.DB.batch(allStatements);
    return { rows: allStatements.length };
}
export async function executeRestore(e: AppEnv, a: any, b: any) {
    const { data } = await loadSnapshot(e, b.id);
    const before = await runBackup(e, a.id, true);
    const jobId = uid();
    await stmt(e, 'INSERT INTO restore_jobs(id,backup_id,status,scope,reason,actor,created_at,detail) VALUES (?,?,\'running\',\'Data dan file yang hilang; catatan aktif dipertahankan\',?,?,?,?)', jobId, b.id, b.reason, a.id, now(), `Backup kondisi saat ini: ${before.id}`).run();
    try {
        for (const media of data.tables.media) {
            if (await e.BUCKET.head(media.key))
                continue;
            const ciphertext = await (await remote(e, `${b.id}/file-${media.id}`)).text();
            const plaintext = await decrypt(ciphertext, e.BACKUP_KEY!);
            const expected = data.files.find(f => f.key === media.key);
            requireValue(expected && await sha(plaintext) === expected.checksum, 'Checksum media tidak cocok. Pemulihan dihentikan.', 409);
            await e.BUCKET.put(media.key, base64ToBytes(plaintext), { httpMetadata: { contentType: media.mime } });
        }
        const result = await restoreSnapshot(e, data);
        await stmt(e, "UPDATE restore_jobs SET status='succeeded',detail=? WHERE id=?", `Backup sebelum pemulihan: ${before.id}. ${result.rows} operasi. Rekonsiliasi transaksi setelah ${data.startedAt} wajib diperiksa.`, jobId).run();
        await auditEvent(e, a.id, 'restore_completed', jobId, b.reason);
        return { ok: true, jobId, note: 'Data yang hilang dipulihkan. Periksa rekonsiliasi dan hak akses sebelum membuka layanan.' };
    }
    catch (err) {
        await stmt(e, "UPDATE restore_jobs SET status='failed',detail=? WHERE id=?", 'Pemulihan gagal. Kondisi database terlindungi oleh transaksi; file yang sudah dikembalikan tetap tersedia. Periksa panduan pemulihan.', jobId).run();
        throw err;
    }
}
export async function backupDue(e: AppEnv) { const settings = await row(e, "SELECT value FROM operational WHERE key='backup_settings'"); const time = settings ? JSON.parse(settings.value).time : '02:00'; const date = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Jakarta', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()); const clock = new Intl.DateTimeFormat('en-GB', { timeZone: 'Asia/Jakarta', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date()); const last = await row(e, "SELECT started_at FROM backup_jobs WHERE status='succeeded' ORDER BY started_at DESC LIMIT 1"); return clock >= time && (!last || new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Jakarta', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(last.started_at)) !== date); }
