import { now, uid, seed } from './seed';
import { sha, randomToken, encrypt, decrypt, validateTotp, base32, validateFile } from './security';
export type AppEnv = {
    DB: D1Database;
    BUCKET: R2Bucket;
    DATA_KEY?: string;
    INITIAL_SETUP_ENABLED?: string;
    BOOTSTRAP_OWNER_EMAIL?: string;
    BACKUP_SERVICE_URL?: string;
    BACKUP_SERVICE_TOKEN?: string;
    BACKUP_KEY?: string;
    OPERATIONS_TOKEN?: string;
    SITE_ORIGIN?: string;
    BACKUP_SCHEDULE_ACTIVE?: string;
};
export class HttpError extends Error {
    constructor(public status: number, message: string) { super(message); }
}
export function requireValue(value: unknown, message: string, status = 400): asserts value { if (!value)
    throw new HttpError(status, message); }
export const clean = (v: unknown, max = 10000) => typeof v === 'string' ? v.trim().replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').slice(0, max) : '';
export const isDate = (v: unknown): v is string => { if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(v))
    return false; const d = new Date(v + 'T00:00:00Z'); return Number.isFinite(d.getTime()) && d.toISOString().slice(0, 10) === v; };
export const money = (v: unknown) => { requireValue(typeof v === 'number' && Number.isSafeInteger(v) && v > 0 && v <= 1000000000000, 'Masukkan nominal rupiah bulat positif, maksimal Rp1.000.000.000.000.'); return v as number; };
export const stmt = (e: AppEnv, sql: string, ...a: unknown[]) => e.DB.prepare(sql).bind(...a);
export async function row<T = any>(e: AppEnv, sql: string, ...a: unknown[]): Promise<T | null> { return stmt(e, sql, ...a).first<T>(); }
export async function rows<T = any>(e: AppEnv, sql: string, ...a: unknown[]): Promise<T[]> { return (await stmt(e, sql, ...a).all<T>()).results; }
export const auditStmt = (e: AppEnv, actor: string, action: string, entity: string, detail: string) => stmt(e, 'INSERT INTO audit (id,actor,action,entity,detail,created_at) VALUES (?,?,?,?,?,?)', uid(), actor, action, entity, clean(detail), now());
export async function auditEvent(e: AppEnv, actor: string, action: string, entity: string, detail: string) { await auditStmt(e, actor, action, entity, detail).run(); }
export function protectMutation(req: Request) { const origin = req.headers.get('origin'); requireValue(origin === new URL(req.url).origin && req.headers.get('x-alhabibah') === '1', 'Permintaan tidak diizinkan. Muat ulang halaman.', 403); }
export async function rate(e: AppEnv, req: Request, area: string, limit = 12) { const ip = req.headers.get('cf-connecting-ip') || 'local'; const t = Math.floor(Date.now() / 600000); const key = await sha(`${area}:${ip}:${t}`); await stmt(e, 'INSERT INTO rate_limits (key,count,reset_at) VALUES (?,1,?) ON CONFLICT(key) DO UPDATE SET count=count+1', key, (t + 1) * 600000).run(); const r = await row(e, 'SELECT count FROM rate_limits WHERE key=?', key); requireValue(r.count <= limit, 'Terlalu banyak percobaan. Silakan coba lagi dalam 10 menit.', 429); }
export async function ensureSeed(e: AppEnv) { if (!await row(e, "SELECT id FROM records WHERE id='site'"))
    await seed(e.DB); }
export async function session(e: AppEnv, req: Request, roles?: string[], full = true) {
    const token = req.headers.get('cookie')?.split(';').map(c => c.trim()).find(c => c.startsWith('ah_session='))?.slice(11);
    requireValue(token, 'Silakan masuk terlebih dahulu.', 401);
    const a = await row(e, 'SELECT a.*,s.hash session_hash,s.factor,s.reauth_at,s.expires FROM sessions s JOIN admins a ON a.id=s.admin_id WHERE s.hash=? AND s.expires>? AND a.active=1', await sha(token), now());
    requireValue(a, 'Sesi telah berakhir. Silakan masuk lagi.', 401);
    if (full)
        requireValue(a.factor === 1 && (!['owner', 'finance'].includes(a.role) || !!a.totp), 'Selesaikan verifikasi dua langkah.', 403);
    if (roles)
        requireValue(roles.includes(a.role), 'Anda tidak memiliki hak akses untuk tindakan ini.', 403);
    return a;
}
export function requireReauth(a: any) { requireValue(a.reauth_at && Date.now() - new Date(a.reauth_at).getTime() < 300000, 'Verifikasi ulang kode authenticator sebelum tindakan ini.', 428); }
export async function financial(e: AppEnv, from = '0000', to = '9999', program?: string) {
    const entries = await rows(e, 'SELECT l.kind,l.amount,l.program_id,l.date,o.kind original_kind FROM ledger l LEFT JOIN ledger o ON o.id=l.related_id WHERE l.date>=? AND l.date<=?' + (program !== undefined ? ' AND l.program_id IS ?' : ''), from, to, ...(program !== undefined ? [program || null] : []));
    const sum = (kind: string) => entries.filter(x => x.kind === kind).reduce((s, x) => s + x.amount, 0);
    const receipts = sum('receipt') - sum('receipt_reversal'), expenses = sum('expense') - entries.filter(x => x.kind === 'correction_credit' && x.original_kind === 'expense').reduce((s, x) => s + x.amount, 0), fees = sum('fee') - entries.filter(x => x.kind === 'correction_credit' && x.original_kind === 'fee').reduce((s, x) => s + x.amount, 0), refunds = sum('refund'), allocIn = sum('allocation_in'), allocOut = sum('allocation_out');
    const openingEntries = await rows(e, 'SELECT kind,amount FROM ledger WHERE date<?' + (program !== undefined ? ' AND program_id IS ?' : ''), from, ...(program !== undefined ? [program || null] : []));
    const sign = (k: string) => ['receipt', 'correction_credit', 'allocation_in'].includes(k) ? 1 : -1;
    const opening = openingEntries.reduce((s, x) => s + sign(x.kind) * x.amount, 0);
    return { receipts, expenses, fees, refunds, allocIn, allocOut, opening, balance: opening + receipts - expenses - fees - refunds + allocIn - allocOut, from, to, basis: 'bruto', count: (await row(e, "SELECT count(*) count FROM donations WHERE status IN ('verified','refunded')"))?.count || 0 };
}
export async function publicData(e: AppEnv, from?: string, to?: string, scope?: string) {
    await ensureSeed(e);
    const data = await rows(e, "SELECT id,type,slug,state,live,published_at FROM records WHERE live IS NOT NULL AND state!='deleted'");
    const parsed = data.map(r => ({ ...JSON.parse(r.live), id: r.id, type: r.type, slug: r.slug, state: r.state, publishedAt: r.published_at }));
    const media = await rows(e, "SELECT id,name,mime,caption,alt FROM media WHERE visibility='public'");
    const summaries = await rows(e, "SELECT program_id, SUM(CASE WHEN kind='receipt' THEN amount WHEN kind='receipt_reversal' THEN -amount ELSE 0 END) receipts, SUM(CASE WHEN kind IN ('receipt','correction_credit','allocation_in') THEN amount ELSE -amount END) balance FROM ledger GROUP BY program_id");
    const programs = parsed.filter(r => r.type === 'program').sort((a, b) => a.order - b.order).map(p => ({ ...p, receipts: summaries.find(s => s.program_id === p.id)?.receipts || 0, balance: summaries.find(s => s.program_id === p.id)?.balance || 0 }));
    const fin = await financial(e, from, to, scope);
    const bank = await row(e, 'SELECT bank,number,holder,confirmed FROM bank_account WHERE id=\'bsi\'');
    const donorNames = await rows(e, "SELECT CASE WHEN publish_name=1 THEN name ELSE 'Hamba Allah' END name,CASE WHEN publish_message=1 AND moderated=1 THEN message ELSE NULL END message,amount,verified_at FROM donations WHERE status='verified' ORDER BY verified_at DESC LIMIT 8");
    return { site: parsed.find(r => r.id === 'site'), programs, progress: parsed.filter(r => r.type === 'progress').sort((a, b) => (b.activityDate || '').localeCompare(a.activityDate || '')), faqs: parsed.filter(r => r.type === 'faq').sort((a, b) => a.order - b.order), reports: parsed.filter(r => r.type === 'report'), media, finance: fin, target: programs.filter(p => p.main).reduce((s, p) => s + p.target, 0), bank: bank?.confirmed ? bank : null, paymentReady: !!bank?.confirmed, priority: summaries.find(s => s.program_id === null) || { receipts: 0, balance: 0 }, donorNames };
}
export async function authStart(e: AppEnv, req: Request) {
    await rate(e, req, 'login', 20);
    const identity = req.headers.get('oai-authenticated-user-id'), email = req.headers.get('oai-authenticated-user-email')?.toLowerCase();
    requireValue(identity && email, 'Masuk melalui akun ChatGPT terlebih dahulu.', 401);
    requireValue(e.DATA_KEY, 'Konfigurasi keamanan server belum tersedia.', 503);
    let a = await row(e, 'SELECT * FROM admins WHERE identity=?', identity);
    if (!a) {
        const invitation = await row(e, 'SELECT * FROM invitations WHERE email=?', email);
        const count = await row(e, 'SELECT count(*) count FROM admins');
        const first = count.count === 0 && e.INITIAL_SETUP_ENABLED === 'true' && (!e.BOOTSTRAP_OWNER_EMAIL || e.BOOTSTRAP_OWNER_EMAIL.toLowerCase() === email);
        requireValue(first || invitation, 'Akun ini belum terdaftar sebagai admin. Hubungi pemilik.', 403);
        const id = uid();
        await e.DB.batch([stmt(e, 'INSERT INTO admins(id,identity,email,name,role,active,created_at) VALUES (?,?,?,?,?,1,?)', id, identity, email, email, first ? 'owner' : invitation.role, now()), auditStmt(e, id, 'admin_created', id, 'Pendaftaran akun terautentikasi'), stmt(e, 'DELETE FROM invitations WHERE email=?', email)]);
        a = await row(e, 'SELECT * FROM admins WHERE id=?', id);
    }
    requireValue(a.active, 'Akun dinonaktifkan.', 403);
    const token = randomToken();
    const expires = new Date(Date.now() + 8 * 3600000).toISOString();
    const factor = a.role === 'editor' && !a.totp ? 1 : 0;
    await stmt(e, 'INSERT INTO sessions(hash,admin_id,factor,expires,created_at) VALUES (?,?,?,?,?)', await sha(token), a.id, factor, expires, now()).run();
    return { a, token, factor };
}
export async function factorVerify(e: AppEnv, req: Request, code: string, recovery = false, reauth = false) {
    await rate(e, req, 'factor', 8);
    const a = await session(e, req, undefined, false);
    requireValue(a.totp, 'Aktifkan authenticator terlebih dahulu.');
    let step: number | null = null;
    if (recovery) {
        const hash = await sha(`${a.id}:${clean(code).replace(/\s/g, '')}`);
        const r = await stmt(e, 'UPDATE recovery_codes SET used_at=? WHERE hash=? AND admin_id=? AND used_at IS NULL', now(), hash, a.id).run();
        requireValue(r.meta.changes === 1, 'Kode pemulihan salah atau telah digunakan.', 401);
    }
    else {
        step = await validateTotp(await decrypt(a.totp, e.DATA_KEY!), clean(code), a.last_totp);
        requireValue(step, 'Kode salah, sudah dipakai, atau kedaluwarsa.', 401);
        const r = await stmt(e, 'UPDATE admins SET last_totp=? WHERE id=? AND last_totp<?', step, a.id, step).run();
        requireValue(r.meta.changes === 1, 'Kode sudah dipakai. Tunggu kode berikutnya.', 401);
    }
    await e.DB.batch([stmt(e, 'UPDATE sessions SET factor=1,reauth_at=? WHERE hash=?', reauth ? now() : null, a.session_hash), auditStmt(e, a.id, recovery ? 'recovery_code_used' : reauth ? 'reauthenticated' : 'factor_verified', a.id, 'Pemeriksaan faktor kedua berhasil')]);
    return { ok: true };
}
export async function enroll(e: AppEnv, req: Request) { const a = await session(e, req, undefined, false); if (a.totp) {
    requireValue(a.factor === 1, 'Selesaikan faktor kedua.', 403);
    requireReauth(a);
} const secret = base32(crypto.getRandomValues(new Uint8Array(20))); await stmt(e, 'UPDATE admins SET pending_totp=? WHERE id=?', await encrypt(secret, e.DATA_KEY!), a.id).run(); return { secret, uri: `otpauth://totp/Al-Habibah:${encodeURIComponent(a.email)}?secret=${secret}&issuer=Al-Habibah&algorithm=SHA1&digits=6&period=30` }; }
export async function confirmEnrollment(e: AppEnv, req: Request, code: string) { await rate(e, req, 'enroll', 8); const a = await session(e, req, undefined, false); requireValue(a.pending_totp, 'Mulai aktivasi authenticator terlebih dahulu.'); const step = await validateTotp(await decrypt(a.pending_totp, e.DATA_KEY!), clean(code)); requireValue(step, 'Kode authenticator tidak sesuai.'); const codes = Array.from({ length: 8 }, () => randomToken(16)); const hashes = await Promise.all(codes.map(c => sha(`${a.id}:${c}`))); await e.DB.batch([stmt(e, 'UPDATE admins SET totp=pending_totp,pending_totp=NULL,last_totp=? WHERE id=?', step, a.id), stmt(e, 'DELETE FROM recovery_codes WHERE admin_id=?', a.id), ...hashes.map(h => stmt(e, 'INSERT INTO recovery_codes(hash,admin_id) VALUES (?,?)', h, a.id)), stmt(e, 'UPDATE sessions SET factor=1,reauth_at=? WHERE hash=?', now(), a.session_hash), stmt(e, 'DELETE FROM sessions WHERE admin_id=? AND hash!=?', a.id, a.session_hash), auditStmt(e, a.id, 'factor_enabled', a.id, 'Authenticator diaktifkan; kode pemulihan disimpan sebagai hash')]); return { codes }; }
export function validateContent(type: string, p: any) {
    requireValue(p && typeof p === 'object' && !Array.isArray(p), 'Isi konten tidak sesuai.');
    const out = JSON.parse(JSON.stringify(p));
    requireValue(JSON.stringify(out).length < 100000, 'Konten terlalu panjang.');
    const walk = (o: any) => { for (const k of Object.keys(o)) {
        if (typeof o[k] === 'string') {
            o[k] = clean(o[k], 20000);
            if (/(href|url)$/i.test(k) && o[k])
                requireValue(o[k].startsWith('/') && !o[k].startsWith('//') || /^https:\/\//.test(o[k]), 'Tautan harus berupa alamat HTTPS atau jalur situs.');
        }
        else if (o[k] && typeof o[k] === 'object')
            walk(o[k]);
    } };
    walk(out);
    if (type === 'program') {
        requireValue(clean(out.title), 'Judul program wajib diisi.');
        money(out.target);
        requireValue(Array.isArray(out.budget), 'Rincian anggaran wajib diisi.');
        for (const b of out.budget)
            money(b.amount);
        requireValue(out.budget.reduce((s: number, b: any) => s + b.amount, 0) === out.target, 'Jumlah rincian anggaran harus sama dengan target program.');
        out.open = out.open === true;
        out.main = out.main !== false;
        out.order = Number(out.order) || 0;
    }
    if (type === 'progress') {
        requireValue(clean(out.title) && isDate(out.activityDate), 'Judul dan tanggal kegiatan wajib diisi.');
        if (out.physicalPercent !== null && out.physicalPercent !== undefined && out.physicalPercent !== '') {
            requireValue(typeof out.physicalPercent === 'number' && out.physicalPercent >= 0 && out.physicalPercent <= 100 && clean(out.physicalBasis), 'Persentase fisik 0–100 memerlukan dasar penilaian.');
        }
        else
            out.physicalPercent = null;
    }
    if (type === 'faq')
        requireValue(clean(out.question) && clean(out.answer), 'Pertanyaan dan jawaban wajib diisi.');
    if (type === 'report')
        requireValue(clean(out.title) && clean(out.summary) && isDate(out.from) && isDate(out.to) && out.from <= out.to, 'Judul, ringkasan, dan periode laporan wajib diisi.');
    return out;
}
export async function saveRecord(e: AppEnv, a: any, input: any) {
    const type = clean(input.type);
    requireValue(['site', 'program', 'progress', 'faq', 'report'].includes(type), 'Jenis konten tidak sesuai.');
    if (type === 'report')
        requireValue(['owner', 'finance'].includes(a.role), 'Laporan hanya dapat diubah bagian keuangan.', 403);
    else
        requireValue(['owner', 'editor'].includes(a.role), 'Konten hanya dapat diubah pemilik atau editor.', 403);
    const existing = input.id ? await row(e, 'SELECT * FROM records WHERE id=?', input.id) : null;
    requireValue(!existing || existing.type === type, 'Jenis konten tidak dapat diubah.');
    if (type === 'site')
        requireValue(existing?.id === 'site', 'Identitas situs tidak sesuai.');
    const payload = validateContent(type, input.payload);
    const mediaIds: string[] = [];
    const collect = (p: any) => { for (const [k, v] of Object.entries(p)) {
        if (k === 'media' && Array.isArray(v))
            for (const id of v)
                mediaIds.push(String(id));
        else if ((/media$/i.test(k) || k === 'cover' || k === 'before' || k === 'after') && typeof v === 'string' && v)
            mediaIds.push(v);
        else if (v && typeof v === 'object')
            collect(v);
    } };
    collect(payload);
    for (const id of mediaIds)
        requireValue(await row(e, "SELECT id FROM media WHERE id=? AND visibility='public'", id), 'Gambar atau dokumen publik tidak ditemukan.');
    const state = clean(input.state);
    requireValue(['draft', 'published', 'archived'].includes(state), 'Status publikasi tidak sesuai.');
    if (type === 'report' && state === 'published') {
        payload.totals = await financial(e, payload.from, payload.to, payload.programId || undefined);
        payload.approvedBy = a.id;
        payload.approvedAt = now();
    }
    const id = existing?.id || uid(), slug = existing?.slug || clean(input.slug || payload.title || id).toLowerCase().replace(/[^a-z0-9]+/g, '-');
    const json = JSON.stringify(payload), s = now(), summary = clean(input.summary, 500);
    requireValue(summary, 'Ringkasan perubahan wajib diisi.');
    if (existing) {
        requireValue(Number(input.revision) === existing.revision, 'Konten sudah diubah admin lain. Muat ulang dan periksa perbedaannya.', 409);
        const next = existing.revision + 1;
        const result = await e.DB.batch([stmt(e, 'UPDATE records SET payload=?,live=CASE WHEN ?=\'published\' THEN ? ELSE live END,state=?,revision=revision+1,updated_at=?,published_at=CASE WHEN ?=\'published\' THEN ? ELSE published_at END,updated_by=? WHERE id=? AND revision=?', json, state, json, state, s, state, s, a.id, id, existing.revision), stmt(e, 'INSERT INTO content_versions (id,record_id,revision,snapshot,state,summary,actor,created_at) SELECT ?,id,revision,payload,state,?,?,? FROM records WHERE id=? AND revision=? AND updated_by=?', uid(), summary, a.id, s, id, next, a.id), auditStmt(e, a.id, 'content_saved', id, summary)]);
        requireValue(result[0].meta.changes === 1, 'Konflik perubahan. Muat ulang konten.', 409);
    }
    else
        await e.DB.batch([stmt(e, 'INSERT INTO records(id,type,slug,state,revision,payload,live,updated_at,published_at,updated_by) VALUES (?,?,?,?,1,?,?,?,?,?)', id, type, slug, state, json, state === 'published' ? json : null, s, state === 'published' ? s : null, a.id), stmt(e, 'INSERT INTO content_versions(id,record_id,revision,snapshot,state,summary,actor,created_at) VALUES (?,?,1,?,?,?,?,?)', uid(), id, json, state, summary, a.id, s), auditStmt(e, a.id, 'content_created', id, summary)]);
    return { id, ok: true };
}
export async function upload(e: AppEnv, req: Request, a?: any, donation?: any) {
    let form: FormData;
    try {
        form = await req.formData();
    }
    catch {
        throw new HttpError(400, 'Unggahan tidak sesuai. Pilih file dan coba kembali.');
    }
    const file = form.get('file');
    requireValue(file instanceof File, 'Pilih file terlebih dahulu.');
    let mime: string;
    try {
        mime = await validateFile(file, !!a);
    }
    catch (err) {
        throw new HttpError(400, (err as Error).message);
    }
    const visibility = donation ? 'private' : form.get('visibility') === 'private' ? 'private' : 'public';
    if (!donation && visibility === 'private')
        requireValue(['owner', 'finance'].includes(a.role), 'Dokumen privat hanya untuk bagian keuangan.', 403);
    const id = uid(), key = `${visibility}/${id}`;
    await e.BUCKET.put(key, file.stream(), { httpMetadata: { contentType: mime } });
    try {
        await stmt(e, 'INSERT INTO media (id,key,name,mime,size,visibility,caption,alt,donation_id,created_by,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)', id, key, clean(file.name, 120), mime, file.size, visibility, clean(form.get('caption'), 500), clean(form.get('alt'), 500), donation?.id || null, a?.id || null, now()).run();
    }
    catch (err) {
        await e.BUCKET.delete(key);
        throw err;
    }
    if (donation) {
        await e.DB.batch([stmt(e, "UPDATE donations SET status='waiting_verification',reason=NULL WHERE id=? AND status IN ('waiting_payment','rejected','expired','waiting_verification')", donation.id), auditStmt(e, 'donor', 'proof_uploaded', donation.id, 'Bukti privat diterima; menunggu pemeriksaan bank')]);
    }
    else
        await auditEvent(e, a.id, 'media_uploaded', id, 'File tersimpan');
    return { id, ok: true, status: donation ? 'waiting_verification' : undefined };
}
