import { c as normalizeOptionalString } from "./string-coerce-DKw2K5wM.js";
import { t as expandHomePrefix } from "./home-dir-DxcD0hdR.js";
import { o as isRecord } from "./record-coerce-Btbek4uV.js";
import { d as resolveConfigDir } from "./utils-FLBGJBzj.js";
import { r as replaceFileAtomic } from "./replace-file-DlzIPzco.js";
import { t as parseJsonWithJson5Fallback } from "./parse-json-compat-c3237LfJ.js";
import { t as coerceFiniteScheduleNumber } from "./schedule-number-B4NZgaXw.js";
import { t as normalizeCronStaggerMs } from "./stagger-CnuD5reO.js";
import fs from "node:fs";
import path from "node:path";
//#region src/cron/schedule-identity.ts
function readString(record, key) {
	return normalizeOptionalString(record[key]);
}
function readNumber(record, key) {
	return coerceFiniteScheduleNumber(record[key]);
}
function readStaggerMs(record) {
	return normalizeCronStaggerMs(record.staggerMs);
}
function schedulePayloadFromRecord(schedule) {
	const rawKind = readString(schedule, "kind")?.toLowerCase();
	const expr = readString(schedule, "expr") ?? readString(schedule, "cron");
	const at = readString(schedule, "at");
	const atMs = readNumber(schedule, "atMs");
	const everyMs = readNumber(schedule, "everyMs");
	const anchorMs = readNumber(schedule, "anchorMs");
	const tz = readString(schedule, "tz");
	const staggerMs = readStaggerMs(schedule);
	const kind = rawKind === "at" || rawKind === "every" || rawKind === "cron" ? rawKind : at || atMs !== void 0 ? "at" : everyMs !== void 0 ? "every" : expr ? "cron" : void 0;
	if (kind === "at") return at ? {
		kind: "at",
		at
	} : atMs !== void 0 ? {
		kind: "at",
		at: String(atMs)
	} : void 0;
	if (kind === "every" && everyMs !== void 0) return {
		kind: "every",
		everyMs,
		anchorMs
	};
	if (kind === "cron" && expr) return {
		kind: "cron",
		expr,
		tz,
		staggerMs
	};
}
function resolveSchedulePayload(job) {
	if (job.schedule && typeof job.schedule === "object" && !Array.isArray(job.schedule)) return schedulePayloadFromRecord(job.schedule);
	return schedulePayloadFromRecord(job);
}
function tryCronScheduleIdentity(job) {
	const schedule = resolveSchedulePayload(job);
	if (!schedule) return;
	return JSON.stringify({
		version: 1,
		enabled: typeof job.enabled === "boolean" ? job.enabled : true,
		schedule
	});
}
function cronSchedulingInputsEqual(previous, next) {
	const previousIdentity = tryCronScheduleIdentity(previous);
	const nextIdentity = tryCronScheduleIdentity(next);
	return previousIdentity !== void 0 && nextIdentity !== void 0 && previousIdentity === nextIdentity;
}
//#endregion
//#region src/cron/store.ts
const serializedStoreCache = /* @__PURE__ */ new Map();
function getSerializedStoreCache(storePath) {
	let entry = serializedStoreCache.get(storePath);
	if (!entry) {
		entry = { needsSplitMigration: false };
		serializedStoreCache.set(storePath, entry);
	}
	return entry;
}
function resolveDefaultCronDir() {
	return path.join(resolveConfigDir(), "cron");
}
function resolveDefaultCronStorePath() {
	return path.join(resolveDefaultCronDir(), "jobs.json");
}
function resolveStatePath(storePath) {
	if (storePath.endsWith(".json")) return storePath.replace(/\.json$/, "-state.json");
	return `${storePath}-state.json`;
}
function resolveCronQuarantinePath(storePath) {
	if (storePath.endsWith(".json")) return storePath.replace(/\.json$/, "-quarantine.json");
	return `${storePath}-quarantine.json`;
}
function parseCronStateFile(raw) {
	try {
		const parsed = parseJsonWithJson5Fallback(raw);
		if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
		const record = parsed;
		if (record.version !== 1 || typeof record.jobs !== "object" || record.jobs === null || Array.isArray(record.jobs)) return null;
		return {
			version: 1,
			jobs: record.jobs
		};
	} catch {
		return null;
	}
}
function normalizeCronStoreFile(parsed) {
	return {
		version: 1,
		jobs: getRawCronJobs(parsed).filter(isRecord)
	};
}
function getRawCronJobs(parsed) {
	return Array.isArray(parsed) ? parsed : isRecord(parsed) && Array.isArray(parsed.jobs) ? parsed.jobs : [];
}
function cloneConfigJobs(jobs) {
	return jobs.map((job) => structuredClone(job));
}
function stripJobRuntimeFields(job) {
	const { state: _state, updatedAtMs: _updatedAtMs, ...rest } = job;
	return {
		...rest,
		state: {}
	};
}
function stripRuntimeOnlyCronFields(store) {
	const jobs = store.jobs.map(stripJobRuntimeFields);
	return {
		version: store.version,
		jobs
	};
}
function extractStateFile(store) {
	const jobs = {};
	for (const job of store.jobs) jobs[job.id] = {
		updatedAtMs: job.updatedAtMs,
		scheduleIdentity: tryCronScheduleIdentity(job),
		state: job.state ?? {}
	};
	return {
		version: 1,
		jobs
	};
}
function resolveCronStorePath(storePath) {
	if (storePath?.trim()) {
		const raw = storePath.trim();
		if (raw.startsWith("~")) return path.resolve(expandHomePrefix(raw));
		return path.resolve(raw);
	}
	return resolveDefaultCronStorePath();
}
async function loadStateFile(statePath) {
	let raw;
	try {
		raw = await fs.promises.readFile(statePath, "utf-8");
	} catch (err) {
		if (err?.code === "ENOENT") return null;
		throw new Error(`Failed to read cron state at ${statePath}: ${String(err)}`, { cause: err });
	}
	return parseCronStateFile(raw);
}
function loadStateFileSync(statePath) {
	let raw;
	try {
		raw = fs.readFileSync(statePath, "utf-8");
	} catch (err) {
		if (err?.code === "ENOENT") return null;
		throw new Error(`Failed to read cron state at ${statePath}: ${String(err)}`, { cause: err });
	}
	return parseCronStateFile(raw);
}
function hasInlineState(jobs) {
	return jobs.some((job) => job != null && isRecord(job.state) && Object.keys(job.state).length > 0);
}
function ensureJobStateObject(job) {
	if (!isRecord(job.state)) job.state = {};
}
function backfillMissingRuntimeFields(job) {
	ensureJobStateObject(job);
	if (typeof job.updatedAtMs !== "number") job.updatedAtMs = typeof job.createdAtMs === "number" ? job.createdAtMs : Date.now();
}
function resolveUpdatedAtMs(job, updatedAtMs) {
	if (typeof updatedAtMs === "number" && Number.isFinite(updatedAtMs)) return updatedAtMs;
	if (typeof job.updatedAtMs === "number" && Number.isFinite(job.updatedAtMs)) return job.updatedAtMs;
	return typeof job.createdAtMs === "number" && Number.isFinite(job.createdAtMs) ? job.createdAtMs : Date.now();
}
function mergeStateFileEntry(job, entry) {
	if (!isRecord(entry)) {
		backfillMissingRuntimeFields(job);
		return;
	}
	job.updatedAtMs = resolveUpdatedAtMs(job, entry.updatedAtMs);
	job.state = isRecord(entry.state) ? entry.state : {};
	if (typeof entry.scheduleIdentity === "string" && entry.scheduleIdentity !== tryCronScheduleIdentity(job)) {
		ensureJobStateObject(job);
		job.state.nextRunAtMs = void 0;
	}
}
function resolveCronStateId(job) {
	return normalizeOptionalString(job.id) ?? normalizeOptionalString(job.jobId);
}
async function loadCronStoreWithConfigJobs(storePath) {
	try {
		const raw = await fs.promises.readFile(storePath, "utf-8");
		let parsed;
		try {
			parsed = parseJsonWithJson5Fallback(raw);
		} catch (err) {
			throw new Error(`Failed to parse cron store at ${storePath}: ${String(err)}`, { cause: err });
		}
		const rawJobs = getRawCronJobs(parsed);
		const configJobIndexes = [];
		const configRows = [];
		const configJobRuntimeEntries = [];
		const invalidConfigRows = [];
		for (const [index, row] of rawJobs.entries()) if (isRecord(row)) {
			configJobIndexes.push(index);
			configRows.push(row);
		} else invalidConfigRows.push({
			sourceIndex: index,
			reason: "non-object-row",
			raw: structuredClone(row)
		});
		const store = {
			version: 1,
			jobs: configRows
		};
		const jobs = store.jobs;
		const configJobs = cloneConfigJobs(configRows);
		const stateFile = await loadStateFile(resolveStatePath(storePath));
		const hasLegacyInlineState = !stateFile && hasInlineState(jobs);
		if (stateFile) for (const job of store.jobs) {
			const stateId = resolveCronStateId(job);
			const entry = stateId ? stateFile.jobs[stateId] : void 0;
			configJobRuntimeEntries.push(isRecord(entry) ? structuredClone(entry) : {});
			if (entry) mergeStateFileEntry(job, entry);
			else backfillMissingRuntimeFields(job);
		}
		else if (!hasLegacyInlineState) for (const job of store.jobs) backfillMissingRuntimeFields(job);
		for (const job of store.jobs) ensureJobStateObject(job);
		const configJson = JSON.stringify(stripRuntimeOnlyCronFields(store), null, 2);
		const stateJson = JSON.stringify(extractStateFile(store), null, 2);
		serializedStoreCache.set(storePath, {
			configJson,
			stateJson,
			needsSplitMigration: hasLegacyInlineState
		});
		return {
			store,
			configJobs,
			configJobIndexes,
			configJobRuntimeEntries,
			invalidConfigRows
		};
	} catch (err) {
		if (err?.code === "ENOENT") {
			serializedStoreCache.delete(storePath);
			return {
				store: {
					version: 1,
					jobs: []
				},
				configJobs: [],
				configJobIndexes: [],
				configJobRuntimeEntries: [],
				invalidConfigRows: []
			};
		}
		throw err;
	}
}
async function loadCronStore(storePath) {
	return (await loadCronStoreWithConfigJobs(storePath)).store;
}
function loadCronStoreSync(storePath) {
	try {
		const raw = fs.readFileSync(storePath, "utf-8");
		let parsed;
		try {
			parsed = parseJsonWithJson5Fallback(raw);
		} catch (err) {
			throw new Error(`Failed to parse cron store at ${storePath}: ${String(err)}`, { cause: err });
		}
		const store = normalizeCronStoreFile(parsed);
		const jobs = store.jobs;
		const stateFile = loadStateFileSync(resolveStatePath(storePath));
		const hasLegacyInlineState = !stateFile && hasInlineState(jobs);
		if (stateFile) for (const job of store.jobs) {
			const stateId = resolveCronStateId(job);
			const entry = stateId ? stateFile.jobs[stateId] : void 0;
			if (entry) mergeStateFileEntry(job, entry);
			else backfillMissingRuntimeFields(job);
		}
		else if (!hasLegacyInlineState) for (const job of store.jobs) backfillMissingRuntimeFields(job);
		for (const job of store.jobs) ensureJobStateObject(job);
		return store;
	} catch (err) {
		if (err?.code === "ENOENT") return {
			version: 1,
			jobs: []
		};
		throw err;
	}
}
async function setSecureFileMode(filePath) {
	await fs.promises.chmod(filePath, 384).catch(() => void 0);
}
async function atomicWrite(filePath, content, dirMode = 448) {
	await replaceFileAtomic({
		filePath,
		content,
		dirMode,
		mode: 384,
		tempPrefix: ".nodmix-cron",
		renameMaxRetries: 3,
		copyFallbackOnPermissionError: true
	});
}
async function serializedFileNeedsWrite(filePath, expectedJson, contentChanged) {
	if (contentChanged) return true;
	try {
		return await fs.promises.readFile(filePath, "utf-8") !== expectedJson;
	} catch (err) {
		if (err?.code === "ENOENT") return true;
		throw err;
	}
}
async function saveCronStore(storePath, store, opts) {
	const stateOnly = opts?.stateOnly === true;
	const configJson = JSON.stringify(stripRuntimeOnlyCronFields(store), null, 2);
	const stateFile = extractStateFile(store);
	const stateJson = JSON.stringify(stateFile, null, 2);
	const statePath = resolveStatePath(storePath);
	const cache = serializedStoreCache.get(storePath);
	const configChanged = !stateOnly && cache?.configJson !== configJson;
	const stateChanged = cache?.stateJson !== stateJson;
	const migrating = cache?.needsSplitMigration === true;
	const configNeedsWrite = stateOnly ? false : await serializedFileNeedsWrite(storePath, configJson, configChanged);
	const stateNeedsWrite = await serializedFileNeedsWrite(statePath, stateJson, stateChanged);
	if (stateOnly ? !stateNeedsWrite && !migrating : !configNeedsWrite && !stateNeedsWrite && !migrating) return;
	const updatedCache = getSerializedStoreCache(storePath);
	if (stateNeedsWrite || migrating) {
		await atomicWrite(statePath, stateJson);
		updatedCache.stateJson = stateJson;
	}
	if (!stateOnly && (configNeedsWrite || migrating)) {
		if (!(opts?.skipBackup === true || !configChanged)) try {
			const backupPath = `${storePath}.bak`;
			await fs.promises.copyFile(storePath, backupPath);
			await setSecureFileMode(backupPath);
		} catch {}
		await atomicWrite(storePath, configJson);
		updatedCache.configJson = configJson;
	}
	updatedCache.needsSplitMigration = stateOnly && migrating;
}
async function loadCronQuarantineFile(path) {
	try {
		const parsed = parseJsonWithJson5Fallback(await fs.promises.readFile(path, "utf-8"));
		if (!isRecord(parsed) || parsed.version !== 1 || !Array.isArray(parsed.jobs)) throw new Error(`Unsupported cron quarantine file shape at ${path}`);
		return {
			version: 1,
			jobs: parsed.jobs.map((entry, index) => {
				if (!isRecord(entry) || typeof entry.reason !== "string" || !isRecord(entry.job) && !("raw" in entry)) throw new Error(`Unsupported cron quarantine entry at ${path} index ${index}`);
				const sourceIndex = typeof entry.sourceIndex === "number" ? entry.sourceIndex : -1;
				const quarantined = {
					quarantinedAtMs: typeof entry.quarantinedAtMs === "number" && Number.isFinite(entry.quarantinedAtMs) ? entry.quarantinedAtMs : Date.now(),
					sourceIndex,
					reason: entry.reason
				};
				if (isRecord(entry.job)) quarantined.job = entry.job;
				if ("raw" in entry) quarantined.raw = entry.raw;
				if (isRecord(entry.state)) quarantined.state = entry.state;
				if (typeof entry.updatedAtMs === "number" && Number.isFinite(entry.updatedAtMs)) quarantined.updatedAtMs = entry.updatedAtMs;
				if (typeof entry.scheduleIdentity === "string") quarantined.scheduleIdentity = entry.scheduleIdentity;
				return quarantined;
			})
		};
	} catch (err) {
		if (err?.code === "ENOENT") return {
			version: 1,
			jobs: []
		};
		throw err;
	}
}
function quarantineEntryKey(entry) {
	const rawId = entry.job ? normalizeOptionalString(entry.job.id) ?? normalizeOptionalString(entry.job.jobId) : null;
	return JSON.stringify({
		id: rawId ?? null,
		sourceIndex: entry.sourceIndex,
		reason: entry.reason,
		job: entry.job ?? null,
		raw: entry.raw ?? null,
		state: entry.state ?? null,
		updatedAtMs: entry.updatedAtMs ?? null,
		scheduleIdentity: entry.scheduleIdentity ?? null
	});
}
async function saveCronQuarantineFile(params) {
	if (params.entries.length === 0) return null;
	const quarantinePath = resolveCronQuarantinePath(params.storePath);
	const existing = await loadCronQuarantineFile(quarantinePath);
	const seen = new Set(existing.jobs.map(quarantineEntryKey));
	const nextJobs = existing.jobs.slice();
	let appended = false;
	for (const entry of params.entries.toSorted((a, b) => a.sourceIndex - b.sourceIndex)) {
		const key = quarantineEntryKey(entry);
		if (seen.has(key)) continue;
		seen.add(key);
		appended = true;
		nextJobs.push({
			quarantinedAtMs: params.nowMs,
			sourceIndex: entry.sourceIndex,
			reason: entry.reason,
			...entry.job ? { job: structuredClone(entry.job) } : {},
			..."raw" in entry ? { raw: structuredClone(entry.raw) } : {},
			...entry.state ? { state: structuredClone(entry.state) } : {},
			...entry.updatedAtMs !== void 0 ? { updatedAtMs: entry.updatedAtMs } : {},
			...entry.scheduleIdentity !== void 0 ? { scheduleIdentity: entry.scheduleIdentity } : {}
		});
	}
	if (!appended) return quarantinePath;
	await atomicWrite(quarantinePath, JSON.stringify({
		version: 1,
		jobs: nextJobs
	}, null, 2));
	return quarantinePath;
}
//#endregion
export { resolveCronQuarantinePath as a, saveCronStore as c, loadCronStoreWithConfigJobs as i, cronSchedulingInputsEqual as l, loadCronStore as n, resolveCronStorePath as o, loadCronStoreSync as r, saveCronQuarantineFile as s, loadCronQuarantineFile as t };
