import { randomBytes } from "node:crypto";
import {
	promises as fs,
	mkdirSync,
	renameSync,
	unlinkSync,
	writeFileSync,
} from "node:fs";
import { dirname } from "node:path";

const TRANSIENT_LOCK_ERROR_CODES = new Set(["EBUSY", "EPERM", "EACCES"]);
const MAX_RENAME_ATTEMPTS = 5;

type RetryableEncoding = BufferEncoding;
type MkdirOptions = { recursive: true };

/**
 * Async filesystem operations for dependency injection in atomic writes.
 */
export interface AtomicWriteAsyncOps {
	mkdir(path: string, options: MkdirOptions): Promise<void>;
	writeFile(
		path: string,
		content: string,
		encoding: RetryableEncoding,
	): Promise<void>;
	rename(from: string, to: string): Promise<void>;
	unlink(path: string): Promise<void>;
	sleep(ms: number): Promise<void>;
	createTempPath?(filePath: string): string;
}

/**
 * Sync filesystem operations for dependency injection in atomic writes.
 */
export interface AtomicWriteSyncOps {
	mkdir(path: string, options: MkdirOptions): void;
	writeFile(path: string, content: string, encoding: RetryableEncoding): void;
	rename(from: string, to: string): void;
	unlink(path: string): void;
	sleep(ms: number): void;
	createTempPath?(filePath: string): string;
}

function defaultTempPath(filePath: string): string {
	return filePath + `.tmp.${randomBytes(4).toString("hex")}`;
}

function computeRetryDelayMs(attempt: number): number {
	const base = 8 * attempt;
	const jitter = randomBytes(1)[0] % 5;
	return base + jitter;
}

function delay(ms: number): Promise<void> {
	return new Promise((resolve) => setTimeout(resolve, ms));
}

function sleepSync(ms: number): void {
	// Atomics.wait() is forbidden on the main thread (throws TypeError in that
	// context). Use a busy-wait spin instead — retry delays here are short
	// (8–40 ms) so CPU cost is negligible.
	const deadline = Date.now() + ms;
	while (Date.now() < deadline) {
		// spin
	}
}

function normalizeErrnoCode(error: unknown): string | undefined {
	if (error && typeof error === "object" && "code" in error) {
		const code = (error as { code?: unknown }).code;
		return typeof code === "string" ? code : undefined;
	}
	return undefined;
}

function isTransientLockError(error: unknown): boolean {
	const code = normalizeErrnoCode(error);
	return typeof code === "string" && TRANSIENT_LOCK_ERROR_CODES.has(code);
}

function buildAtomicWriteError(
	filePath: string,
	attempts: number,
	errors: unknown[],
): Error {
	const lastError = errors[errors.length - 1];
	const code = normalizeErrnoCode(lastError) ?? "UNKNOWN";
	const messages = errors.map(
		(e, i) =>
			`  attempt ${i + 1}: [${normalizeErrnoCode(e) ?? "UNKNOWN"}] ${e instanceof Error ? e.message : String(e)}`,
	);
	const wrapped = new Error(
		`Atomic write to ${filePath} failed after ${attempts} attempts:\n${messages.join("\n")}`,
	) as NodeJS.ErrnoException;
	wrapped.code = code;
	if (lastError instanceof Error && "stack" in lastError && lastError.stack) {
		wrapped.stack = lastError.stack;
	}
	return wrapped;
}

async function cleanupTempFileAsync(
	tmpPath: string,
	ops: AtomicWriteAsyncOps,
): Promise<void> {
	try {
		await ops.unlink(tmpPath);
	} catch {
		// Best-effort cleanup only.
	}
}

function cleanupTempFileSync(tmpPath: string, ops: AtomicWriteSyncOps): void {
	try {
		ops.unlink(tmpPath);
	} catch {
		// Best-effort cleanup only.
	}
}

/** @internal Exported for retry/cleanup tests. */
export async function atomicWriteAsyncWithOps(
	filePath: string,
	content: string,
	encoding: RetryableEncoding = "utf-8",
	ops: AtomicWriteAsyncOps,
): Promise<void> {
	await ops.mkdir(dirname(filePath), { recursive: true });
	const tmpPath = ops.createTempPath?.(filePath) ?? defaultTempPath(filePath);
	await ops.writeFile(tmpPath, content, encoding);

	const errors: unknown[] = [];
	let attempts = 0;

	for (attempts = 1; attempts <= MAX_RENAME_ATTEMPTS; attempts++) {
		try {
			await ops.rename(tmpPath, filePath);
			return;
		} catch (error) {
			errors.push(error);
			if (!isTransientLockError(error) || attempts === MAX_RENAME_ATTEMPTS) {
				break;
			}
			await ops.sleep(computeRetryDelayMs(attempts));
		}
	}

	await cleanupTempFileAsync(tmpPath, ops);
	throw buildAtomicWriteError(filePath, attempts, errors);
}

/** @internal Exported for retry/cleanup tests. */
export function atomicWriteSyncWithOps(
	filePath: string,
	content: string,
	encoding: RetryableEncoding = "utf-8",
	ops: AtomicWriteSyncOps,
): void {
	ops.mkdir(dirname(filePath), { recursive: true });
	const tmpPath = ops.createTempPath?.(filePath) ?? defaultTempPath(filePath);
	ops.writeFile(tmpPath, content, encoding);

	const errors: unknown[] = [];
	let attempts = 0;

	for (attempts = 1; attempts <= MAX_RENAME_ATTEMPTS; attempts++) {
		try {
			ops.rename(tmpPath, filePath);
			return;
		} catch (error) {
			errors.push(error);
			if (!isTransientLockError(error) || attempts === MAX_RENAME_ATTEMPTS) {
				break;
			}
			ops.sleep(computeRetryDelayMs(attempts));
		}
	}

	cleanupTempFileSync(tmpPath, ops);
	throw buildAtomicWriteError(filePath, attempts, errors);
}

const DEFAULT_ASYNC_OPS: AtomicWriteAsyncOps = {
	mkdir: async (path, options) => {
		await fs.mkdir(path, options);
	},
	writeFile: (path, content, encoding) => fs.writeFile(path, content, encoding),
	rename: (from, to) => fs.rename(from, to),
	unlink: (path) => fs.unlink(path),
	sleep: delay,
};

const DEFAULT_SYNC_OPS: AtomicWriteSyncOps = {
	mkdir: (path, options) => mkdirSync(path, options),
	writeFile: (path, content, encoding) =>
		writeFileSync(path, content, encoding),
	rename: (from, to) => renameSync(from, to),
	unlink: (path) => unlinkSync(path),
	sleep: sleepSync,
};

/**
 * Atomically writes content to a file by writing to a temp file first,
 * then renaming. Prevents partial/corrupt files on crash.
 */
export function atomicWriteSync(
	filePath: string,
	content: string,
	encoding: BufferEncoding = "utf-8",
): void {
	atomicWriteSyncWithOps(filePath, content, encoding, DEFAULT_SYNC_OPS);
}

/**
 * Async variant of atomicWriteSync. Atomically writes content to a file
 * by writing to a temp file first, then renaming.
 */
export async function atomicWriteAsync(
	filePath: string,
	content: string,
	encoding: BufferEncoding = "utf-8",
): Promise<void> {
	return atomicWriteAsyncWithOps(
		filePath,
		content,
		encoding,
		DEFAULT_ASYNC_OPS,
	);
}
