import { z } from "zod";
import { familyRoot, knownApps } from "./registry.js";
import { getPool, withTransaction } from "./db.js";
import { bumpCounter } from "./counters.js";
import { loadPrefsMap } from "./prefs.js";
import { evaluateGate } from "./quiet.js";
import { wakeDrain } from "./drain.js";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const eventSchema = z
    .object({
    app_id: z.string().min(1).max(200),
    category: z
        .string()
        .max(80)
        .regex(/^[a-z0-9_]+(\.[a-z0-9_]+)+$/, "category is producer-namespaced, dot-separated"),
    recipients: z.array(z.string().regex(UUID_RE)).max(1000),
    actor_user_id: z.string().regex(UUID_RE).optional(),
    title: z.string().min(1).max(200),
    body: z.string().min(1).max(1000),
    coalesced_body: z.string().min(1).max(1000).optional(),
    data: z.record(z.string().min(1).max(40), z.string().max(500)).optional(),
    collapse_key: z.string().min(1).max(120).optional(),
    dedupe_key: z.string().min(1).max(200).optional(),
    coalesce_window_s: z.number().int().min(0).max(3600).optional(),
    ttl_s: z.number().int().min(60).max(7 * 24 * 3600).optional(),
    priority: z.enum(["normal", "high"]).optional(),
})
    .refine((v) => !v.data || JSON.stringify(v.data).length <= 3000, {
    message: "data is too large for a push payload",
})
    .refine((v) => !v.data ||
    Object.keys(v.data).every((k) => !/^(google|gcm)\./.test(k) && !["from", "notification", "message_type"].includes(k)), { message: "data uses an FCM-reserved key" });
export async function emit(event) {
    try {
        await enqueueEvent(event);
    }
    catch (err) {
        console.error("[notify] emit dropped an event", err);
        const appId = typeof event?.app_id === "string" ? event.app_id : "unknown";
        const category = typeof event?.category === "string" ? event.category : "unknown";
        await bumpCounter(appId, category, "failed");
    }
}
export async function enqueueEvent(raw) {
    const parsed = eventSchema.safeParse(raw);
    if (!parsed.success) {
        throw new Error(parsed.error.issues[0]?.message ?? "invalid event");
    }
    const event = { ...parsed.data, app_id: familyRoot(parsed.data.app_id) };
    if (!(await knownApps()(event.app_id))) {
        throw new Error(`app_id ${event.app_id} is not a registered app`);
    }
    const recipients = [...new Set(event.recipients)].filter((r) => r !== event.actor_user_id);
    if (recipients.length === 0)
        return { queued: 0, muted: 0, quiet_dropped: 0 };
    const now = new Date();
    const ttlS = event.ttl_s ?? 12 * 3600;
    const expiresAt = new Date(now.getTime() + ttlS * 1000);
    const windowS = event.collapse_key ? (event.coalesce_window_s ?? 60) : 0;
    const prefs = await loadPrefsMap(getPool(), recipients, event.app_id);
    let muted = 0;
    let quietDropped = 0;
    const rows = [];
    for (const userId of recipients) {
        const gate = evaluateGate(prefs.get(userId), event.category, now);
        if (gate.kind === "muted") {
            muted++;
            continue;
        }
        let sendAfter = new Date(now.getTime() + windowS * 1000);
        if (gate.kind === "deferred") {
            if (gate.until.getTime() > expiresAt.getTime()) {
                quietDropped++;
                continue;
            }
            if (gate.until.getTime() > sendAfter.getTime())
                sendAfter = gate.until;
        }
        rows.push({ userId, sendAfter });
    }
    if (rows.length > 0) {
        await withTransaction(async (tx) => {
            for (const row of rows) {
                await tx.query(`INSERT INTO outbox
             (app_id, category, user_id, title, body, coalesced_body, collapse_key,
              data, priority, dedupe_key, send_after, expires_at)
           VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
                   now() + make_interval(secs => $11::double precision),
                   now() + make_interval(secs => $12::double precision))
           ON CONFLICT (app_id, user_id, dedupe_key) WHERE dedupe_key IS NOT NULL DO NOTHING`, [
                    event.app_id,
                    event.category,
                    row.userId,
                    event.title,
                    event.body,
                    event.coalesced_body ?? null,
                    event.collapse_key ?? null,
                    JSON.stringify(event.data ?? {}),
                    event.priority ?? "normal",
                    event.dedupe_key ?? null,
                    Math.max(0, (row.sendAfter.getTime() - now.getTime()) / 1000),
                    Math.max(0, (expiresAt.getTime() - now.getTime()) / 1000),
                ]);
            }
        });
    }
    await bumpCounter(event.app_id, event.category, "muted", muted);
    await bumpCounter(event.app_id, event.category, "quiet_dropped", quietDropped);
    if (rows.length > 0)
        wakeDrain();
    return { queued: rows.length, muted, quiet_dropped: quietDropped };
}
