import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { query, withTransaction } from "./db.js";
import { ApiError } from "./errors.js";
export const PRODUCER_KEY_HEADER = "x-notify-key";
export const PRODUCER_TIMESTAMP_HEADER = "x-notify-timestamp";
export const PRODUCER_SIGNATURE_HEADER = "x-notify-signature";
const MAX_SKEW_S = 300;
const REQUEST_RETENTION = "2 hours";
export function newProducerKeyId() {
    return `pk_${randomBytes(9).toString("base64url")}`;
}
export function newProducerSecret() {
    return `zbp_${randomBytes(32).toString("base64url")}`;
}
export function signProducerRequest(secret, keyId, timestampSeconds, rawBody) {
    return createHmac("sha256", secret)
        .update(`${keyId}\n${timestampSeconds}\n${rawBody}`, "utf8")
        .digest("hex");
}
function equalHex(a, b) {
    if (a.length !== b.length || a.length === 0)
        return false;
    const left = Buffer.from(a, "hex");
    const right = Buffer.from(b, "hex");
    if (left.length !== right.length || left.length === 0)
        return false;
    return timingSafeEqual(left, right);
}
export async function authenticateProducer(header, rawBody) {
    if (rawBody === undefined) {
        throw new ApiError(503, "raw_body_unavailable", "This mount does not pass rawBody through; the signature covers the exact bytes sent");
    }
    const keyId = header(PRODUCER_KEY_HEADER);
    const timestamp = header(PRODUCER_TIMESTAMP_HEADER);
    const presented = header(PRODUCER_SIGNATURE_HEADER);
    if (!keyId || !timestamp || !presented) {
        throw new ApiError(401, "invalid_signature", `Sign the request with ${PRODUCER_KEY_HEADER}, ${PRODUCER_TIMESTAMP_HEADER} and ${PRODUCER_SIGNATURE_HEADER}`);
    }
    const seconds = Number(timestamp);
    if (!Number.isInteger(seconds) || Math.abs(Date.now() / 1000 - seconds) > MAX_SKEW_S) {
        throw new ApiError(401, "stale_signature", "Signature timestamp is outside the accepted window");
    }
    const res = await query(`SELECT * FROM notify_producers WHERE key_id = $1`, [keyId]);
    const credential = res.rows[0];
    if (!credential)
        throw new ApiError(401, "unknown_key", "No such producer credential");
    if (credential.disabled_at) {
        throw new ApiError(403, "credential_revoked", "This producer credential has been revoked");
    }
    const signature = presented.startsWith("sha256=") ? presented.slice(7) : presented;
    const expected = signProducerRequest(credential.secret, credential.key_id, seconds, rawBody);
    if (!equalHex(expected, signature)) {
        throw new ApiError(401, "invalid_signature", "Signature does not match the request body");
    }
    return { credential, signature };
}
export async function claimProducerRequest(credential, signature) {
    return withTransaction(async (tx) => {
        // Serialise this credential against itself for the length of the transaction. The cap below
        // is a count followed by an insert, and without the lock concurrent requests all read the
        // same count and all insert -- measured at six times the cap with twelve at once. Keyed on
        // the credential, so different producers never wait on each other.
        await tx.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [credential.key_id]);
        const seen = await tx.query(`SELECT 1 FROM notify_producer_requests WHERE key_id = $1 AND signature = $2`, [credential.key_id, signature]);
        if (seen.rowCount)
            return "replay";
        const used = await tx.query(`SELECT count(*) AS n FROM notify_producer_requests
        WHERE key_id = $1 AND received_at > now() - interval '1 hour'`, [credential.key_id]);
        if (Number(used.rows[0]?.n ?? 0) >= credential.hourly_limit)
            return "rate_limited";
        const inserted = await tx.query(`INSERT INTO notify_producer_requests (key_id, signature) VALUES ($1, $2)
       ON CONFLICT (key_id, signature) DO NOTHING
       RETURNING signature`, [credential.key_id, signature]);
        return inserted.rowCount ? "accepted" : "replay";
    });
}
export async function releaseProducerRequest(credential, signature) {
    try {
        await query(`DELETE FROM notify_producer_requests WHERE key_id = $1 AND signature = $2`, [
            credential.key_id,
            signature,
        ]);
    }
    catch (err) {
        console.error("[notify] producer request release failed", err);
    }
}
export async function pruneProducerRequests() {
    try {
        await query(`DELETE FROM notify_producer_requests WHERE received_at < now() - interval '${REQUEST_RETENTION}'`);
    }
    catch (err) {
        console.error("[notify] producer request prune failed", err);
    }
}
export async function listProducers(appId) {
    const res = await query(appId
        ? `SELECT * FROM notify_producers WHERE app_id = $1 ORDER BY created_at`
        : `SELECT * FROM notify_producers ORDER BY app_id, created_at`, appId ? [appId] : []);
    return res.rows;
}
export async function createProducer(input) {
    const categories = [...new Set(input.categories.map((c) => c.trim()).filter(Boolean))];
    if (categories.length === 0)
        throw new Error("A producer credential needs at least one category");
    for (const category of categories) {
        if (!/^[a-z0-9_]+(\.[a-z0-9_]+)+$/.test(category)) {
            throw new Error(`${category} is not a producer-namespaced, dot-separated category`);
        }
    }
    const keyId = newProducerKeyId();
    const secret = newProducerSecret();
    const res = await query(`INSERT INTO notify_producers
       (key_id, app_id, label, secret, categories, max_recipients, hourly_limit)
     VALUES ($1, $2, $3, $4, $5, $6, $7)
     RETURNING *`, [
        keyId,
        input.appId,
        input.label,
        secret,
        categories,
        input.maxRecipients,
        input.hourlyLimit,
    ]);
    return { credential: res.rows[0], secret };
}
export async function revokeProducer(keyId) {
    const res = await query(`UPDATE notify_producers SET disabled_at = now() WHERE key_id = $1 AND disabled_at IS NULL`, [keyId]);
    return Boolean(res.rowCount);
}
