/**
 * detector-periodic-runner.test.mjs — periodic detector sweep contracts.
 *
 * Purpose: prove R056 aggregates all Wiggums detectors, throttles per-name,
 * respects a custom detector list, and isolates individual detector failures
 * from the rest of the sweep.
 */
import assert from "node:assert/strict";
import { test } from "vitest";
import {
	SWEEP_CADENCE_MS,
	runDetectorSweep,
} from "../detectors/periodic-runner.js";

const NOW = Date.parse("2026-05-17T12:00:00.000Z");

/**
 * Build a minimal clean ctx that produces no detector fires.
 * All optional fields are either absent or set to values that do not trigger.
 */
function cleanCtx() {
	return {
		unitId: "M001/S01/T01",
		recentDispatches: [], // no same-unit-loop
		unitMetrics: {
			// no zero-progress (no tool calls yet)
			toolCalls: 0,
			elapsedMs: 0,
			iterationsSinceProgress: 0,
		},
		sessionFingerprint: null,
		recentFeedback: [], // no repeated-feedback-kind
		artifactHistory: [], // no artifact-flap
		lockPaths: [], // no stale locks (empty list)
	};
}

// ── All-clean ctx → zero detectorsFired, all 8 core detectors checked ─────

test("runDetectorSweep_when_all_clean_returns_empty_detectorsFired_and_core_detectors_checked", async () => {
	const result = await runDetectorSweep(cleanCtx());

	assert.equal(result.detectorsFired.length, 0);
	// 8 core detectors; optional model-route-flap / prompt-drift add up to 2 more
	assert.ok(
		result.totalChecked >= 8,
		`Expected at least 8 detectors checked, got ${result.totalChecked}`,
	);
	assert.equal(typeof result.durationMs, "number");
	assert.ok(result.durationMs >= 0);
});

// ── One detector fires → appears in detectorsFired with name + signature ──

test("runDetectorSweep_when_one_custom_detector_fires_returns_it_in_detectorsFired", async () => {
	const customSig = { testKey: "testValue" };
	const customDetector = {
		name: "test-always-fires",
		run: async () => ({ stuck: true, reason: "test", signature: customSig }),
	};

	const result = await runDetectorSweep({}, { detectors: [customDetector] });

	assert.equal(result.detectorsFired.length, 1);
	assert.equal(result.detectorsFired[0].name, "test-always-fires");
	assert.deepEqual(result.detectorsFired[0].signature, customSig);
	assert.equal(result.totalChecked, 1);
});

// ── Throttle: same detector fires twice within throttleMs → second run skips it ─

test("runDetectorSweep_throttle_prevents_second_fire_within_throttleMs", async () => {
	const throttleState = new Map();
	const throttleMs = 5_000; // 5 seconds
	const callCount = { n: 0 };

	const flakyDetector = {
		name: "test-flaky",
		run: async () => {
			callCount.n += 1;
			return { stuck: true, reason: "test", signature: { call: callCount.n } };
		},
	};
	const opts = { detectors: [flakyDetector], throttleState, throttleMs };

	// First call — detector fires and is throttled.
	const result1 = await runDetectorSweep({}, opts);
	assert.equal(result1.detectorsFired.length, 1);
	assert.equal(result1.totalChecked, 1);

	// Second call within throttleMs — detector must NOT run (skipped by throttle).
	const result2 = await runDetectorSweep({}, opts);
	assert.equal(result2.detectorsFired.length, 0);
	assert.equal(
		result2.totalChecked,
		0,
		"Throttled detector should not be checked",
	);

	// The detector's run() was only called once (the throttle short-circuits).
	assert.equal(callCount.n, 1);
});

// ── Throttle: after throttleMs expires the detector runs again ────────────

test("runDetectorSweep_throttle_allows_refire_after_throttleMs", async () => {
	const throttleState = new Map();
	const throttleMs = 100; // 100ms
	const fireTime = NOW - 200; // 200ms ago — before window

	// Seed the throttle state with a past fire time that is older than throttleMs.
	throttleState.set("test-old", fireTime);

	const refiringDetector = {
		name: "test-old",
		run: async () => ({
			stuck: true,
			reason: "test",
			signature: { refired: true },
		}),
	};

	const result = await runDetectorSweep(
		{},
		{
			detectors: [refiringDetector],
			throttleState,
			throttleMs,
		},
	);

	assert.equal(result.detectorsFired.length, 1);
	assert.equal(result.detectorsFired[0].name, "test-old");
});

// ── options.detectors override: only those detectors run ──────────────────

test("runDetectorSweep_when_custom_detectors_provided_only_those_run", async () => {
	const runLog = [];

	const detectorA = {
		name: "custom-a",
		run: async () => {
			runLog.push("a");
			return { stuck: false };
		},
	};
	const detectorB = {
		name: "custom-b",
		run: async () => {
			runLog.push("b");
			return { stuck: false };
		},
	};

	const result = await runDetectorSweep(
		{},
		{ detectors: [detectorA, detectorB] },
	);

	assert.deepEqual(runLog, ["a", "b"]);
	assert.equal(result.totalChecked, 2);
	assert.equal(result.detectorsFired.length, 0);
});

// ── onError callback is invoked when a detector throws ───────────────────

test("runDetectorSweep_when_detector_throws_onError_is_called_and_sweep_continues", async () => {
	const errors = [];
	const names = [];

	const throwingDetector = {
		name: "test-throws",
		run: async () => {
			throw new Error("boom");
		},
	};
	const goodDetector = {
		name: "test-good",
		run: async () => ({ stuck: false }),
	};

	const result = await runDetectorSweep(
		{},
		{
			detectors: [throwingDetector, goodDetector],
			onError: (err, name) => {
				errors.push(err.message);
				names.push(name);
			},
		},
	);

	// Good detector still ran.
	assert.equal(result.totalChecked, 2);
	assert.equal(result.detectorsFired.length, 0);

	// Error callback was invoked for the throwing detector.
	assert.equal(errors.length, 1);
	assert.equal(errors[0], "boom");
	assert.equal(names[0], "test-throws");
});

// ── Not-stuck detectors don't appear in detectorsFired ───────────────────

test("runDetectorSweep_when_detector_returns_not_stuck_it_is_excluded_from_fired", async () => {
	const notStuckDetector = {
		name: "test-not-stuck",
		run: async () => ({ stuck: false }),
	};

	const result = await runDetectorSweep({}, { detectors: [notStuckDetector] });

	assert.equal(result.detectorsFired.length, 0);
	assert.equal(result.totalChecked, 1);
});

// ── Multiple detectors fire → all appear in detectorsFired ───────────────

test("runDetectorSweep_when_multiple_detectors_fire_all_appear_in_fired", async () => {
	const detectors = [
		{
			name: "fire-alpha",
			run: async () => ({ stuck: true, reason: "alpha", signature: { a: 1 } }),
		},
		{
			name: "fire-beta",
			run: async () => ({ stuck: true, reason: "beta", signature: { b: 2 } }),
		},
	];

	const result = await runDetectorSweep({}, { detectors });

	assert.equal(result.detectorsFired.length, 2);
	const names = result.detectorsFired.map((d) => d.name);
	assert.ok(names.includes("fire-alpha"));
	assert.ok(names.includes("fire-beta"));
});

// ── durationMs is always a non-negative number ───────────────────────────

test("runDetectorSweep_durationMs_is_always_non_negative", async () => {
	const result = await runDetectorSweep(cleanCtx(), {
		detectors: [],
	});
	assert.equal(result.totalChecked, 0);
	assert.ok(result.durationMs >= 0);
});

// ── SWEEP_CADENCE_MS is exported and is 60s ───────────────────────────────

test("SWEEP_CADENCE_MS_is_60_seconds", () => {
	assert.equal(SWEEP_CADENCE_MS, 60_000);
});
