// SF Extension — Persistent Notification Store
// Captures durable ctx.ui.notify() calls and workflow-logger errors to
// .sf/notifications.jsonl so they survive context resets and session restarts.
// Rotates at MAX_ENTRIES to prevent unbounded growth.
import { randomUUID } from "node:crypto";
import {
	appendFileSync,
	closeSync,
	existsSync,
	mkdirSync,
	openSync,
	readFileSync,
	renameSync,
	unlinkSync,
	writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { sfRuntimeRoot } from "./paths.js";

// ─── Constants ──────────────────────────────────────────────────────────
const MAX_ENTRIES = 500;
const FILENAME = "notifications.jsonl";
const LOCKFILE = "notifications.lock";
const NOTIFICATION_SCHEMA_VERSION = 1;
const DEDUP_WINDOW_MS = 30_000;
const DURABLE_DEDUP_WINDOW_MS = 60 * 60 * 1000;
const DEDUP_PRUNE_THRESHOLD = 200;
const DURABLE_DEDUP_SCAN_LIMIT = 100;
const ACTIONABLE_KINDS = new Set([
	"action_required",
	"approval_request",
	"attention",
	"blocker",
	"error",
	"terminal",
]);
const NOISY_STATUS_PATTERNS = [
	/^MCP client ready\b/,
	/^Startup doctor: applied\b/,
	/^Flow audit: Inspect session\b/,
	/^UOK parity report shows\b/,
	/Permission bypassed - all checks disabled!?$/,
	/^Dynamic routing:/,
	/^Pre-flight:/,
	/^\[unit\]/,
	/^Model \[/,
	/^\[mcp\]/,
	/^Assisted mode started\b/,
	/^Autonomous mode resumed\b/,
	/^Resuming paused session\b/,
	/^Resumed paused session\b/,
];
// ─── Module State ───────────────────────────────────────────────────────
let _basePath = null;
let _lineCount = 0; // Hint for rotation — not authoritative for public API
let _suppressCount = 0;
let _recentMessageTimestamps = new Map();
const _changeListeners = new Set();
/** Count of appendNotification failures since last reset — surfaced by status checks. */
let _appendFailureCount = 0;
/** Last append failure details for diagnostics. */
let _lastAppendFailure = null;
// ─── Public API ─────────────────────────────────────────────────────────
/**
 * Initialize the notification store. Call once at session start with the
 * project root. Seeds in-memory counters from the existing file on disk.
 */
export function initNotificationStore(basePath) {
	if (_basePath !== basePath) {
		_recentMessageTimestamps.clear();
	}
	_basePath = basePath;
	// Seed line count hint for rotation — public counters read from disk
	_lineCount = _readEntriesFromDisk(basePath).length;
}
/**
 * Append a notification entry to the store. Synchronous — safe to call
 * from the notify() shim which is declared void (not async).
 */
export function appendNotification(
	message,
	severity,
	source = "notify",
	metadata,
) {
	if (!_basePath) return;
	if (_suppressCount > 0) return;
	const normalizedSeverity = severity === "warn" ? "warning" : severity;
	const persistedMessage =
		message.length > 500 ? message.slice(0, 500) + "…" : message;
	if (
		!shouldPersistNotification(normalizedSeverity, metadata, persistedMessage)
	)
		return;
	// Use explicit dedupe_key when provided; fall back to message-hash based key.
	const dedupKey = metadata?.dedupe_key
		? `${_basePath}:${metadata.dedupe_key}`
		: `${_basePath}:${normalizedSeverity}:${source}:${persistedMessage}`;
	const now = Date.now();
	const lastSeen = _recentMessageTimestamps.get(dedupKey);
	if (lastSeen !== undefined && now - lastSeen <= DEDUP_WINDOW_MS) return;
	_recentMessageTimestamps.set(dedupKey, now);
	if (_recentMessageTimestamps.size > DEDUP_PRUNE_THRESHOLD) {
		for (const [key, ts] of _recentMessageTimestamps) {
			if (now - ts > DEDUP_WINDOW_MS) _recentMessageTimestamps.delete(key);
		}
	}
	if (
		hasRecentPersistedDuplicate(
			_basePath,
			metadata?.dedupe_key ??
				`${normalizedSeverity}:${source}:${persistedMessage}`,
			now,
		)
	) {
		return;
	}
	const entry = {
		schemaVersion: NOTIFICATION_SCHEMA_VERSION,
		id: randomUUID(),
		ts: new Date().toISOString(),
		severity: normalizedSeverity,
		message: persistedMessage,
		source,
		read: false,
		...(metadata ? { metadata } : {}),
	};
	try {
		const dir = sfRuntimeRoot(_basePath);
		mkdirSync(dir, { recursive: true });
		appendFileSync(join(dir, FILENAME), JSON.stringify(entry) + "\n", "utf-8");
		_lineCount++;
		// Rotate if hint suggests we're over limit
		if (_lineCount > MAX_ENTRIES) {
			_rotate();
		}
		_emitChange();
	} catch (err) {
		// Non-fatal — never let persistence break the caller — but track failure
		// so callers can distinguish persisted notifications from failed ones.
		_appendFailureCount++;
		_lastAppendFailure = {
			ts: new Date().toISOString(),
			correlationId: entry.id,
			error: err?.message ?? String(err),
		};
		_writeStderr(
			`[sf:notification-store] append failed for ${entry.id}: ${err?.message ?? String(err)}\n`,
		);
	}
}
function shouldPersistNotification(severity, metadata, message) {
	if (metadata?.kind === "progress") return false;
	if (metadata?.persist === false) return false;
	if (metadata?.persist === true) return true;
	if (metadata?.blocking === true || metadata?.requires_action === true) {
		return true;
	}
	if (ACTIONABLE_KINDS.has(metadata?.kind)) return true;
	if (severity === "error") return true;
	if (isNoisyStatusMessage(message)) return false;
	return severity === "warning";
}
/**
 * Read all notification entries from disk. Returns newest-first.
 */
export function readNotifications(basePath) {
	const bp = basePath ?? _basePath;
	if (!bp) return [];
	return _readEntriesFromDisk(bp).reverse();
}
/**
 * Mark all notifications as read. Atomic rewrite via temp-file + rename.
 * Resyncs in-memory counters from disk after mutation.
 */
export function markAllRead(basePath) {
	const bp = basePath ?? _basePath;
	if (!bp) return;
	const entries = _readEntriesFromDisk(bp);
	if (entries.length === 0) return;
	const hasUnread = entries.some((e) => !e.read);
	if (!hasUnread) return;
	let changed = false;
	try {
		_withLock(bp, () => {
			// Re-read inside lock to get freshest state
			const fresh = _readEntriesFromDisk(bp);
			if (fresh.length === 0 || !fresh.some((e) => !e.read)) return;
			const lines = fresh.map((e) => JSON.stringify({ ...e, read: true }));
			_atomicWrite(bp, lines.join("\n") + "\n");
			changed = true;
		});
	} catch {
		// Non-fatal
	}
	if (changed) _emitChange();
}
/**
 * Clear all notifications. Atomic write of empty content under lock.
 */
export function clearNotifications(basePath) {
	const bp = basePath ?? _basePath;
	if (!bp) return;
	try {
		_withLock(bp, () => {
			_atomicWrite(bp, "");
		});
		_lineCount = 0;
		_emitChange();
	} catch {
		// Non-fatal
	}
}
/**
 * Get the current unread count. Reads from disk to stay accurate across
 * processes (web subprocess can clear/modify the file independently).
 */
export function getUnreadCount() {
	if (!_basePath) return 0;
	try {
		const entries = _readEntriesFromDisk(_basePath);
		return entries.filter((e) => !e.read).length;
	} catch {
		return 0;
	}
}
/**
 * Get the total notification count. Reads from disk for cross-process accuracy.
 */
export function getLineCount() {
	if (!_basePath) return 0;
	try {
		return _readEntriesFromDisk(_basePath).length;
	} catch {
		return 0;
	}
}
/**
 * Temporarily suppress persistence. Use around ctx.ui.notify calls that
 * should NOT be persisted (e.g., confirmation toasts after clear).
 * Calls are ref-counted — nest safely.
 */
export function suppressPersistence() {
	_suppressCount++;
}
export function unsuppressPersistence() {
	_suppressCount = Math.max(0, _suppressCount - 1);
}
/**
 * Subscribe to notification-store mutations (append, mark-read, clear).
 * Returns an unsubscribe function.
 */
export function onNotificationStoreChange(listener) {
	_changeListeners.add(listener);
	return () => {
		_changeListeners.delete(listener);
	};
}

/**
 * Return the count of appendNotification failures since last reset.
 * Consumed by doctor/status to surface persistent notification-store divergence.
 */
export function getAppendFailureCount() {
	return _appendFailureCount;
}

/**
 * Return the last append failure details, or null if none since reset.
 */
export function getLastAppendFailure() {
	return _lastAppendFailure;
}

// ─── Test Helpers ───────────────────────────────────────────────────────
/**
 * Reset module state. Only for use in tests.
 */
export function _resetNotificationStore() {
	_basePath = null;
	_lineCount = 0;
	_suppressCount = 0;
	_recentMessageTimestamps = new Map();
	_changeListeners.clear();
	_appendFailureCount = 0;
	_lastAppendFailure = null;
}
// ─── Internal ───────────────────────────────────────────────────────────
function _readEntriesFromDisk(basePath) {
	const filePath = join(sfRuntimeRoot(basePath), FILENAME);
	if (!existsSync(filePath)) return [];
	try {
		const content = readFileSync(filePath, "utf-8");
		return content
			.split("\n")
			.filter((l) => l.length > 0)
			.map((l) => {
				try {
					return normalizeNotificationEntry(JSON.parse(l));
				} catch {
					return null;
				}
			})
			.filter((e) => e !== null);
	} catch {
		return [];
	}
}
function normalizeNotificationEntry(entry) {
	if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
	const schemaVersion = entry.schemaVersion ?? NOTIFICATION_SCHEMA_VERSION;
	if (schemaVersion !== NOTIFICATION_SCHEMA_VERSION) return null;
	return {
		...entry,
		schemaVersion,
		read: entry.read === true,
	};
}
function hasRecentPersistedDuplicate(basePath, keySeed, now) {
	const normalizedKey = normalizeDedupKey(keySeed);
	const entries = _readEntriesFromDisk(basePath);
	for (const entry of entries.slice(-DURABLE_DEDUP_SCAN_LIMIT)) {
		if (entry.read === true) continue;
		const ts = Date.parse(entry.ts);
		if (!Number.isFinite(ts) || now - ts > DURABLE_DEDUP_WINDOW_MS) continue;
		const entryKey = entry.metadata?.dedupe_key
			? normalizeDedupKey(entry.metadata.dedupe_key)
			: normalizeDedupKey(
					`${entry.severity}:${entry.source ?? "notify"}:${entry.message}`,
				);
		if (entryKey === normalizedKey) return true;
	}
	return false;
}
function normalizeDedupKey(value) {
	return String(value)
		.replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/g, "<ts>")
		.replace(/\bsince\s+[^.]+/gi, "since <elapsed>")
		.trim();
}
function isNoisyStatusMessage(message) {
	return NOISY_STATUS_PATTERNS.some((pattern) => pattern.test(message));
}
function _rotate() {
	if (!_basePath) return;
	try {
		_withLock(_basePath, () => {
			// Re-read inside lock to get freshest state
			const entries = _readEntriesFromDisk(_basePath);
			if (entries.length <= MAX_ENTRIES) return;
			const trimmed = entries.slice(entries.length - MAX_ENTRIES);
			const lines = trimmed.map((e) => JSON.stringify(e));
			_atomicWrite(_basePath, lines.join("\n") + "\n");
			_lineCount = trimmed.length;
		});
	} catch {
		// Non-fatal
	}
}
function _emitChange() {
	for (const listener of _changeListeners) {
		try {
			listener();
		} catch {
			// Non-fatal
		}
	}
}
/**
 * Atomic file rewrite via temp-file + rename. Prevents partial reads
 * by other processes (web API subprocess, parallel workers).
 * Must be called inside _withLock for cross-process safety.
 */
function _atomicWrite(basePath, content) {
	const dir = sfRuntimeRoot(basePath);
	mkdirSync(dir, { recursive: true });
	const target = join(dir, FILENAME);
	const tmp = target + ".tmp." + process.pid;
	writeFileSync(tmp, content, "utf-8");
	renameSync(tmp, target);
}
/**
 * Acquire an exclusive lockfile for rewrite operations.
 * Uses O_CREAT|O_EXCL for atomic creation — if the file exists, another
 * process holds the lock. Retries briefly, then proceeds anyway (best-effort)
 * to avoid deadlocking the UI on a stale lock.
 */
function _withLock(basePath, fn) {
	const runtimeDir = sfRuntimeRoot(basePath);
	const lockPath = join(runtimeDir, LOCKFILE);
	let fd = null;
	const maxAttempts = 5;
	const retryMs = 20;
	for (let i = 0; i < maxAttempts; i++) {
		try {
			mkdirSync(runtimeDir, { recursive: true });
			fd = openSync(lockPath, "wx");
			break;
		} catch (err) {
			if (err?.code === "EEXIST") {
				// Check if lock is stale (older than 5s)
				try {
					const stat = readFileSync(lockPath, "utf-8");
					const lockTime = parseInt(stat, 10);
					// Treat NaN (creator crashed before writing timestamp) as stale.
					if (
						Number.isNaN(lockTime) ||
						(Number.isFinite(lockTime) && Date.now() - lockTime > 5000)
					) {
						try {
							unlinkSync(lockPath);
						} catch {
							/* race ok */
						}
						continue;
					}
				} catch {
					/* can't read lock, retry */
				}
				// Wait and retry
				const start = Date.now();
				while (Date.now() - start < retryMs) {
					/* spin */
				}
				continue;
			}
			// Other error — proceed without lock
			break;
		}
	}
	// Best-effort: mutation runs regardless of lock status (idempotent overwrites).
	// createdLock gates cleanup only — never skip fn() on lock failure.
	const createdLock = fd !== null;
	try {
		if (createdLock && fd !== null) {
			// Write our PID timestamp into the lock for stale detection
			writeFileSync(lockPath, String(Date.now()), "utf-8");
			closeSync(fd);
		}
		return fn();
	} finally {
		// Only delete the lock if we created it — never remove another process's lock
		if (createdLock) {
			try {
				unlinkSync(lockPath);
			} catch {
				/* best-effort cleanup */
			}
		}
	}
}

function _writeStderr(message) {
	process.stderr.write(message);
}
