import { AppEnv, HttpError, requireValue, clean, money, stmt, row, rows, auditStmt, auditEvent, protectMutation, rate, session, requireReauth, ensureSeed, publicData, authStart, enroll, confirmEnrollment, factorVerify, saveRecord, upload, financial } from './core';
import { sha, randomToken } from './security';
import { now, uid } from './seed';
import { matchBank, verifyDonation, rejectDonation, addLedger, importBank, reconciliation } from './finance';
import { runBackup, restorePreview, executeRestore, backupDue } from './backup';
async function input(req: Request): Promise<any> { const raw = await req.text(); requireValue(raw.length <= 1000000, 'Data permintaan terlalu besar.', 413); let b: any; try {
    b = JSON.parse(raw);
}
catch {
    throw new HttpError(400, 'Isi permintaan bukan JSON yang valid.');
} requireValue(b && typeof b === 'object' && !Array.isArray(b), 'Isi permintaan harus berupa objek.'); return b; }
export async function handle(req: Request, e: AppEnv): Promise<Response> {
    try {
        requireValue(e.DB && e.BUCKET, 'Layanan data belum tersedia. Silakan coba lagi.', 503);
        const url = new URL(req.url), path = url.pathname.replace(/^\/api\//, ''), method = req.method;
        const reply = (data: unknown, status = 200) => Response.json(data, { status, headers: { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer' } });
        if (path === 'ops/backup' && method === 'POST') {
            requireValue(e.OPERATIONS_TOKEN && req.headers.get('authorization') === `Bearer ${e.OPERATIONS_TOKEN}`, 'Akses pekerjaan tidak diizinkan.', 401);
            if (!await backupDue(e))
                return reply({ ok: true, skipped: true });
            return reply(await runBackup(e, 'scheduler'));
        }
        if (method !== 'GET')
            protectMutation(req);
        if (path === 'public' && method === 'GET') {
            return reply(await publicData(e, url.searchParams.get('from') || undefined, url.searchParams.get('to') || undefined, url.searchParams.has('program') ? url.searchParams.get('program') || '' : undefined));
        }
        if (path === 'auth/info' && method === 'GET') {
            const identity = req.headers.get('oai-authenticated-user-id'), email = req.headers.get('oai-authenticated-user-email');
            const count = await row(e, 'SELECT count(*) count FROM admins');
            let a: any = null;
            try {
                a = await session(e, req, undefined, false);
            }
            catch { }
            return reply({ signedIn: !!identity, firstSetup: count.count === 0 && e.INITIAL_SETUP_ENABLED === 'true', configured: !!e.DATA_KEY, email: email || null, admin: a ? { id: a.id, name: a.name, email: a.email, role: a.role, factor: a.factor, hasTotp: !!a.totp } : null });
        }
        if (path === 'auth/start' && method === 'POST') {
            await ensureSeed(e);
            const { a, token, factor } = await authStart(e, req);
            return Response.json({ ok: true, role: a.role, factor, hasTotp: !!a.totp }, { headers: { 'Set-Cookie': `ah_session=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=28800`, 'Cache-Control': 'no-store' } });
        }
        if (path === 'auth/logout' && method === 'POST') {
            try {
                const a = await session(e, req, undefined, false);
                await stmt(e, 'DELETE FROM sessions WHERE hash=?', a.session_hash).run();
            }
            catch { }
            return Response.json({ ok: true }, { headers: { 'Set-Cookie': 'ah_session=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0' } });
        }
        if (path === 'auth/enroll' && method === 'POST')
            return reply(await enroll(e, req));
        if (path === 'auth/enroll-confirm' && method === 'POST')
            return reply(await confirmEnrollment(e, req, (await input(req)).code));
        if (path === 'auth/verify' && method === 'POST') {
            const b = await input(req);
            return reply(await factorVerify(e, req, b.code, b.recovery === true, b.reauth === true));
        }
        if (path === 'auth/disable' && method === 'POST') {
            const a = await session(e, req);
            requireReauth(a);
            requireValue(a.role === 'editor', 'Pemilik dan keuangan wajib menggunakan faktor kedua.');
            await e.DB.batch([stmt(e, 'UPDATE admins SET totp=NULL,pending_totp=NULL,last_totp=0 WHERE id=?', a.id), stmt(e, 'DELETE FROM recovery_codes WHERE admin_id=?', a.id), stmt(e, 'DELETE FROM sessions WHERE admin_id=?', a.id), auditStmt(e, a.id, 'factor_disabled', a.id, 'Faktor kedua editor dinonaktifkan setelah autentikasi ulang')]);
            return reply({ ok: true });
        }
        if (path === 'donations' && method === 'POST') {
            await ensureSeed(e);
            await rate(e, req, 'donation', 15);
            const b = await input(req);
            requireValue(!b.website, 'Permintaan ditolak.');
            const bank = await row(e, "SELECT bank,number,holder,confirmed FROM bank_account WHERE id='bsi'");
            requireValue(bank?.confirmed, 'Instruksi pembayaran belum aktif. Pengelola sedang memeriksa rekening.', 409);
            const amount = money(b.amount);
            let programId = null;
            if (b.programId) {
                const p = await row(e, "SELECT live,state FROM records WHERE id=? AND type='program'", b.programId);
                requireValue(p?.live && p.state !== 'archived' && JSON.parse(p.live).open, 'Program sedang tidak menerima donasi.');
                programId = b.programId;
            }
            const token = randomToken(), id = uid(), created = now(), expires = new Date(Date.now() + 72 * 3600000).toISOString();
            await stmt(e, "INSERT INTO donations(id,token_hash,program_id,amount,name,contact,message,publish_name,publish_message,status,source,created_at,expires_at,instructions) VALUES (?,?,?,?,?,?,?,?,?,'waiting_payment','online',?,?,?)", id, await sha(token), programId, amount, clean(b.name, 120) || 'Hamba Allah', clean(b.contact, 200) || null, clean(b.message, 2000) || null, b.publishName === true ? 1 : 0, b.publishMessage === true ? 1 : 0, created, expires, JSON.stringify({ bank: bank.bank, number: bank.number, holder: bank.holder })).run();
            return reply({ id, token, status: 'waiting_payment' }, 201);
        }
        if (path.startsWith('donation/')) {
            const token = path.slice(9);
            requireValue(/^[a-f\d]{64}$/.test(token), 'Halaman status tidak ditemukan.', 404);
            const d = await row(e, 'SELECT * FROM donations WHERE token_hash=?', await sha(token));
            requireValue(d, 'Halaman status tidak ditemukan.', 404);
            if (method === 'GET') {
                if (d.status === 'waiting_payment' && d.expires_at < now()) {
                    await stmt(e, "UPDATE donations SET status='expired' WHERE id=? AND status='waiting_payment'", d.id).run();
                    d.status = 'expired';
                }
                const instruction = d.instructions ? JSON.parse(d.instructions) : null;
                const account = await row(e, 'SELECT bank,number,holder,confirmed FROM bank_account WHERE id=?', instruction ? `bsi:${instruction.number}` : 'bsi');
                const bank = account?.confirmed ? { ...(instruction || account), confirmed: 1 } : null;
                const p = d.program_id ? await row(e, 'SELECT live FROM records WHERE id=?', d.program_id) : null;
                const proofs = await rows(e, 'SELECT id,name,created_at FROM media WHERE donation_id=?', d.id);
                return reply({ id: d.id, amount: d.amount, name: d.name, status: d.status, reason: d.reason, program: p ? JSON.parse(p.live).title : 'Kebutuhan Pembangunan Prioritas', createdAt: d.created_at, expiresAt: d.expires_at, verifiedAt: d.verified_at, receipt: d.receipt_snapshot ? JSON.parse(d.receipt_snapshot) : null, bank: bank?.confirmed ? bank : null, proofs });
            }
            if (method === 'POST') {
                requireValue(!['verified', 'refunded'].includes(d.status), 'Donasi sudah diverifikasi. Bukti baru tidak diperlukan.');
                await rate(e, req, 'proof', 10);
                return reply(await upload(e, req, undefined, d));
            }
        }
        if (path.startsWith('media/') && method === 'GET') {
            const id = path.slice(6);
            const m = await row(e, 'SELECT * FROM media WHERE id=?', id);
            requireValue(m, 'File tidak ditemukan.', 404);
            if (m.visibility !== 'public')
                await session(e, req, ['owner', 'finance']);
            const obj = await e.BUCKET.get(m.key);
            requireValue(obj, 'File belum tersedia.', 404);
            return new Response(obj.body, { headers: { 'Content-Type': m.mime, 'Content-Length': String(m.size), 'Content-Disposition': `${m.mime === 'application/pdf' ? 'inline' : 'inline'}; filename="${m.name.replace(/[^a-zA-Z0-9._-]/g, '_')}"`, 'Cache-Control': m.visibility === 'public' ? 'public, max-age=86400, immutable' : 'private, no-store', 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'; sandbox" } });
        }
        if (path.startsWith('admin/')) {
            const financeRoles = ['owner', 'finance'], contentRoles = ['owner', 'editor'];
            const action = path.slice(6);
            const a = await session(e, req);
            const b: any = method === 'POST' && !(action === 'upload') ? await input(req) : null;
            if (action === 'data' && method === 'GET') {
                const isFinance = financeRoles.includes(a.role), isContent = contentRoles.includes(a.role);
                const records = await rows(e, "SELECT * FROM records WHERE type IN ('site','program','progress','faq','report')");
                const result: any = { admin: { id: a.id, name: a.name, email: a.email, role: a.role, hasTotp: !!a.totp }, records: records.filter(r => r.type === 'report' ? isFinance : isContent || r.type === 'program').map(r => ({ ...r, payload: JSON.parse(r.payload), live: r.live ? JSON.parse(r.live) : null })), media: await rows(e, a.role === 'editor' ? "SELECT id,name,mime,size,visibility,caption,alt,created_at FROM media WHERE visibility='public'" : "SELECT id,name,mime,size,visibility,caption,alt,donation_id,created_at FROM media"), finance: await financial(e) };
                if (isFinance) {
                    result.donations = await rows(e, 'SELECT id,program_id,amount,name,contact,message,publish_name,publish_message,moderated,status,source,created_at,verified_at,reason FROM donations ORDER BY created_at DESC LIMIT 1000');
                    result.ledger = await rows(e, 'SELECT * FROM ledger ORDER BY created_at DESC LIMIT 1000');
                    result.bank = await row(e, "SELECT * FROM bank_account WHERE id='bsi'");
                    result.accounts = await rows(e, 'SELECT * FROM bank_account ORDER BY updated_at DESC');
                    result.reconciliation = await reconciliation(e, url.searchParams.get('from') || '0000', url.searchParams.get('to') || '9999');
                    result.matches = await rows(e, 'SELECT * FROM bank_matches');
                }
                if (a.role === 'owner') {
                    result.admins = await rows(e, 'SELECT id,name,email,role,active,CASE WHEN totp IS NOT NULL THEN 1 ELSE 0 END hasTotp FROM admins');
                    result.invitations = await rows(e, 'SELECT * FROM invitations');
                    result.audit = await rows(e, 'SELECT * FROM audit ORDER BY created_at DESC LIMIT 200');
                    result.backups = await rows(e, 'SELECT * FROM backup_jobs ORDER BY started_at DESC LIMIT 50');
                    result.restores = await rows(e, 'SELECT * FROM restore_jobs ORDER BY created_at DESC LIMIT 50');
                    result.backupConfigured = !!(e.BACKUP_SERVICE_URL && e.BACKUP_SERVICE_TOKEN && e.BACKUP_KEY && e.OPERATIONS_TOKEN);
                    result.backupScheduleActive = e.BACKUP_SCHEDULE_ACTIVE === 'true';
                    result.backupSettings = await row(e, "SELECT value FROM operational WHERE key='backup_settings'");
                }
                return reply(result);
            }
            if (action === 'record' && method === 'POST')
                return reply(await saveRecord(e, a, b));
            if (action === 'versions' && method === 'GET') {
                const rec = await row(e, 'SELECT type FROM records WHERE id=?', url.searchParams.get('id'));
                requireValue(rec, 'Konten tidak ditemukan.', 404);
                requireValue((rec.type === 'report' ? financeRoles : contentRoles).includes(a.role), 'Anda tidak berhak membaca versi ini.', 403);
                return reply(await rows(e, 'SELECT v.*,a.name actor_name FROM content_versions v LEFT JOIN admins a ON a.id=v.actor WHERE record_id=? ORDER BY revision DESC', url.searchParams.get('id')));
            }
            if (action === 'restore-version' && method === 'POST') {
                const v = await row(e, 'SELECT * FROM content_versions WHERE id=?', b.versionId);
                requireValue(v, 'Versi tidak ditemukan.', 404);
                const r = await row(e, 'SELECT * FROM records WHERE id=?', v.record_id);
                return reply(await saveRecord(e, a, { id: r.id, type: r.type, revision: b.revision, payload: JSON.parse(v.snapshot), state: 'draft', summary: `Pemulihan versi ${v.revision}: ${clean(b.reason)}` }));
            }
            if (action === 'upload' && method === 'POST')
                return reply(await upload(e, req, a));
            if (action === 'media' && method === 'POST') {
                const m = await row(e, 'SELECT * FROM media WHERE id=?', b.id);
                requireValue(m, 'File tidak ditemukan.', 404);
                requireValue((m.visibility === 'private' ? financeRoles : contentRoles).includes(a.role), 'Anda tidak berhak mengubah file ini.', 403);
                await e.DB.batch([stmt(e, 'UPDATE media SET caption=?,alt=? WHERE id=?', clean(b.caption, 500), clean(b.alt, 500), m.id), auditStmt(e, a.id, 'media_metadata_updated', m.id, 'Keterangan dan alt text diperbarui; berkas dipertahankan untuk riwayat versi')]);
                return reply({ ok: true });
            }
            if (['verify', 'reject', 'ledger', 'import', 'match', 'offline', 'bank-review', 'unmatch', 'moderate'].includes(action))
                requireValue(financeRoles.includes(a.role), 'Fitur ini khusus keuangan dan pemilik.', 403);
            if (action === 'verify' && method === 'POST')
                return reply(await verifyDonation(e, a, b));
            if (action === 'reject' && method === 'POST')
                return reply(await rejectDonation(e, a, b));
            if (action === 'ledger' && method === 'POST')
                return reply(await addLedger(e, a, b));
            if (action === 'import' && method === 'POST')
                return reply(await importBank(e, a, b));
            if (action === 'match' && method === 'POST')
                return reply(await matchBank(e, a, b));
            if (action === 'unmatch' && method === 'POST') {
                const m = await row(e, 'SELECT * FROM bank_matches WHERE id=?', b.id);
                requireValue(m && clean(b.reason).length >= 8, 'Pencocokan dan alasan wajib diisi.');
                await e.DB.batch([stmt(e, 'DELETE FROM bank_matches WHERE id=?', m.id), auditStmt(e, a.id, 'bank_unmatched', m.id, clean(b.reason))]);
                return reply({ ok: true });
            }
            if (action === 'bank-review' && method === 'POST') {
                const allocated = await row(e, 'SELECT COALESCE(SUM(amount),0) amount FROM bank_matches WHERE bank_id=?', b.id);
                requireValue(!allocated.amount || b.scope === 'donation', 'Mutasi yang sudah dialokasikan tidak dapat dikeluarkan dari cakupan donasi. Lepaskan pencocokan yang belum diverifikasi dengan alasan.');
                requireValue(clean(b.reason).length >= 8 && ['donation', 'other', 'unidentified', 'duplicate_excluded'].includes(b.scope), 'Pilih cakupan dan tulis alasan pemeriksaan.');
                await e.DB.batch([stmt(e, 'UPDATE bank_transactions SET scope=?,duplicate_resolved=1 WHERE id=?', b.scope, b.id), auditStmt(e, a.id, 'bank_reviewed', b.id, `${b.scope}; ${clean(b.reason)}`)]);
                return reply({ ok: true });
            }
            if (action === 'offline' && method === 'POST') {
                const id = uid(), amount = money(b.amount), token = randomToken();
                requireValue(clean(b.reason).length >= 8, 'Konfirmasi asal serta peruntukan dana offline.');
                if (b.programId)
                    requireValue(await row(e, "SELECT id FROM records WHERE id=? AND type='program'", b.programId), 'Program tidak ditemukan.');
                await e.DB.batch([stmt(e, "INSERT INTO donations(id,token_hash,program_id,amount,name,status,source,created_at,expires_at,reason) VALUES (?,?,?,?,?,'waiting_verification','offline',?,?,?)", id, await sha(token), b.programId || null, amount, clean(b.name, 120) || 'Hamba Allah', now(), new Date(Date.now() + 72 * 3600000).toISOString(), clean(b.reason)), auditStmt(e, a.id, 'offline_donation_created', id, clean(b.reason))]);
                return reply({ ok: true, id });
            }
            if (action === 'moderate' && method === 'POST') {
                const d = await row(e, 'SELECT publish_message FROM donations WHERE id=?', b.id);
                requireValue(d?.publish_message, 'Donatur tidak menyetujui publikasi pesan.');
                await e.DB.batch([stmt(e, 'UPDATE donations SET moderated=? WHERE id=?', b.approved ? 1 : 0, b.id), auditStmt(e, a.id, 'message_moderated', b.id, b.approved ? 'Pesan disetujui' : 'Pesan disembunyikan')]);
                return reply({ ok: true });
            }
            if (action === 'reconciliation' && method === 'GET') {
                requireValue(financeRoles.includes(a.role), 'Rekonsiliasi khusus keuangan.', 403);
                return reply(await reconciliation(e, url.searchParams.get('from') || '0000', url.searchParams.get('to') || '9999'));
            }
            if (action === 'export' && method === 'GET') {
                requireValue(financeRoles.includes(a.role), 'Ekspor khusus keuangan.', 403);
                const data = await rows(e, 'SELECT id,amount,program_id,status,source,created_at,verified_at FROM donations ORDER BY created_at');
                const escape = (s: unknown) => '"' + String(s ?? '').replace(/"/g, '""').replace(/^[=+@-]/, "'") + '"';
                return new Response('\ufeff' + ['id,nominal,program,status,sumber,dibuat,diverifikasi', ...data.map(r => Object.values(r).map(escape).join(','))].join('\r\n'), { headers: { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': 'attachment; filename="donasi-alhabibah.csv"', 'Cache-Control': 'no-store' } });
            }
            if (['bank-settings', 'admins', 'backup', 'backup-settings', 'restore-preview', 'restore'].includes(action)) {
                requireValue(a.role === 'owner', 'Fitur ini khusus pemilik.', 403);
                if (action !== 'backup-settings')
                    requireReauth(a);
            }
            if (action === 'bank-settings' && method === 'POST') {
                requireValue(clean(b.bank) && /^\d{6,30}$/.test(b.number) && clean(b.holder), 'Periksa identitas rekening.');
                requireValue(b.confirmed === true && clean(b.reason).length >= 8, 'Konfirmasi pemeriksaan dokumen rekening dan tulis dasar konfirmasi.');
                requireValue(await row(e, 'SELECT id FROM media WHERE id=? AND mime=\'application/pdf\'', b.sourceMediaId), 'Unggah dokumen sumber PDF sebelum mengaktifkan rekening.');
                const old = await row(e, "SELECT bank,number,holder FROM bank_account WHERE id='bsi'");
                const accountId = `bsi:${b.number}`, changedAt = now();
                const changes = [stmt(e, 'INSERT INTO bank_account(id,bank,number,holder,confirmed,source_media_id,confirmed_by,updated_at) VALUES (?,?,?,?,1,?,?,?) ON CONFLICT(id) DO UPDATE SET bank=excluded.bank,holder=excluded.holder,confirmed=1,source_media_id=excluded.source_media_id,confirmed_by=excluded.confirmed_by,updated_at=excluded.updated_at', accountId, clean(b.bank,100), b.number, clean(b.holder,200), b.sourceMediaId, a.id, changedAt)];
                if (b.makeActive !== false) changes.push(stmt(e, "UPDATE bank_account SET bank=?,number=?,holder=?,confirmed=1,source_media_id=?,confirmed_by=?,updated_at=? WHERE id='bsi'", clean(b.bank,100), b.number, clean(b.holder,200), b.sourceMediaId, a.id, changedAt));
                changes.push(auditStmt(e, a.id, 'bank_account_changed', accountId, `Aktif ${b.makeActive !== false}; sebelumnya ${JSON.stringify(old)}; baru ${b.bank}/${b.number}/${b.holder}; ${clean(b.reason)}`));
                await e.DB.batch(changes);
                return reply({ ok: true });
            }
            if (action === 'admins' && method === 'POST') {
                requireValue(['owner', 'finance', 'editor'].includes(b.role) && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(b.email), 'Email dan peran wajib sesuai.');
                if (b.id) {
                    requireValue(b.id !== a.id, 'Akun Anda sendiri tidak dapat dinonaktifkan atau diubah perannya lewat formulir ini.');
                    await e.DB.batch([stmt(e, 'UPDATE admins SET role=?,active=? WHERE id=?', b.role, b.active === false ? 0 : 1, b.id), stmt(e, 'DELETE FROM sessions WHERE admin_id=?', b.id), auditStmt(e, a.id, 'admin_updated', b.id, `${b.role}; aktif ${b.active !== false}`)]);
                }
                else
                    await e.DB.batch([stmt(e, 'INSERT INTO invitations(id,email,role,created_at,created_by) VALUES (?,?,?,?,?) ON CONFLICT(email) DO UPDATE SET role=excluded.role', uid(), b.email.toLowerCase(), b.role, now(), a.id), auditStmt(e, a.id, 'admin_allowlisted', b.email, b.role)]);
                return reply({ ok: true });
            }
            if (action === 'backup' && method === 'POST')
                return reply(await runBackup(e, a.id));
            if (action === 'backup-settings' && method === 'POST') {
                requireValue(Number.isInteger(b.retention) && b.retention >= 7 && b.retention <= 365, 'Masa simpan 7–365 hari.');
                requireValue(/^([01]\d|2[0-3]):[0-5]\d$/.test(b.time), 'Waktu backup tidak sesuai.');
                await e.DB.batch([stmt(e, "INSERT INTO operational(key,value,updated_at) VALUES ('backup_settings',?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at", JSON.stringify({ retention: b.retention, time: b.time, timezone: 'Asia/Jakarta' }), now()), auditStmt(e, a.id, 'backup_settings_updated', 'backup', `${b.time} WIB; masa simpan ${b.retention} hari`)]);
                return reply({ ok: true, note: 'Scheduler eksternal membaca waktu ini. Status tetap belum aktif sampai konfigurasi scheduler diverifikasi.' });
            }
            if (action === 'restore-preview' && method === 'POST')
                return reply(await restorePreview(e, b.id));
            if (action === 'restore' && method === 'POST') {
                requireValue(b.confirmation === 'PULIHKAN DATA' && clean(b.reason).length >= 12, 'Konfirmasi cakupan dengan teks PULIHKAN DATA dan alasan.');
                return reply(await executeRestore(e, a, b));
            }
        }
        throw new HttpError(404, 'Halaman atau tindakan tidak ditemukan.');
    }
    catch (err) {
        const error = err as Error;
        const constrained = /SQLITE_CONSTRAINT/.test(error.message);
        const status = err instanceof HttpError ? err.status : constrained ? 400 : 500;
        const message = err instanceof HttpError ? error.message : /Alokasi|Saldo|Koreksi|Pencocokan|Pengembalian|Catatan keuangan|Donasi terverifikasi/.test(error.message) ? error.message.replace(/^D1_ERROR:\s*/, '').replace(/:\s*SQLITE_.*$/, '') : 'Permintaan belum dapat diselesaikan. Muat ulang dan coba lagi. Data yang sudah tersimpan tetap dipertahankan.';
        return Response.json({ error: message }, { status: status === 500 && message !== error.message ? 500 : status, headers: { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' } });
    }
}
