import Foundation

/// Keep-warm release governor for nvrsion (NVRSION §4). After an agent *lands* a file it keeps the
/// lock ("warm") past that single edit, so a sibling can't interleave between two edits of one
/// logical change — but only until the file goes idle past the project's `keepWarmIdle`, at which
/// point a sweep releases it without waiting for the whole turn to end. Turn-end / lifecycle drops
/// everything (the host calls `forget`). Re-landing a file refreshes its idle clock, so a file under
/// active editing stays held while a finished one is handed off quickly.
///
/// Pure timekeeping over an injected clock — no locks, no git — so the eviction policy is unit
/// testable in isolation; `AppStore` performs the actual `LockManager.release` for whatever `sweep`
/// returns. Releasing a file the agent later returns to simply re-acquires + re-grounds (NVRSION §4).
public actor NvrsionReleaseGovernor {
    private let now: @Sendable () -> Date

    private struct Warm { var landedAt: Date; var idle: TimeInterval }
    /// `warm[session][path]` — a file the session has landed and still holds, with its idle window.
    private var warm: [SessionID: [String: Warm]] = [:]

    public init(now: @escaping @Sendable () -> Date = { Date() }) { self.now = now }

    /// Record (or refresh) that `session` just landed `path` and still holds it; `idleSeconds` is
    /// the project's keep-warm window (NVRSION §4). Re-landing refreshes the idle clock.
    public func warmed(_ session: SessionID, _ path: String, idleSeconds: Int) {
        warm[session, default: [:]][path] = Warm(landedAt: now(), idle: TimeInterval(max(0, idleSeconds)))
    }

    /// Forget everything `session` holds warm — turn-end or lifecycle (the host released the locks).
    public func forget(_ session: SessionID) { warm[session] = nil }

    /// The files idle past their window, grouped by session; the governor drops them from its table
    /// (the caller releases the corresponding locks). A file re-landed within its window is kept.
    public func sweep() -> [(session: SessionID, paths: [String])] {
        let t = now()
        var out: [(session: SessionID, paths: [String])] = []
        for (session, files) in warm {
            let due = files.filter { t.timeIntervalSince($0.value.landedAt) >= $0.value.idle }.map(\.key)
            guard !due.isEmpty else { continue }
            for p in due { warm[session]?[p] = nil }
            if warm[session]?.isEmpty == true { warm[session] = nil }
            out.append((session: session, paths: due.sorted()))
        }
        return out
    }

    /// Nothing is warm — the host stops the sweep timer until the next land.
    public var isEmpty: Bool { warm.isEmpty }

    /// Warm paths a session currently holds, sorted (tests / introspection).
    public func warmPaths(_ session: SessionID) -> [String] {
        (warm[session].map { Array($0.keys) } ?? []).sorted()
    }
}
