import Foundation
import Testing

@testable import NucleicCore

/// A clock the test advances by hand, so keep-warm eviction (which is pure timekeeping) is
/// deterministic without real sleeps. Thread-safe — the governor reads it from an actor.
final class MutableClock: @unchecked Sendable {
    private let lock = NSLock()
    private var _date: Date
    init(_ date: Date = Date(timeIntervalSince1970: 1_000_000)) { _date = date }
    var date: Date { lock.withLock { _date } }
    func advance(_ seconds: TimeInterval) { lock.withLock { _date = _date.addingTimeInterval(seconds) } }
    var now: @Sendable () -> Date { { [self] in date } }
}

@Suite("NvrsionReleaseGovernor — keep-warm eviction (NVRSION §4)")
struct NvrsionReleaseGovernorTests {
    @Test func keepsAFileUntilIdlePastWindowThenSweepsIt() async throws {
        let clock = MutableClock()
        let gov = NvrsionReleaseGovernor(now: clock.now)
        let s = SessionID.generate()
        await gov.warmed(s, "a.txt", idleSeconds: 4)

        // Before the window: held — the sweep returns nothing and the file stays warm.
        clock.advance(3)
        #expect(await gov.sweep().isEmpty)
        #expect(await gov.warmPaths(s) == ["a.txt"])

        // Past the window: swept and dropped.
        clock.advance(2)  // 5 ≥ 4
        let swept = await gov.sweep()
        #expect(swept.count == 1)
        #expect(swept.first?.session == s)
        #expect(swept.first?.paths == ["a.txt"])
        #expect(await gov.isEmpty)
    }

    @Test func reLandingRefreshesTheIdleClock() async throws {
        let clock = MutableClock()
        let gov = NvrsionReleaseGovernor(now: clock.now)
        let s = SessionID.generate()
        await gov.warmed(s, "a.txt", idleSeconds: 4)
        clock.advance(3)
        await gov.warmed(s, "a.txt", idleSeconds: 4)  // active edit refreshes the clock

        clock.advance(2)  // only 2 since the refresh
        #expect(await gov.sweep().isEmpty)  // still held — active work protected
        clock.advance(3)  // now 5 since the refresh
        #expect(await gov.sweep().first?.paths == ["a.txt"])
    }

    @Test func forgetDropsEverythingForASession() async throws {
        let clock = MutableClock()
        let gov = NvrsionReleaseGovernor(now: clock.now)
        let s = SessionID.generate()
        await gov.warmed(s, "a.txt", idleSeconds: 4)
        await gov.warmed(s, "b.txt", idleSeconds: 4)
        await gov.forget(s)  // turn-end / lifecycle
        #expect(await gov.isEmpty)
        clock.advance(100)
        #expect(await gov.sweep().isEmpty)
    }

    @Test func sweepsIdleFilesButKeepsActiveOnes() async throws {
        let clock = MutableClock()
        let gov = NvrsionReleaseGovernor(now: clock.now)
        let s = SessionID.generate()
        await gov.warmed(s, "old.txt", idleSeconds: 2)
        clock.advance(1)
        await gov.warmed(s, "new.txt", idleSeconds: 2)  // landed 1s after old

        clock.advance(1.5)  // old: 2.5 ≥ 2 (due);  new: 1.5 < 2 (kept)
        let swept = await gov.sweep()
        #expect(swept.first?.paths == ["old.txt"])
        #expect(await gov.warmPaths(s) == ["new.txt"])
    }

    @Test func differentSessionsAreSweptIndependently() async throws {
        let clock = MutableClock()
        let gov = NvrsionReleaseGovernor(now: clock.now)
        let a = SessionID.generate()
        let b = SessionID.generate()
        await gov.warmed(a, "x", idleSeconds: 2)
        clock.advance(3)
        await gov.warmed(b, "y", idleSeconds: 2)  // b is fresh

        let swept = await gov.sweep()
        #expect(swept.count == 1)
        #expect(swept.first?.session == a)
        #expect(await gov.warmPaths(b) == ["y"])  // b kept
    }
}
