import Foundation
import Testing

@testable import NucleicCore

private let dev = LockDomain("dev")
private func sid(_ s: String) -> SessionID { SessionID(rawValue: s) }

/// Poll `cond` until true or the timeout — for asserting that a backgrounded `acquire` has
/// parked in the queue before we trigger the release that should grant it.
private func waitUntil(_ cond: @Sendable @escaping () async -> Bool, _ timeoutMs: Int = 2000) async {
    var elapsed = 0
    while elapsed < timeoutMs {
        if await cond() { return }
        try? await Task.sleep(for: .milliseconds(5))
        elapsed += 5
    }
}

/// Records which sessions the update-on-grant hook was invoked for.
private actor GrantRecorder {
    var ids: [String] = []
    func add(_ s: String) { ids.append(s) }
    func snapshot() -> [String] { ids }
}

/// Records the lock-lifecycle events the host hook is fed, for the chat-log audit trail.
private actor LockEventRecorder {
    var events: [(String, LockEvent)] = []
    func add(_ s: String, _ e: LockEvent) { events.append((s, e)) }
    func snapshot() -> [(String, LockEvent)] { events }
}

@Suite("LockManager — dedicated file locks", .serialized)
struct LockManagerTests {
    @Test func disjointFilesBothGrantImmediately() async {
        let lm = LockManager()
        #expect(await lm.acquire(sid("a"), domain: dev, files: ["x.swift"]) == .granted)
        #expect(await lm.acquire(sid("b"), domain: dev, files: ["y.swift"]) == .granted)
        #expect(await lm.heldPaths(sid("a")) == ["x.swift"])
        #expect(await lm.heldPaths(sid("b")) == ["y.swift"])
    }

    @Test func sameFileBlocksThenGrantsOnRelease() async {
        let lm = LockManager()
        #expect(await lm.acquire(sid("a"), domain: dev, files: ["x"]) == .granted)
        let asker = Task { await lm.acquire(sid("b"), domain: dev, files: ["x"]) }
        await waitUntil { await lm.isWaiting(sid("b")) }
        #expect(await lm.heldPaths(sid("b")).isEmpty)        // holds nothing while blocked
        await lm.release(sid("a"), paths: ["x"])
        #expect(await asker.value == .granted)
        #expect(await lm.heldPaths(sid("b")) == ["x"])
    }

    /// A fresh session needing {a, b} where b is held holds **neither** while queued — so it
    /// never blocks another session from the free file `a`.
    @Test func freshAcquisitionIsAllOrNothing() async {
        let lm = LockManager()
        #expect(await lm.acquire(sid("a"), domain: dev, files: ["b.swift"]) == .granted)
        let asker = Task { await lm.acquire(sid("b"), domain: dev, files: ["a.swift", "b.swift"]) }
        await waitUntil { await lm.isWaiting(sid("b")) }
        #expect(await lm.heldPaths(sid("b")).isEmpty)        // not even the free a.swift
        // Another session still takes the free a.swift — the queued one isn't holding it.
        #expect(await lm.acquire(sid("c"), domain: dev, files: ["a.swift"]) == .granted)
        await lm.release(sid("a"), paths: ["b.swift"])
        await lm.release(sid("c"), paths: ["a.swift"])
        #expect(await asker.value == .granted)
        #expect(await lm.heldPaths(sid("b")) == ["a.swift", "b.swift"])
    }

    /// Mid-work expansion keeps already-held files and queues only for the new set, never
    /// holding a *new* file while blocked.
    @Test func midWorkHoldsEditedAndQueuesForDelta() async {
        let lm = LockManager()
        #expect(await lm.acquire(sid("a"), domain: dev, files: ["shared"]) == .granted)
        #expect(await lm.acquire(sid("b"), domain: dev, files: ["owned"]) == .granted)
        let expand = Task { await lm.acquire(sid("b"), domain: dev, files: ["shared"]) }
        await waitUntil { await lm.isWaiting(sid("b")) }
        #expect(await lm.heldPaths(sid("b")) == ["owned"])   // keeps what it had, no new file
        await lm.release(sid("a"), paths: ["shared"])
        #expect(await expand.value == .granted)
        #expect(await lm.heldPaths(sid("b")) == ["owned", "shared"])
    }

    /// Readiness, not queue position, gates a grant: a queued waiter whose file frees is granted
    /// even though an earlier waiter ahead of it is still blocked.
    @Test func readyQueuedWaiterIsGrantedAheadOfBlockedHead() async {
        let lm = LockManager()
        #expect(await lm.acquire(sid("holder"), domain: dev, files: ["x", "y"]) == .granted)
        let w1 = Task { await lm.acquire(sid("first"), domain: dev, files: ["x"]) }
        await waitUntil { await lm.isWaiting(sid("first")) }
        let w2 = Task { await lm.acquire(sid("second"), domain: dev, files: ["y"]) }
        await waitUntil { await lm.isWaiting(sid("second")) }
        await lm.release(sid("holder"), paths: ["y"])         // free only the second's file
        #expect(await w2.value == .granted)
        #expect(await lm.isWaiting(sid("first")))             // head still waits for x
        await lm.release(sid("holder"), paths: ["x"])
        #expect(await w1.value == .granted)
    }

    @Test func releaseAllCancelsAWaitingSession() async {
        let lm = LockManager()
        #expect(await lm.acquire(sid("a"), domain: dev, files: ["x"]) == .granted)
        let asker = Task { await lm.acquire(sid("b"), domain: dev, files: ["x"]) }
        await waitUntil { await lm.isWaiting(sid("b")) }
        await lm.releaseAll(sid("b"))                          // b terminated while waiting
        #expect(await asker.value == .cancelled)
        #expect(await lm.isWaiting(sid("b")) == false)
    }

    @Test func reapsDeadWaiter() async {
        let lm = LockManager()
        await lm.setReapCheck { $0 == sid("gone") }
        #expect(await lm.acquire(sid("a"), domain: dev, files: ["x"]) == .granted)
        let asker = Task { await lm.acquire(sid("gone"), domain: dev, files: ["x"]) }
        await waitUntil { await lm.isWaiting(sid("gone")) }
        await lm.poll(dev)                                     // a poll reaps the dead waiter
        #expect(await asker.value == .cancelled)
    }

    @Test func directoryLockBlocksFileUnderItButNotSiblings() async {
        let lm = LockManager()
        #expect(await lm.acquire(sid("a"), domain: dev, files: ["src/"]) == .granted)
        let asker = Task { await lm.acquire(sid("b"), domain: dev, files: ["src/auth/Login.swift"]) }
        await waitUntil { await lm.isWaiting(sid("b")) }
        #expect(await lm.acquire(sid("c"), domain: dev, files: ["src2/Other.swift"]) == .granted)
        await lm.release(sid("a"), paths: ["src/"])
        #expect(await asker.value == .granted)
    }

    @Test func differentDomainsDoNotContend() async {
        let lm = LockManager()
        #expect(await lm.acquire(sid("a"), domain: LockDomain("dev"), files: ["x"]) == .granted)
        #expect(await lm.acquire(sid("b"), domain: LockDomain("main"), files: ["x"]) == .granted)
        #expect(await lm.state(in: LockDomain("dev")).holders.count == 1)
        #expect(await lm.state(in: LockDomain("main")).holders.count == 1)
    }

    @Test func snapshotReportsHoldersAndWaiters() async {
        let lm = LockManager()
        #expect(await lm.acquire(sid("holder"), domain: dev, files: ["f.swift"]) == .granted)
        let asker = Task { await lm.acquire(sid("waiter"), domain: dev, files: ["f.swift"]) }
        await waitUntil { await lm.isWaiting(sid("waiter")) }
        let state = await lm.state(in: dev)
        #expect(state.holders == [.init(session: sid("holder"), paths: ["f.swift"])])
        #expect(state.waiters == [.init(session: sid("waiter"), delta: ["f.swift"])])
        await lm.releaseAll(sid("holder"))
        #expect(await asker.value == .granted)
    }

    /// Update-on-grant runs on BOTH an immediate, uncontended acquire AND a queued waiter that is
    /// later granted — so a freshly-granted session always refreshes from the parent (LOCKING §4.5),
    /// never left on a stale base whether or not it had to wait.
    @Test func updateOnGrantRunsForImmediateAndWaiter() async {
        let lm = LockManager()
        let recorder = GrantRecorder()
        await lm.setUpdateOnGrant { sid, _ in await recorder.add(sid.rawValue); return .clean }

        _ = await lm.acquire(sid("a"), domain: dev, files: ["x"])   // immediate — now runs update-on-grant
        #expect(await recorder.snapshot() == ["a"])

        let w = Task { await lm.acquire(sid("b"), domain: dev, files: ["x"]) }
        await waitUntil { await lm.isWaiting(sid("b")) }
        await lm.release(sid("a"), paths: ["x"])
        #expect(await w.value == .granted)
        #expect(await recorder.snapshot() == ["a", "b"])           // immediate grant AND granted waiter
    }

    /// A `.changed` from update-on-grant (the parent moved a file this edit targets) surfaces as
    /// `.grantedNeedsReground` carrying the files + diff, while keeping the lock held.
    @Test func changedUpdateSurfacesGrantedNeedsReground() async {
        let lm = LockManager()
        await lm.setUpdateOnGrant { _, _ in .changed(files: ["x"], diff: "PATCH") }
        let outcome = await lm.acquire(sid("a"), domain: dev, files: ["x"])
        #expect(outcome == .grantedNeedsReground(files: ["x"], diff: "PATCH"))
        #expect(await lm.heldPaths(sid("a")) == ["x"])             // lock held — the agent re-grounds, doesn't release
    }

    /// The re-ground block fires exactly once: an already-held re-acquire short-circuits with no
    /// update-on-grant, so the agent's retried edit proceeds instead of being blocked again.
    @Test func reAcquireOfHeldFileDoesNotRerunUpdateOnGrant() async {
        let lm = LockManager()
        let recorder = GrantRecorder()
        await lm.setUpdateOnGrant { sid, _ in await recorder.add(sid.rawValue); return .changed(files: ["x"], diff: "P") }

        let first = await lm.acquire(sid("a"), domain: dev, files: ["x"])
        #expect(first == .grantedNeedsReground(files: ["x"], diff: "P"))
        let retry = await lm.acquire(sid("a"), domain: dev, files: ["x"])   // already holds x
        #expect(retry == .granted)                                  // bare grant, no re-block
        #expect(await recorder.snapshot() == ["a"])                 // hook ran once, not on the retry
    }

    /// No cycle ⇒ no deadlock: one holder, one ordinary waiter behind it is not a deadlock (the
    /// holder isn't itself waiting, so it will eventually release).
    @Test func ordinaryQueueingIsNotADeadlock() async {
        let lm = LockManager()
        #expect(await lm.acquire(sid("a"), domain: dev, files: ["x"]) == .granted)
        let asker = Task { await lm.acquire(sid("b"), domain: dev, files: ["x"]) }
        await waitUntil { await lm.isWaiting(sid("b")) }
        #expect(await lm.deadlockedSessions().isEmpty)
        await lm.releaseAll(sid("a"))
        #expect(await asker.value == .granted)
    }

    /// Two sessions each holding a file the other is queued for is a true deadlock: A holds x and
    /// waits for y, B holds y and waits for x. Both sessions are flagged.
    @Test func mutualHoldAndWaitIsDeadlock() async {
        let lm = LockManager()
        #expect(await lm.acquire(sid("a"), domain: dev, files: ["x"]) == .granted)
        #expect(await lm.acquire(sid("b"), domain: dev, files: ["y"]) == .granted)
        let wa = Task { await lm.acquire(sid("a"), domain: dev, files: ["x", "y"]) }
        let wb = Task { await lm.acquire(sid("b"), domain: dev, files: ["x", "y"]) }
        await waitUntil { await lm.deadlockedSessions().count == 2 }
        #expect(await lm.deadlockedSessions() == [sid("a"), sid("b")])
        // Releasing one holder breaks the cycle and lets the other proceed.
        await lm.releaseAll(sid("a"))
        #expect(await wb.value == .granted)
        wa.cancel()
    }

    /// A fresh waiter holding nothing can point *into* a deadlock cycle without being on it — it's
    /// excluded from the flagged set.
    @Test func freshWaiterIntoCycleIsNotFlagged() async {
        let lm = LockManager()
        #expect(await lm.acquire(sid("a"), domain: dev, files: ["x"]) == .granted)
        #expect(await lm.acquire(sid("b"), domain: dev, files: ["y"]) == .granted)
        let wa = Task { await lm.acquire(sid("a"), domain: dev, files: ["x", "y"]) }
        let wb = Task { await lm.acquire(sid("b"), domain: dev, files: ["x", "y"]) }
        let wc = Task { await lm.acquire(sid("c"), domain: dev, files: ["x"]) }  // holds nothing
        await waitUntil { await lm.state(in: dev).waiters.count == 3 }
        #expect(await lm.deadlockedSessions() == [sid("a"), sid("b")])         // c excluded
        wa.cancel(); wb.cancel(); wc.cancel()
        await lm.releaseAll(sid("a")); await lm.releaseAll(sid("b")); await lm.releaseAll(sid("c"))
    }

    /// An update-on-grant conflict keeps the lock and surfaces `.updateConflict` to the caller.
    @Test func updateConflictKeepsLockAndSignalsCaller() async {
        let lm = LockManager()
        await lm.setUpdateOnGrant { _, _ in .conflicted(["x"]) }
        _ = await lm.acquire(sid("a"), domain: dev, files: ["x"])
        let w = Task { await lm.acquire(sid("b"), domain: dev, files: ["x"]) }
        await waitUntil { await lm.isWaiting(sid("b")) }
        await lm.release(sid("a"), paths: ["x"])
        #expect(await w.value == .updateConflict(["x"]))
        #expect(await lm.heldPaths(sid("b")) == ["x"])            // lock kept despite the conflict
    }

    // MARK: Reconcile-loop liveness (LOCKING §4.4)

    /// `isIdle()` is the authoritative liveness gate for the host's release-reconcile loop: true
    /// only when nothing is held and nobody waits. A held lock or a queued waiter keeps it false,
    /// so the loop can never stop while a lock still needs watching for its landing.
    @Test func isIdleTracksHoldersAndWaiters() async {
        let lm = LockManager()
        #expect(await lm.isIdle())                                          // empty → idle
        #expect(await lm.acquire(sid("a"), domain: dev, files: ["x"]) == .granted)
        #expect(await !lm.isIdle())                                         // a holder → busy
        let waiter = Task { await lm.acquire(sid("b"), domain: dev, files: ["x"]) }
        await waitUntil { await lm.isWaiting(sid("b")) }
        #expect(await !lm.isIdle())                                         // holder + waiter → busy
        await lm.release(sid("a"), paths: ["x"])                            // grants the waiter
        #expect(await waiter.value == .granted)
        #expect(await !lm.isIdle())                                         // b now holds x → busy
        await lm.release(sid("b"), paths: ["x"])
        #expect(await lm.isIdle())                                          // fully drained → idle
    }

    /// A grant yields on the reconcile-signal stream, so a loop parked while idle wakes the instant
    /// a lock is acquired (rather than relying on a poll tick). `.bufferingNewest(1)` means the
    /// wake survives even when raised before the consumer is awaiting.
    @Test func reconcileSignalWakesOnGrant() async {
        let lm = LockManager()
        let stream = await lm.reconcileSignals()
        let counter = SignalCounter()
        let consumer = Task { for await _ in stream { await counter.bump() } }
        #expect(await lm.acquire(sid("a"), domain: dev, files: ["x"]) == .granted)
        await waitUntil { await counter.count >= 1 }
        #expect(await counter.count >= 1)
        consumer.cancel()
    }

    // MARK: Chat-log lifecycle events (host hook)

    /// The lock-event hook feeds the host the full contention story with exact paths: an immediate
    /// grant emits `.acquired`; a blocked acquire emits `.waiting` then `.acquired` once granted on
    /// release; and each release emits `.released`. The hook is fire-and-forget, so we poll.
    @Test func lockEventHookReportsWaitAcquireRelease() async {
        let lm = LockManager()
        let rec = LockEventRecorder()
        await lm.setLockEventHandler { s, e in await rec.add(s.rawValue, e) }

        // Immediate grant → one `.acquired` for the holder, no `.waiting`.
        #expect(await lm.acquire(sid("a"), domain: dev, files: ["x"]) == .granted)
        await waitUntil { await rec.snapshot().contains { $0.0 == "a" && $0.1 == .acquired(["x"]) } }

        // b blocks on the held file → `.waiting`, then `.acquired` once a releases.
        let asker = Task { await lm.acquire(sid("b"), domain: dev, files: ["x"]) }
        await waitUntil { await rec.snapshot().contains { $0.0 == "b" && $0.1 == .waiting(["x"]) } }
        await lm.release(sid("a"), paths: ["x"])
        #expect(await asker.value == .granted)
        await waitUntil { await rec.snapshot().contains { $0.0 == "b" && $0.1 == .acquired(["x"]) } }
        await waitUntil { await rec.snapshot().contains { $0.0 == "a" && $0.1 == .released(["x"]) } }

        // releaseAll reports everything b holds.
        await lm.releaseAll(sid("b"))
        await waitUntil { await rec.snapshot().contains { $0.0 == "b" && $0.1 == .released(["x"]) } }

        let events = await rec.snapshot()
        #expect(events.contains { $0.0 == "a" && $0.1 == .acquired(["x"]) })
        #expect(events.contains { $0.0 == "b" && $0.1 == .waiting(["x"]) })
        #expect(events.contains { $0.0 == "b" && $0.1 == .acquired(["x"]) })
        #expect(events.contains { $0.0 == "a" && $0.1 == .released(["x"]) })
        #expect(events.contains { $0.0 == "b" && $0.1 == .released(["x"]) })
    }
}

/// Counts wake-ups observed on a `reconcileSignals()` stream, for `reconcileSignalWakesOnGrant`.
private actor SignalCounter {
    private(set) var count = 0
    func bump() { count += 1 }
}
