import { bumpCounter, pruneCounters } from "./counters.js";
import { pruneSendFailures, recordSendFailure } from "./failures.js";
import { getConfig } from "./config.js";
import { withTransaction } from "./db.js";
import { sendPush } from "./fcm.js";
import { loadPrefsMap } from "./prefs.js";
import { pruneProducerRequests } from "./producers.js";
import { sharedGet, sharedSet } from "./shared.js";
import { evaluateGate } from "./quiet.js";
const TICK_MS = 5_000;
const CLAIM_LIMIT = 100;
const SEND_CONCURRENCY = 8;
const MAX_ATTEMPTS = 5;
const PRUNE_EVERY_MS = 24 * 3600 * 1000;
const LEASE_S = 120;
const PUSH_DIRECTORY = "pushDirectory";
const DRAIN_STARTED_AT = "drainStartedAt";
let timer = null;
let inFlight = null;
let wakeQueued = false;
let lastPrune = 0;
export function setPushDirectory(directory) {
    sharedSet(PUSH_DIRECTORY, directory);
}
export function drainStartedAt() {
    return sharedGet(DRAIN_STARTED_AT);
}
export function hasPushDirectory() {
    return Boolean(sharedGet(PUSH_DIRECTORY));
}
export function startNotifyDrain(directory) {
    if (directory)
        sharedSet(PUSH_DIRECTORY, directory);
    if (timer)
        return;
    announceDeliveryConfiguration();
    sharedSet(DRAIN_STARTED_AT, new Date());
    timer = setInterval(() => void tick(), TICK_MS);
    timer.unref?.();
}
export function stopNotifyDrain() {
    if (timer)
        clearInterval(timer);
    timer = null;
    sharedSet(DRAIN_STARTED_AT, undefined);
}
function announceDeliveryConfiguration() {
    let fcm = null;
    try {
        fcm = getConfig().fcm;
    }
    catch (err) {
        console.error("[notify] configuration unreadable; delivery is off", err);
        return;
    }
    if (!fcm) {
        console.warn("[notify] NO FCM CREDENTIALS: sends are logged, not delivered. Set NOTIFY_FCM_PROJECT_ID and NOTIFY_FCM_SERVICE_ACCOUNT.");
    }
    else {
        console.log(`[notify] FCM delivery enabled for project ${fcm.projectId}`);
    }
    if (!sharedGet(PUSH_DIRECTORY)) {
        console.warn("[notify] NO PUSH DIRECTORY: every event will count no_token. Pass one to startNotifyDrain().");
    }
}
export function wakeDrain() {
    void tick();
}
export async function drainOnce() {
    await tick();
    while (inFlight)
        await inFlight;
}
function tick() {
    if (inFlight) {
        wakeQueued = true;
        return inFlight;
    }
    inFlight = pass().finally(() => {
        inFlight = null;
    });
    return inFlight;
}
async function pass() {
    for (;;) {
        try {
            for (;;) {
                const claimed = await drainBatch();
                if (claimed === 0)
                    break;
            }
            if (Date.now() - lastPrune > PRUNE_EVERY_MS) {
                lastPrune = Date.now();
                await pruneCounters();
                await pruneSendFailures();
                await pruneProducerRequests();
            }
        }
        catch (err) {
            console.error("[notify] drain tick failed", err);
        }
        if (!wakeQueued)
            return;
        wakeQueued = false;
    }
}
async function drainBatch() {
    const tally = new Tally();
    const { claimed, units } = await claimBatch(tally);
    await tally.flush();
    if (units.length === 0)
        return claimed;
    const settlements = await deliverUnits(units, tally);
    await settleUnits(settlements);
    await tally.flush();
    return claimed;
}
async function claimBatch(tally) {
    return withTransaction(async (tx) => {
        const claimed = await tx.query(`SELECT * FROM outbox
        WHERE send_after <= now()
        ORDER BY send_after
        LIMIT $1
        FOR UPDATE SKIP LOCKED`, [CLAIM_LIMIT]);
        const rows = claimed.rows;
        if (rows.length === 0)
            return { claimed: 0, units: [] };
        const toDelete = [];
        const now = new Date();
        const prefsByApp = new Map();
        for (const appId of new Set(rows.map((r) => r.app_id))) {
            const users = [...new Set(rows.filter((r) => r.app_id === appId).map((r) => r.user_id))];
            prefsByApp.set(appId, await loadPrefsMap(tx, users, appId));
        }
        const keep = [];
        for (const row of rows) {
            if (row.expires_at.getTime() <= now.getTime()) {
                toDelete.push(row.id);
                tally.add(row, "expired");
                continue;
            }
            const gate = evaluateGate(prefsByApp.get(row.app_id)?.get(row.user_id), row.category, now);
            if (gate.kind === "muted") {
                toDelete.push(row.id);
                tally.add(row, "muted");
                continue;
            }
            if (gate.kind === "deferred") {
                if (gate.until.getTime() > row.expires_at.getTime()) {
                    toDelete.push(row.id);
                    tally.add(row, "quiet_dropped");
                }
                else {
                    await tx.query(`UPDATE outbox SET send_after = $2 WHERE id = $1`, [row.id, gate.until]);
                }
                continue;
            }
            keep.push(row);
        }
        if (toDelete.length > 0) {
            await tx.query(`DELETE FROM outbox WHERE id = ANY($1::bigint[])`, [toDelete]);
        }
        if (keep.length > 0) {
            await tx.query(`UPDATE outbox SET send_after = now() + make_interval(secs => $2::double precision)
          WHERE id = ANY($1::bigint[])`, [keep.map((r) => r.id), LEASE_S]);
        }
        const units = new Map();
        for (const row of keep) {
            const key = `${row.user_id}|${row.app_id}|${row.collapse_key ?? `row:${row.id}`}`;
            const unit = units.get(key);
            if (unit)
                unit.rows.push(row);
            else
                units.set(key, {
                    appId: row.app_id,
                    category: row.category,
                    userId: row.user_id,
                    rows: [row],
                    collapseKey: row.collapse_key,
                });
        }
        return { claimed: rows.length, units: [...units.values()] };
    });
}
async function deliverUnits(units, tally) {
    const directory = sharedGet(PUSH_DIRECTORY);
    const targetsByApp = new Map();
    for (const appId of directory ? new Set(units.map((u) => u.appId)) : []) {
        const users = [...new Set(units.filter((u) => u.appId === appId).map((u) => u.userId))];
        const targets = await directory.targetsFor(users, appId);
        const byUser = new Map();
        for (const t of targets) {
            const list = byUser.get(t.user_id) ?? [];
            list.push(t);
            byUser.set(t.user_id, list);
        }
        targetsByApp.set(appId, byUser);
    }
    const settlements = [];
    for (let i = 0; i < units.length; i += SEND_CONCURRENCY) {
        const chunk = units.slice(i, i + SEND_CONCURRENCY);
        const done = await Promise.all(chunk.map((unit) => deliverUnit(unit, targetsByApp.get(unit.appId)?.get(unit.userId) ?? [])));
        settlements.push(...done);
    }
    for (const settlement of settlements) {
        if (settlement.kind === "done") {
            for (const row of settlement.unit.rows)
                tally.add(row, settlement.outcome);
        }
    }
    return settlements;
}
async function deliverUnit(unit, targets) {
    const n = unit.rows.length;
    const rep = unit.rows.reduce((a, b) => (BigInt(a.id) > BigInt(b.id) ? a : b));
    const body = n > 1 && rep.coalesced_body ? rep.coalesced_body.split("{n}").join(String(n)) : rep.body;
    if (targets.length === 0) {
        return { kind: "done", unit, outcome: "no_token" };
    }
    const data = {
        ...rep.data,
        category: rep.category,
        title: rep.title,
        body,
        ...(n > 1 ? { count: String(n) } : {}),
    };
    const priority = unit.rows.some((r) => r.priority === "high") ? "high" : "normal";
    let anySent = false;
    let anyRetryable = false;
    let anyHardFailed = false;
    // The last reason any target gave, kept so that whichever branch below settles on "failed"
    // can write down why. Without it the counter says a category has never been delivered and
    // nothing anywhere says what FCM answered.
    let lastReason;
    for (const target of targets) {
        const { result, reason } = await sendPush({
            token: target.push_token,
            data,
            priority,
            collapseKey: unit.collapseKey ?? undefined,
        });
        if (reason)
            lastReason = reason;
        if (result === "sent")
            anySent = true;
        else if (result === "retryable")
            anyRetryable = true;
        else if (result === "failed")
            anyHardFailed = true;
        else {
            await sharedGet(PUSH_DIRECTORY)?.forget(target.device_id).catch((err) => console.error("[notify] push directory forget failed", err));
        }
    }
    if (anySent)
        return { kind: "done", unit, outcome: "sent" };
    if (anyRetryable) {
        const attempts = Math.max(...unit.rows.map((r) => r.attempts)) + 1;
        if (attempts >= MAX_ATTEMPTS) {
            await recordSendFailure(rep.app_id, rep.category, lastReason ?? `gave up after ${attempts} attempts`);
            return { kind: "done", unit, outcome: "failed" };
        }
        const backoffMs = 30_000 * 2 ** attempts;
        return { kind: "retry", unit, attempts, retryAt: new Date(Date.now() + backoffMs) };
    }
    if (anyHardFailed) {
        await recordSendFailure(rep.app_id, rep.category, lastReason ?? "fcm refused the send");
        return { kind: "done", unit, outcome: "failed" };
    }
    return { kind: "done", unit, outcome: "unregistered" };
}
async function settleUnits(settlements) {
    const toDelete = [];
    const retries = [];
    for (const settlement of settlements) {
        if (settlement.kind === "done") {
            for (const row of settlement.unit.rows)
                toDelete.push(row.id);
        }
        else {
            retries.push({
                ids: settlement.unit.rows.map((r) => r.id),
                attempts: settlement.attempts,
                retryAt: settlement.retryAt,
            });
        }
    }
    if (toDelete.length === 0 && retries.length === 0)
        return;
    await withTransaction(async (tx) => {
        if (toDelete.length > 0) {
            await tx.query(`DELETE FROM outbox WHERE id = ANY($1::bigint[])`, [toDelete]);
        }
        for (const retry of retries) {
            await tx.query(`UPDATE outbox SET attempts = $2, send_after = $3 WHERE id = ANY($1::bigint[])`, [retry.ids, retry.attempts, retry.retryAt]);
        }
    });
}
class Tally {
    counts = new Map();
    add(row, outcome) {
        const key = `${row.app_id}|${row.category}|${outcome}`;
        this.counts.set(key, (this.counts.get(key) ?? 0) + 1);
    }
    async flush() {
        for (const [key, n] of this.counts) {
            const [appId, category, outcome] = key.split("|");
            await bumpCounter(appId, category, outcome, n);
        }
        this.counts.clear();
    }
}
