/**
 * detector-stale-lock.test.mjs — stale-lock detector contracts.
 *
 * Purpose: prove R055 catches lock files whose owning process is gone while
 * leaving live locks and absent locks alone.
 */
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, test } from "vitest";
import { detectStaleLock } from "../detectors/stale-lock.js";

let tmpDir;

beforeEach(() => {
	tmpDir = mkdtempSync(join(tmpdir(), "sf-stale-lock-test-"));
});

afterEach(() => {
	try {
		rmSync(tmpDir, { recursive: true, force: true });
	} catch {
		// Best-effort cleanup.
	}
});

function lockPath(name = "sf.lock") {
	return join(tmpDir, name);
}

/**
 * Write a JSON envelope lock file with an embedded pid field.
 * The detector's parsePid reads the ".pid" key from a JSON object; a plain
 * integer string is valid JSON but has no .pid property, so JSON envelope is
 * the only format that reliably round-trips through parsePid.
 */
function writeLock(path, pid, extra = {}) {
	writeFileSync(
		path,
		JSON.stringify({ pid, lockedAt: Date.now(), ...extra }),
		"utf8",
	);
}

/**
 * A PID that is virtually guaranteed to be dead: pid 1 is always alive;
 * pick a very high PID unlikely to exist and that process.kill(pid, 0) will
 * reject with ESRCH. We use MAX_PID - 1 so it never aliases a live process
 * in the test runner's PID namespace.
 */
const DEAD_PID = 2_147_483_647; // INT_MAX; ESRCH guaranteed on Linux/macOS

/**
 * The current process PID — guaranteed alive.
 */
const LIVE_PID = process.pid;

// ── Dead PID: plain lock file ─────────────────────────────────────────────

test("detectStaleLock_when_dead_pid_plain_lock_returns_stuck", async () => {
	const path = lockPath("sf.lock");
	writeLock(path, DEAD_PID);

	const result = await detectStaleLock([path]);

	assert.equal(result.stuck, true);
	assert.equal(result.reason, "stale-lock");
	assert.equal(result.signature.lockPath, path);
	assert.equal(result.signature.deadPid, DEAD_PID);
	assert.equal(typeof result.signature.ageMs, "number");
	assert.ok(result.signature.ageMs >= 0);
});

// ── Dead PID: JSON envelope lock file ────────────────────────────────────

test("detectStaleLock_when_dead_pid_json_lock_returns_stuck", async () => {
	const path = lockPath("interactive.lock");
	writeLock(path, DEAD_PID);

	const result = await detectStaleLock([path]);

	assert.equal(result.stuck, true);
	assert.equal(result.reason, "stale-lock");
	assert.equal(result.signature.deadPid, DEAD_PID);
});

// ── Live PID: lock file exists but owner is alive ─────────────────────────

test("detectStaleLock_when_live_pid_lock_returns_not_stuck", async () => {
	const path = lockPath("sf.lock");
	writeLock(path, LIVE_PID);

	const result = await detectStaleLock([path]);

	assert.deepEqual(result, { stuck: false });
});

// ── Lock file absent ──────────────────────────────────────────────────────

test("detectStaleLock_when_lock_file_absent_returns_not_stuck", async () => {
	const path = lockPath("missing.lock");
	// Deliberately do not create the file.

	const result = await detectStaleLock([path]);

	assert.deepEqual(result, { stuck: false });
});

// ── autoRecover: true, dead PID → deletes the lock and returns recovered ──

test("detectStaleLock_when_autoRecover_true_and_dead_pid_deletes_lock_and_returns_recovered", async () => {
	const path = lockPath("sf.lock");
	writeLock(path, DEAD_PID);

	const result = await detectStaleLock([path], { autoRecover: true });

	assert.equal(result.stuck, true);
	assert.equal(result.recovered, true);

	// The lock file must be gone after autoRecover.
	assert.equal(
		existsSync(path),
		false,
		"Lock file must be deleted by autoRecover",
	);
});

// ── autoRecover: false (default) → reports stuck but does NOT delete ──────

test("detectStaleLock_when_autoRecover_false_does_not_delete_lock", async () => {
	const path = lockPath("sf.lock");
	writeLock(path, DEAD_PID);

	const result = await detectStaleLock([path], { autoRecover: false });

	assert.equal(result.stuck, true);
	assert.equal(result.recovered, false);

	assert.equal(
		existsSync(path),
		true,
		"Lock file must remain when autoRecover is false",
	);
});

// ── Multiple paths: first stale lock wins ────────────────────────────────

test("detectStaleLock_when_multiple_paths_first_stale_lock_is_returned", async () => {
	const livePath = lockPath("live.lock");
	const deadPath = lockPath("dead.lock");
	writeLock(livePath, LIVE_PID);
	writeLock(deadPath, DEAD_PID);

	const result = await detectStaleLock([livePath, deadPath]);

	assert.equal(result.stuck, true);
	assert.equal(result.signature.lockPath, deadPath);
});

// ── Empty paths array → not stuck ────────────────────────────────────────

test("detectStaleLock_when_empty_paths_returns_not_stuck", async () => {
	const result = await detectStaleLock([]);
	assert.deepEqual(result, { stuck: false });
});

// ── Null/undefined paths → not stuck (graceful) ──────────────────────────

test("detectStaleLock_when_null_paths_returns_not_stuck", async () => {
	const result = await detectStaleLock(null);
	assert.deepEqual(result, { stuck: false });
});
