import { r as formatErrorMessage } from "./errors-CGMew-1w.js";
import { i as normalizeLowercaseStringOrEmpty, o as normalizeOptionalLowercaseString, s as normalizeOptionalString } from "./string-coerce-CIT1-v_a.js";
import { _ as resolveStateDir } from "./paths-CAiS8AEq.js";
import { n as readJsonFile, r as writeJsonAtomic, t as createAsyncLock } from "./json-files-kvH2Eboc.js";
import { a as signDevicePayload, n as loadOrCreateDeviceIdentity } from "./device-identity-C3vDRjBL.js";
import { W as sniffMimeFromBase64 } from "./compact-DBNgkjcR.js";
import { n as estimateBase64DecodedBytes } from "./base64-IvsIgrLu.js";
import { t as normalizeHostname } from "./hostname-Mz3PXPjm.js";
import { i as deleteMediaBuffer, l as saveMediaBuffer } from "./store-CCD9jDq_.js";
import { URL } from "node:url";
import path from "node:path";
import fs from "node:fs/promises";
import { createHash, createPrivateKey, sign } from "node:crypto";
import http2 from "node:http2";
//#region src/gateway/chat-attachments.ts
const OFFLOAD_THRESHOLD_BYTES = 2e6;
const MIME_TO_EXT = {
	"image/jpeg": ".jpg",
	"image/jpg": ".jpg",
	"image/png": ".png",
	"image/webp": ".webp",
	"image/gif": ".gif",
	"image/heic": ".heic",
	"image/heif": ".heif",
	"image/bmp": ".bmp",
	"image/tiff": ".tiff"
};
const SUPPORTED_OFFLOAD_MIMES = new Set([
	"image/jpeg",
	"image/jpg",
	"image/png",
	"image/webp",
	"image/gif",
	"image/heic",
	"image/heif"
]);
/**
* Raised when the Gateway cannot persist an attachment to the media store.
*
* Distinct from ordinary input-validation errors so that Gateway handlers can
* map it to a server-side 5xx status rather than a client 4xx.
*
* Example causes: ENOSPC, EPERM, unexpected saveMediaBuffer return shape.
*/
var MediaOffloadError = class extends Error {
	constructor(message, options) {
		super(message, options);
		this.name = "MediaOffloadError";
		this.cause = options?.cause;
	}
};
function normalizeMime(mime) {
	if (!mime) return;
	return normalizeOptionalLowercaseString(mime.split(";")[0]) || void 0;
}
function isImageMime(mime) {
	return typeof mime === "string" && mime.startsWith("image/");
}
function isValidBase64(value) {
	if (value.length === 0 || value.length % 4 !== 0) return false;
	return /^[A-Za-z0-9+/]+={0,2}$/.test(value);
}
/**
* Confirms that the decoded buffer produced by Buffer.from(b64, 'base64')
* matches the pre-decode size estimate.
*
* Node's Buffer.from silently drops invalid base64 characters rather than
* throwing. A material size discrepancy means the source string contained
* embedded garbage that was silently stripped, which would produce a corrupted
* file on disk. ±3 bytes of slack accounts for base64 padding rounding.
*
* IMPORTANT: this is an input-validation check (4xx client error).
* It MUST be called OUTSIDE the MediaOffloadError try/catch so that
* corrupt-input errors are not misclassified as 5xx server errors.
*/
function verifyDecodedSize(buffer, estimatedBytes, label) {
	if (Math.abs(buffer.byteLength - estimatedBytes) > 3) throw new Error(`attachment ${label}: base64 contains invalid characters (expected ~${estimatedBytes} bytes decoded, got ${buffer.byteLength})`);
}
function ensureExtension(label, mime) {
	if (/\.[a-zA-Z0-9]+$/.test(label)) return label;
	const ext = MIME_TO_EXT[normalizeLowercaseStringOrEmpty(mime)] ?? "";
	return ext ? `${label}${ext}` : label;
}
/**
* Type guard for the return value of saveMediaBuffer.
*
* Also validates that the returned ID:
* - is a non-empty string
* - contains no path separators (/ or \) or null bytes
*
* Catching a bad shape here produces a cleaner error than a cryptic failure
* deeper in the stack, and is treated as a 5xx infrastructure error.
*/
function assertSavedMedia(value, label) {
	if (value !== null && typeof value === "object" && "id" in value && typeof value.id === "string") {
		const id = value.id;
		if (id.length === 0) throw new Error(`attachment ${label}: saveMediaBuffer returned an empty media ID`);
		if (id.includes("/") || id.includes("\\") || id.includes("\0")) throw new Error(`attachment ${label}: saveMediaBuffer returned an unsafe media ID (contains path separator or null byte)`);
		return value;
	}
	throw new Error(`attachment ${label}: saveMediaBuffer returned an unexpected shape`);
}
function normalizeAttachment(att, idx, opts) {
	const mime = att.mimeType ?? "";
	const content = att.content;
	const label = att.fileName || att.type || `attachment-${idx + 1}`;
	if (typeof content !== "string") throw new Error(`attachment ${label}: content must be base64 string`);
	if (opts.requireImageMime && !mime.startsWith("image/")) throw new Error(`attachment ${label}: only image/* supported`);
	let base64 = content.trim();
	if (opts.stripDataUrlPrefix) {
		const dataUrlMatch = /^data:[^;]+;base64,(.*)$/.exec(base64);
		if (dataUrlMatch) base64 = dataUrlMatch[1];
	}
	return {
		label,
		mime,
		base64
	};
}
/**
* Parse attachments and extract images as structured content blocks.
* Returns the message text, inline image blocks, and offloaded media refs.
*
* ## Offload behaviour
* Attachments whose decoded size exceeds OFFLOAD_THRESHOLD_BYTES are saved to
* disk via saveMediaBuffer and replaced with an opaque `media://inbound/<id>`
* URI appended to the message. The agent resolves these URIs via
* resolveMediaBufferPath before passing them to the model.
*
* ## Transcript metadata
* Callers MUST use `result.offloadedRefs` to persist structured media metadata
* for transcripts. These refs are intentionally excluded from `result.images`
* because they are not passed inline to the model.
*
* ## Text-only model runs
* Pass `supportsImages: false` for text-only model runs so that no media://
* markers are injected into prompt text.
*
* ⚠️  Call sites in chat.ts, agent.ts, and server-node-events.ts MUST be
* updated to pass `supportsImages: modelSupportsImages(model)`. Until they do,
* text-only model runs receive unresolvable media:// markers in their prompt.
*
* ## Cleanup on failure
* On any parse failure after files have already been offloaded, best-effort
* cleanup is performed before rethrowing so that malformed requests do not
* accumulate orphaned files on disk ahead of the periodic TTL sweep.
*
* ## Known ordering limitation
* In mixed large/small batches, the model receives images in a different order
* than the original attachment list because detectAndLoadPromptImages
* initialises from existingImages first, then appends prompt-detected refs.
* A future refactor should unify all image references into a single ordered list.
*
* @throws {MediaOffloadError} Infrastructure failure saving to media store → 5xx.
* @throws {Error} Input validation failure → 4xx.
*/
async function parseMessageWithAttachments(message, attachments, opts) {
	const maxBytes = opts?.maxBytes ?? 5e6;
	const log = opts?.log;
	if (!attachments || attachments.length === 0) return {
		message,
		images: [],
		imageOrder: [],
		offloadedRefs: []
	};
	if (opts?.supportsImages === false) {
		if (attachments.length > 0) log?.warn(`parseMessageWithAttachments: ${attachments.length} attachment(s) dropped — model does not support images`);
		return {
			message,
			images: [],
			imageOrder: [],
			offloadedRefs: []
		};
	}
	const images = [];
	const imageOrder = [];
	const offloadedRefs = [];
	let updatedMessage = message;
	const savedMediaIds = [];
	try {
		for (const [idx, att] of attachments.entries()) {
			if (!att) continue;
			const { base64: b64, label, mime } = normalizeAttachment(att, idx, {
				stripDataUrlPrefix: true,
				requireImageMime: false
			});
			if (!isValidBase64(b64)) throw new Error(`attachment ${label}: invalid base64 content`);
			const sizeBytes = estimateBase64DecodedBytes(b64);
			if (sizeBytes <= 0) {
				log?.warn(`attachment ${label}: estimated size is zero, dropping`);
				continue;
			}
			if (sizeBytes > maxBytes) throw new Error(`attachment ${label}: exceeds size limit (${sizeBytes} > ${maxBytes} bytes)`);
			const providedMime = normalizeMime(mime);
			const sniffedMime = normalizeMime(await sniffMimeFromBase64(b64));
			if (sniffedMime && !isImageMime(sniffedMime)) {
				log?.warn(`attachment ${label}: detected non-image (${sniffedMime}), dropping`);
				continue;
			}
			if (!sniffedMime && !isImageMime(providedMime)) {
				log?.warn(`attachment ${label}: unable to detect image mime type, dropping`);
				continue;
			}
			if (sniffedMime && providedMime && sniffedMime !== providedMime) log?.warn(`attachment ${label}: mime mismatch (${providedMime} -> ${sniffedMime}), using sniffed`);
			const finalMime = sniffedMime ?? providedMime ?? normalizeMime(mime) ?? mime;
			let isOffloaded = false;
			if (sizeBytes > OFFLOAD_THRESHOLD_BYTES) {
				if (!SUPPORTED_OFFLOAD_MIMES.has(finalMime)) throw new Error(`attachment ${label}: format ${finalMime} is too large to pass inline (${sizeBytes} > ${OFFLOAD_THRESHOLD_BYTES} bytes) and cannot be offloaded. Please convert to JPEG, PNG, WEBP, GIF, HEIC, or HEIF.`);
				const buffer = Buffer.from(b64, "base64");
				verifyDecodedSize(buffer, sizeBytes, label);
				try {
					const savedMedia = assertSavedMedia(await saveMediaBuffer(buffer, finalMime, "inbound", maxBytes, ensureExtension(label, finalMime)), label);
					savedMediaIds.push(savedMedia.id);
					const mediaRef = `media://inbound/${savedMedia.id}`;
					updatedMessage += `\n[media attached: ${mediaRef}]`;
					log?.info?.(`[Gateway] Intercepted large image payload. Saved: ${mediaRef}`);
					offloadedRefs.push({
						mediaRef,
						id: savedMedia.id,
						path: savedMedia.path ?? "",
						mimeType: finalMime,
						label
					});
					imageOrder.push("offloaded");
					isOffloaded = true;
				} catch (err) {
					throw new MediaOffloadError(`[Gateway Error] Failed to save intercepted media to disk: ${formatErrorMessage(err)}`, { cause: err });
				}
			}
			if (isOffloaded) continue;
			images.push({
				type: "image",
				data: b64,
				mimeType: finalMime
			});
			imageOrder.push("inline");
		}
	} catch (err) {
		if (savedMediaIds.length > 0) await Promise.allSettled(savedMediaIds.map((id) => deleteMediaBuffer(id, "inbound")));
		throw err;
	}
	return {
		message: updatedMessage !== message ? updatedMessage.trimEnd() : message,
		images,
		imageOrder,
		offloadedRefs
	};
}
//#endregion
//#region src/gateway/server-methods/attachment-normalize.ts
function normalizeAttachmentContent(content) {
	if (typeof content === "string") return content;
	if (ArrayBuffer.isView(content)) return Buffer.from(content.buffer, content.byteOffset, content.byteLength).toString("base64");
	if (content instanceof ArrayBuffer) return Buffer.from(content).toString("base64");
}
function normalizeRpcAttachmentsToChatAttachments(attachments) {
	return attachments?.map((a) => {
		const sourceRecord = a?.source && typeof a.source === "object" ? a.source : void 0;
		const sourceType = typeof sourceRecord?.type === "string" ? sourceRecord.type : void 0;
		const sourceMimeType = typeof sourceRecord?.media_type === "string" ? sourceRecord.media_type : void 0;
		const sourceContent = sourceType === "base64" ? normalizeAttachmentContent(sourceRecord?.data) : void 0;
		return {
			type: typeof a?.type === "string" ? a.type : void 0,
			mimeType: typeof a?.mimeType === "string" ? a.mimeType : sourceMimeType,
			fileName: typeof a?.fileName === "string" ? a.fileName : void 0,
			content: normalizeAttachmentContent(a?.content) ?? sourceContent
		};
	}).filter((a) => a.content) ?? [];
}
//#endregion
//#region src/infra/push-apns.relay.ts
const DEFAULT_APNS_RELAY_TIMEOUT_MS = 1e4;
const GATEWAY_DEVICE_ID_HEADER = "x-kaijibot-gateway-device-id";
const GATEWAY_SIGNATURE_HEADER = "x-kaijibot-gateway-signature";
const GATEWAY_SIGNED_AT_HEADER = "x-kaijibot-gateway-signed-at-ms";
function normalizeNonEmptyString$1(value) {
	const trimmed = normalizeOptionalString(value) ?? "";
	return trimmed.length > 0 ? trimmed : null;
}
function normalizeTimeoutMs(value) {
	const raw = typeof value === "number" ? value : typeof value === "string" ? normalizeOptionalString(value) : void 0;
	if (raw === void 0 || raw === "") return DEFAULT_APNS_RELAY_TIMEOUT_MS;
	const parsed = Number(raw);
	if (!Number.isFinite(parsed)) return DEFAULT_APNS_RELAY_TIMEOUT_MS;
	return Math.max(1e3, Math.trunc(parsed));
}
function readAllowHttp(value) {
	const normalized = normalizeOptionalString(value) ? normalizeLowercaseStringOrEmpty(value) : void 0;
	return normalized === "1" || normalized === "true" || normalized === "yes";
}
function isLoopbackRelayHostname(hostname) {
	const normalized = normalizeHostname(hostname);
	return normalized === "localhost" || normalized === "::1" || normalized === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(normalized);
}
function parseReason$1(value) {
	return typeof value === "string" ? normalizeOptionalString(value) : void 0;
}
function buildRelayGatewaySignaturePayload(params) {
	return [
		"kaijibot-relay-send-v1",
		params.gatewayDeviceId.trim(),
		String(Math.trunc(params.signedAtMs)),
		params.bodyJson
	].join("\n");
}
function resolveApnsRelayConfigFromEnv(env = process.env, gatewayConfig) {
	const configuredRelay = gatewayConfig?.push?.apns?.relay;
	const envBaseUrl = normalizeNonEmptyString$1(env.KAIJIBOT_APNS_RELAY_BASE_URL);
	const configBaseUrl = normalizeNonEmptyString$1(configuredRelay?.baseUrl);
	const baseUrl = envBaseUrl ?? configBaseUrl;
	const baseUrlSource = envBaseUrl ? "KAIJIBOT_APNS_RELAY_BASE_URL" : "gateway.push.apns.relay.baseUrl";
	if (!baseUrl) return {
		ok: false,
		error: "APNs relay config missing: set gateway.push.apns.relay.baseUrl or KAIJIBOT_APNS_RELAY_BASE_URL"
	};
	try {
		const parsed = new URL(baseUrl);
		if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new Error("unsupported protocol");
		if (!parsed.hostname) throw new Error("host required");
		if (parsed.protocol === "http:" && !readAllowHttp(env.KAIJIBOT_APNS_RELAY_ALLOW_HTTP)) throw new Error("http relay URLs require KAIJIBOT_APNS_RELAY_ALLOW_HTTP=true (development only)");
		if (parsed.protocol === "http:" && !isLoopbackRelayHostname(parsed.hostname)) throw new Error("http relay URLs are limited to loopback hosts");
		if (parsed.username || parsed.password) throw new Error("userinfo is not allowed");
		if (parsed.search || parsed.hash) throw new Error("query and fragment are not allowed");
		return {
			ok: true,
			value: {
				baseUrl: parsed.toString().replace(/\/+$/, ""),
				timeoutMs: normalizeTimeoutMs(env.KAIJIBOT_APNS_RELAY_TIMEOUT_MS ?? configuredRelay?.timeoutMs)
			}
		};
	} catch (err) {
		return {
			ok: false,
			error: `invalid ${baseUrlSource} (${baseUrl}): ${formatErrorMessage(err)}`
		};
	}
}
async function sendApnsRelayRequest(params) {
	const response = await fetch(`${params.relayConfig.baseUrl}/v1/push/send`, {
		method: "POST",
		redirect: "manual",
		headers: {
			authorization: `Bearer ${params.sendGrant}`,
			"content-type": "application/json",
			[GATEWAY_DEVICE_ID_HEADER]: params.gatewayDeviceId,
			[GATEWAY_SIGNATURE_HEADER]: params.signature,
			[GATEWAY_SIGNED_AT_HEADER]: String(params.signedAtMs)
		},
		body: params.bodyJson,
		signal: AbortSignal.timeout(params.relayConfig.timeoutMs)
	});
	if (response.status >= 300 && response.status < 400) return {
		ok: false,
		status: response.status,
		reason: "RelayRedirectNotAllowed",
		environment: "production"
	};
	let json = null;
	try {
		json = await response.json();
	} catch {
		json = null;
	}
	const body = json && typeof json === "object" && !Array.isArray(json) ? json : {};
	const status = typeof body.status === "number" && Number.isFinite(body.status) ? Math.trunc(body.status) : response.status;
	return {
		ok: typeof body.ok === "boolean" ? body.ok : response.ok && status >= 200 && status < 300,
		status,
		apnsId: parseReason$1(body.apnsId),
		reason: parseReason$1(body.reason),
		environment: "production",
		tokenSuffix: parseReason$1(body.tokenSuffix)
	};
}
async function sendApnsRelayPush(params) {
	const sender = params.requestSender ?? sendApnsRelayRequest;
	const gatewayIdentity = params.gatewayIdentity ?? loadOrCreateDeviceIdentity();
	const signedAtMs = Date.now();
	const bodyJson = JSON.stringify({
		relayHandle: params.relayHandle,
		pushType: params.pushType,
		priority: Number(params.priority),
		payload: params.payload
	});
	const signature = signDevicePayload(gatewayIdentity.privateKeyPem, buildRelayGatewaySignaturePayload({
		gatewayDeviceId: gatewayIdentity.deviceId,
		signedAtMs,
		bodyJson
	}));
	return await sender({
		relayConfig: params.relayConfig,
		sendGrant: params.sendGrant,
		relayHandle: params.relayHandle,
		gatewayDeviceId: gatewayIdentity.deviceId,
		signature,
		signedAtMs,
		bodyJson,
		pushType: params.pushType,
		priority: params.priority,
		payload: params.payload
	});
}
//#endregion
//#region src/infra/push-apns.ts
const EXEC_APPROVAL_GENERIC_ALERT_BODY = "Open KaijiBot to review this request.";
const EXEC_APPROVAL_NOTIFICATION_CATEGORY = "kaijibot.exec-approval";
const APNS_STATE_FILENAME = "push/apns-registrations.json";
const APNS_JWT_TTL_MS = 3e3 * 1e3;
const DEFAULT_APNS_TIMEOUT_MS = 1e4;
const MAX_NODE_ID_LENGTH = 256;
const MAX_TOPIC_LENGTH = 255;
const MAX_APNS_TOKEN_HEX_LENGTH = 512;
const MAX_RELAY_IDENTIFIER_LENGTH = 256;
const MAX_SEND_GRANT_LENGTH = 1024;
const withLock = createAsyncLock();
let cachedJwt = null;
function resolveApnsRegistrationPath(baseDir) {
	const root = baseDir ?? resolveStateDir();
	return path.join(root, APNS_STATE_FILENAME);
}
function normalizeNodeId(value) {
	return value.trim();
}
function isValidNodeId(value) {
	return value.length > 0 && value.length <= MAX_NODE_ID_LENGTH;
}
function normalizeApnsToken(value) {
	return normalizeLowercaseStringOrEmpty(value.trim().replace(/[<>\s]/g, ""));
}
function normalizeRelayHandle(value) {
	return value.trim();
}
function normalizeInstallationId(value) {
	return value.trim();
}
function validateRelayIdentifier(value, fieldName, maxLength = MAX_RELAY_IDENTIFIER_LENGTH) {
	if (!value) throw new Error(`${fieldName} required`);
	if (value.length > maxLength) throw new Error(`${fieldName} too long`);
	if (/[^\x21-\x7e]/.test(value)) throw new Error(`${fieldName} invalid`);
	return value;
}
function normalizeTopic(value) {
	return value.trim();
}
function isValidTopic(value) {
	return value.length > 0 && value.length <= MAX_TOPIC_LENGTH;
}
function normalizeTokenDebugSuffix(value) {
	if (typeof value !== "string") return;
	const normalized = normalizeLowercaseStringOrEmpty(value.trim()).replace(/[^0-9a-z]/g, "");
	return normalized.length > 0 ? normalized.slice(-8) : void 0;
}
function isLikelyApnsToken(value) {
	return value.length <= MAX_APNS_TOKEN_HEX_LENGTH && /^[0-9a-f]{32,}$/i.test(value);
}
function parseReason(body) {
	const trimmed = body.trim();
	if (!trimmed) return;
	try {
		const parsed = JSON.parse(trimmed);
		return typeof parsed.reason === "string" && parsed.reason.trim().length > 0 ? parsed.reason.trim() : trimmed.slice(0, 200);
	} catch {
		return trimmed.slice(0, 200);
	}
}
function toBase64UrlBytes(value) {
	return Buffer.from(value).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function toBase64UrlJson(value) {
	return toBase64UrlBytes(Buffer.from(JSON.stringify(value)));
}
function getJwtCacheKey(auth) {
	const keyHash = createHash("sha256").update(auth.privateKey).digest("hex");
	return `${auth.teamId}:${auth.keyId}:${keyHash}`;
}
function getApnsBearerToken(auth, nowMs = Date.now()) {
	const cacheKey = getJwtCacheKey(auth);
	if (cachedJwt && cachedJwt.cacheKey === cacheKey && nowMs < cachedJwt.expiresAtMs) return cachedJwt.token;
	const iat = Math.floor(nowMs / 1e3);
	const signingInput = `${toBase64UrlJson({
		alg: "ES256",
		kid: auth.keyId,
		typ: "JWT"
	})}.${toBase64UrlJson({
		iss: auth.teamId,
		iat
	})}`;
	const token = `${signingInput}.${toBase64UrlBytes(sign("sha256", Buffer.from(signingInput, "utf8"), {
		key: createPrivateKey(auth.privateKey),
		dsaEncoding: "ieee-p1363"
	}))}`;
	cachedJwt = {
		cacheKey,
		token,
		expiresAtMs: nowMs + APNS_JWT_TTL_MS
	};
	return token;
}
function normalizePrivateKey(value) {
	return value.trim().replace(/\\n/g, "\n");
}
function normalizeNonEmptyString(value) {
	const trimmed = normalizeOptionalString(value) ?? "";
	return trimmed.length > 0 ? trimmed : null;
}
function normalizeDistribution(value) {
	if (typeof value !== "string") return null;
	return (normalizeOptionalString(value) ? normalizeLowercaseStringOrEmpty(value) : void 0) === "official" ? "official" : null;
}
function normalizeDirectRegistration(record) {
	if (typeof record.nodeId !== "string" || typeof record.token !== "string") return null;
	const nodeId = normalizeNodeId(record.nodeId);
	const token = normalizeApnsToken(record.token);
	const topic = normalizeTopic(typeof record.topic === "string" ? record.topic : "");
	const environment = normalizeApnsEnvironment(record.environment) ?? "sandbox";
	const updatedAtMs = typeof record.updatedAtMs === "number" && Number.isFinite(record.updatedAtMs) ? Math.trunc(record.updatedAtMs) : 0;
	if (!isValidNodeId(nodeId) || !isValidTopic(topic) || !isLikelyApnsToken(token)) return null;
	return {
		nodeId,
		transport: "direct",
		token,
		topic,
		environment,
		updatedAtMs
	};
}
function normalizeRelayRegistration(record) {
	if (typeof record.nodeId !== "string" || typeof record.relayHandle !== "string" || typeof record.sendGrant !== "string" || typeof record.installationId !== "string") return null;
	const nodeId = normalizeNodeId(record.nodeId);
	const relayHandle = normalizeRelayHandle(record.relayHandle);
	const sendGrant = record.sendGrant.trim();
	const installationId = normalizeInstallationId(record.installationId);
	const topic = normalizeTopic(typeof record.topic === "string" ? record.topic : "");
	const environment = normalizeApnsEnvironment(record.environment);
	const distribution = normalizeDistribution(record.distribution);
	const updatedAtMs = typeof record.updatedAtMs === "number" && Number.isFinite(record.updatedAtMs) ? Math.trunc(record.updatedAtMs) : 0;
	if (!isValidNodeId(nodeId) || !relayHandle || !sendGrant || !installationId || !isValidTopic(topic) || environment !== "production" || distribution !== "official") return null;
	return {
		nodeId,
		transport: "relay",
		relayHandle,
		sendGrant,
		installationId,
		topic,
		environment,
		distribution,
		updatedAtMs,
		tokenDebugSuffix: normalizeTokenDebugSuffix(record.tokenDebugSuffix)
	};
}
function normalizeStoredRegistration(record) {
	if (!record || typeof record !== "object" || Array.isArray(record)) return null;
	const candidate = record;
	if ((normalizeLowercaseStringOrEmpty(candidate.transport) || "direct") === "relay") return normalizeRelayRegistration(candidate);
	return normalizeDirectRegistration(candidate);
}
async function loadRegistrationsState(baseDir) {
	const existing = await readJsonFile(resolveApnsRegistrationPath(baseDir));
	if (!existing || typeof existing !== "object") return { registrationsByNodeId: {} };
	const registrations = existing.registrationsByNodeId && typeof existing.registrationsByNodeId === "object" && !Array.isArray(existing.registrationsByNodeId) ? existing.registrationsByNodeId : {};
	const normalized = {};
	for (const [nodeId, record] of Object.entries(registrations)) {
		const registration = normalizeStoredRegistration(record);
		if (registration) {
			const normalizedNodeId = normalizeNodeId(nodeId);
			normalized[isValidNodeId(normalizedNodeId) ? normalizedNodeId : registration.nodeId] = registration;
		}
	}
	return { registrationsByNodeId: normalized };
}
async function persistRegistrationsState(state, baseDir) {
	await writeJsonAtomic(resolveApnsRegistrationPath(baseDir), state, {
		mode: 384,
		ensureDirMode: 448,
		trailingNewline: true
	});
}
function normalizeApnsEnvironment(value) {
	if (typeof value !== "string") return null;
	const normalized = normalizeLowercaseStringOrEmpty(value);
	if (normalized === "sandbox" || normalized === "production") return normalized;
	return null;
}
async function registerApnsRegistration(params) {
	const nodeId = normalizeNodeId(params.nodeId);
	const topic = normalizeTopic(params.topic);
	if (!isValidNodeId(nodeId)) throw new Error("nodeId required");
	if (!isValidTopic(topic)) throw new Error("topic required");
	return await withLock(async () => {
		const state = await loadRegistrationsState(params.baseDir);
		const updatedAtMs = Date.now();
		let next;
		if (params.transport === "relay") {
			const relayHandle = validateRelayIdentifier(normalizeRelayHandle(params.relayHandle), "relayHandle");
			const sendGrant = validateRelayIdentifier(params.sendGrant.trim(), "sendGrant", MAX_SEND_GRANT_LENGTH);
			const installationId = validateRelayIdentifier(normalizeInstallationId(params.installationId), "installationId");
			const environment = normalizeApnsEnvironment(params.environment);
			const distribution = normalizeDistribution(params.distribution);
			if (environment !== "production") throw new Error("relay registrations must use production environment");
			if (distribution !== "official") throw new Error("relay registrations must use official distribution");
			next = {
				nodeId,
				transport: "relay",
				relayHandle,
				sendGrant,
				installationId,
				topic,
				environment,
				distribution,
				updatedAtMs,
				tokenDebugSuffix: normalizeTokenDebugSuffix(params.tokenDebugSuffix)
			};
		} else {
			const token = normalizeApnsToken(params.token);
			const environment = normalizeApnsEnvironment(params.environment) ?? "sandbox";
			if (!isLikelyApnsToken(token)) throw new Error("invalid APNs token");
			next = {
				nodeId,
				transport: "direct",
				token,
				topic,
				environment,
				updatedAtMs
			};
		}
		state.registrationsByNodeId[nodeId] = next;
		await persistRegistrationsState(state, params.baseDir);
		return next;
	});
}
async function loadApnsRegistration(nodeId, baseDir) {
	const normalizedNodeId = normalizeNodeId(nodeId);
	if (!normalizedNodeId) return null;
	return (await loadRegistrationsState(baseDir)).registrationsByNodeId[normalizedNodeId] ?? null;
}
function isSameApnsRegistration(a, b) {
	if (a.nodeId !== b.nodeId || a.transport !== b.transport || a.topic !== b.topic || a.environment !== b.environment || a.updatedAtMs !== b.updatedAtMs) return false;
	if (a.transport === "direct" && b.transport === "direct") return a.token === b.token;
	if (a.transport === "relay" && b.transport === "relay") return a.relayHandle === b.relayHandle && a.sendGrant === b.sendGrant && a.installationId === b.installationId && a.distribution === b.distribution && a.tokenDebugSuffix === b.tokenDebugSuffix;
	return false;
}
async function clearApnsRegistrationIfCurrent(params) {
	const normalizedNodeId = normalizeNodeId(params.nodeId);
	if (!normalizedNodeId) return false;
	return await withLock(async () => {
		const state = await loadRegistrationsState(params.baseDir);
		const current = state.registrationsByNodeId[normalizedNodeId];
		if (!current || !isSameApnsRegistration(current, params.registration)) return false;
		delete state.registrationsByNodeId[normalizedNodeId];
		await persistRegistrationsState(state, params.baseDir);
		return true;
	});
}
function shouldInvalidateApnsRegistration(result) {
	if (result.status === 410) return true;
	return result.status === 400 && result.reason?.trim() === "BadDeviceToken";
}
function shouldClearStoredApnsRegistration(params) {
	if (params.registration.transport !== "direct") return false;
	if (params.overrideEnvironment && params.overrideEnvironment !== params.registration.environment) return false;
	return shouldInvalidateApnsRegistration(params.result);
}
async function resolveApnsAuthConfigFromEnv(env = process.env) {
	const teamId = normalizeNonEmptyString(env.KAIJIBOT_APNS_TEAM_ID);
	const keyId = normalizeNonEmptyString(env.KAIJIBOT_APNS_KEY_ID);
	if (!teamId || !keyId) return {
		ok: false,
		error: "APNs auth missing: set KAIJIBOT_APNS_TEAM_ID and KAIJIBOT_APNS_KEY_ID"
	};
	const inlineKeyRaw = normalizeNonEmptyString(env.KAIJIBOT_APNS_PRIVATE_KEY_P8) ?? normalizeNonEmptyString(env.KAIJIBOT_APNS_PRIVATE_KEY);
	if (inlineKeyRaw) return {
		ok: true,
		value: {
			teamId,
			keyId,
			privateKey: normalizePrivateKey(inlineKeyRaw)
		}
	};
	const keyPath = normalizeNonEmptyString(env.KAIJIBOT_APNS_PRIVATE_KEY_PATH);
	if (!keyPath) return {
		ok: false,
		error: "APNs private key missing: set KAIJIBOT_APNS_PRIVATE_KEY_P8 or KAIJIBOT_APNS_PRIVATE_KEY_PATH"
	};
	try {
		return {
			ok: true,
			value: {
				teamId,
				keyId,
				privateKey: normalizePrivateKey(await fs.readFile(keyPath, "utf8"))
			}
		};
	} catch (err) {
		return {
			ok: false,
			error: `failed reading KAIJIBOT_APNS_PRIVATE_KEY_PATH (${keyPath}): ${formatErrorMessage(err)}`
		};
	}
}
async function sendApnsRequest(params) {
	const authority = params.environment === "production" ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com";
	const body = JSON.stringify(params.payload);
	const requestPath = `/3/device/${params.token}`;
	return await new Promise((resolve, reject) => {
		const client = http2.connect(authority);
		let settled = false;
		const fail = (err) => {
			if (settled) return;
			settled = true;
			client.destroy();
			reject(err);
		};
		const finish = (result) => {
			if (settled) return;
			settled = true;
			client.close();
			resolve(result);
		};
		client.once("error", (err) => fail(err));
		const req = client.request({
			":method": "POST",
			":path": requestPath,
			authorization: `bearer ${params.bearerToken}`,
			"apns-topic": params.topic,
			"apns-push-type": params.pushType,
			"apns-priority": params.priority,
			"apns-expiration": "0",
			"content-type": "application/json",
			"content-length": Buffer.byteLength(body).toString()
		});
		let statusCode = 0;
		let apnsId;
		let responseBody = "";
		req.setEncoding("utf8");
		req.setTimeout(params.timeoutMs, () => {
			req.close(http2.constants.NGHTTP2_CANCEL);
			fail(/* @__PURE__ */ new Error(`APNs request timed out after ${params.timeoutMs}ms`));
		});
		req.on("response", (headers) => {
			const statusHeader = headers[":status"];
			statusCode = typeof statusHeader === "number" ? statusHeader : Number(statusHeader ?? 0);
			const idHeader = headers["apns-id"];
			if (typeof idHeader === "string" && idHeader.trim().length > 0) apnsId = idHeader.trim();
		});
		req.on("data", (chunk) => {
			if (typeof chunk === "string") responseBody += chunk;
		});
		req.on("end", () => {
			finish({
				status: statusCode,
				apnsId,
				body: responseBody
			});
		});
		req.on("error", (err) => fail(err));
		req.end(body);
	});
}
function resolveApnsTimeoutMs(timeoutMs) {
	return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) ? Math.max(1e3, Math.trunc(timeoutMs)) : DEFAULT_APNS_TIMEOUT_MS;
}
function resolveDirectSendContext(params) {
	const token = normalizeApnsToken(params.registration.token);
	if (!isLikelyApnsToken(token)) throw new Error("invalid APNs token");
	const topic = normalizeTopic(params.registration.topic);
	if (!isValidTopic(topic)) throw new Error("topic required");
	return {
		token,
		topic,
		environment: params.registration.environment,
		bearerToken: getApnsBearerToken(params.auth)
	};
}
function toPushMetadata(params) {
	return {
		kind: params.kind,
		nodeId: params.nodeId,
		ts: Date.now(),
		...params.reason ? { reason: params.reason } : {}
	};
}
function resolveRegistrationDebugSuffix(registration, relayResult) {
	if (registration.transport === "direct") return registration.token.slice(-8);
	return relayResult?.tokenSuffix ?? registration.tokenDebugSuffix ?? registration.relayHandle.slice(-8);
}
function toPushResult(params) {
	const response = "body" in params.response ? {
		ok: params.response.status === 200,
		status: params.response.status,
		apnsId: params.response.apnsId,
		reason: parseReason(params.response.body),
		environment: params.registration.environment,
		tokenSuffix: params.tokenSuffix
	} : params.response;
	return {
		ok: response.ok,
		status: response.status,
		apnsId: response.apnsId,
		reason: response.reason,
		tokenSuffix: params.tokenSuffix ?? resolveRegistrationDebugSuffix(params.registration, "tokenSuffix" in response ? response : void 0),
		topic: params.registration.topic,
		environment: params.registration.transport === "relay" ? "production" : response.environment,
		transport: params.registration.transport
	};
}
async function sendDirectApnsPush(params) {
	const { token, topic, environment, bearerToken } = resolveDirectSendContext({
		auth: params.auth,
		registration: params.registration
	});
	const response = await (params.requestSender ?? sendApnsRequest)({
		token,
		topic,
		environment,
		bearerToken,
		payload: params.payload,
		timeoutMs: resolveApnsTimeoutMs(params.timeoutMs),
		pushType: params.pushType,
		priority: params.priority
	});
	return toPushResult({
		registration: params.registration,
		response,
		tokenSuffix: token.slice(-8)
	});
}
async function sendRelayApnsPush(params) {
	const response = await sendApnsRelayPush({
		relayConfig: params.relayConfig,
		sendGrant: params.registration.sendGrant,
		relayHandle: params.registration.relayHandle,
		payload: params.payload,
		pushType: params.pushType,
		priority: params.priority,
		gatewayIdentity: params.gatewayIdentity,
		requestSender: params.requestSender
	});
	return toPushResult({
		registration: params.registration,
		response
	});
}
function createAlertPayload(params) {
	return {
		aps: {
			alert: {
				title: params.title,
				body: params.body
			},
			sound: "default"
		},
		kaijibot: toPushMetadata({
			kind: "push.test",
			nodeId: params.nodeId
		})
	};
}
function createBackgroundPayload(params) {
	return {
		aps: { "content-available": 1 },
		kaijibot: toPushMetadata({
			kind: "node.wake",
			reason: params.wakeReason ?? "node.invoke",
			nodeId: params.nodeId
		})
	};
}
function resolveExecApprovalAlertBody() {
	return EXEC_APPROVAL_GENERIC_ALERT_BODY;
}
function createExecApprovalAlertPayload(params) {
	return {
		aps: {
			alert: {
				title: "Exec approval required",
				body: resolveExecApprovalAlertBody()
			},
			sound: "default",
			category: EXEC_APPROVAL_NOTIFICATION_CATEGORY,
			"content-available": 1
		},
		kaijibot: {
			kind: "exec.approval.requested",
			approvalId: params.approvalId,
			ts: Date.now()
		}
	};
}
function createExecApprovalResolvedPayload(params) {
	return {
		aps: { "content-available": 1 },
		kaijibot: {
			kind: "exec.approval.resolved",
			approvalId: params.approvalId,
			ts: Date.now()
		}
	};
}
async function sendApnsAlert(params) {
	const payload = createAlertPayload({
		nodeId: params.nodeId,
		title: params.title,
		body: params.body
	});
	if (params.registration.transport === "relay") {
		const relayParams = params;
		return await sendRelayApnsPush({
			relayConfig: relayParams.relayConfig,
			registration: relayParams.registration,
			payload,
			pushType: "alert",
			priority: "10",
			gatewayIdentity: relayParams.relayGatewayIdentity,
			requestSender: relayParams.relayRequestSender
		});
	}
	const directParams = params;
	return await sendDirectApnsPush({
		auth: directParams.auth,
		registration: directParams.registration,
		payload,
		timeoutMs: directParams.timeoutMs,
		requestSender: directParams.requestSender,
		pushType: "alert",
		priority: "10"
	});
}
async function sendApnsBackgroundWake(params) {
	const payload = createBackgroundPayload({
		nodeId: params.nodeId,
		wakeReason: params.wakeReason
	});
	if (params.registration.transport === "relay") {
		const relayParams = params;
		return await sendRelayApnsPush({
			relayConfig: relayParams.relayConfig,
			registration: relayParams.registration,
			payload,
			pushType: "background",
			priority: "5",
			gatewayIdentity: relayParams.relayGatewayIdentity,
			requestSender: relayParams.relayRequestSender
		});
	}
	const directParams = params;
	return await sendDirectApnsPush({
		auth: directParams.auth,
		registration: directParams.registration,
		payload,
		timeoutMs: directParams.timeoutMs,
		requestSender: directParams.requestSender,
		pushType: "background",
		priority: "5"
	});
}
async function sendApnsExecApprovalAlert(params) {
	const payload = createExecApprovalAlertPayload({
		nodeId: params.nodeId,
		approvalId: params.approvalId
	});
	if (params.registration.transport === "relay") {
		const relayParams = params;
		return await sendRelayApnsPush({
			relayConfig: relayParams.relayConfig,
			registration: relayParams.registration,
			payload,
			pushType: "alert",
			priority: "10",
			gatewayIdentity: relayParams.relayGatewayIdentity,
			requestSender: relayParams.relayRequestSender
		});
	}
	const directParams = params;
	return await sendDirectApnsPush({
		auth: directParams.auth,
		registration: directParams.registration,
		payload,
		timeoutMs: directParams.timeoutMs,
		requestSender: directParams.requestSender,
		pushType: "alert",
		priority: "10"
	});
}
async function sendApnsExecApprovalResolvedWake(params) {
	const payload = createExecApprovalResolvedPayload({
		nodeId: params.nodeId,
		approvalId: params.approvalId
	});
	if (params.registration.transport === "relay") {
		const relayParams = params;
		return await sendRelayApnsPush({
			relayConfig: relayParams.relayConfig,
			registration: relayParams.registration,
			payload,
			pushType: "background",
			priority: "5",
			gatewayIdentity: relayParams.relayGatewayIdentity,
			requestSender: relayParams.relayRequestSender
		});
	}
	const directParams = params;
	return await sendDirectApnsPush({
		auth: directParams.auth,
		registration: directParams.registration,
		payload,
		timeoutMs: directParams.timeoutMs,
		requestSender: directParams.requestSender,
		pushType: "background",
		priority: "5"
	});
}
//#endregion
export { resolveApnsAuthConfigFromEnv as a, sendApnsExecApprovalAlert as c, resolveApnsRelayConfigFromEnv as d, normalizeRpcAttachmentsToChatAttachments as f, registerApnsRegistration as i, sendApnsExecApprovalResolvedWake as l, parseMessageWithAttachments as m, loadApnsRegistration as n, sendApnsAlert as o, MediaOffloadError as p, normalizeApnsEnvironment as r, sendApnsBackgroundWake as s, clearApnsRegistrationIfCurrent as t, shouldClearStoredApnsRegistration as u };
