import Foundation
import Testing

@testable import NucleicCore

/// Regression: a chat must never release a file lock on work that has NOT landed in its parent
/// (LOCKING invariant 4 — "release only on merge-into-parent … never on a bare commit").
///
/// The reported integrity failure came from `AppStore.reconcileLocks`'s lifecycle branch, which
/// called `lockManager.releaseAll(sid)` for any holder whose `session.status.isTerminal` — with no
/// landing check at all. `SessionStatus.error` is terminal, and a conversational chat reaches it
/// whenever a turn ends `runFinished(.errored)` (an API/CLI failure, a crashed tool, a rate-limit
/// abort). That chat is *not* over: the user replies and it keeps working from the very worktree
/// whose locks had just been dropped, while a sibling was free to take those files and diverge.
/// `.error`/`.interrupted` are now treated as resumable and fall through to the content-verified
/// landed check, with the idle backstop still reaping a genuinely abandoned chat.
@MainActor
@Suite("Lock lifecycle", .serialized, .isolatedContainerSettings)
struct LockLifecycleLeakTests {
    private func waitForAsync(_ condition: @escaping () async -> Bool, timeoutMs: Int = 3000) async {
        var elapsed = 0
        while await !condition() && elapsed < timeoutMs {
            try? await Task.sleep(for: .milliseconds(10))
            elapsed += 10
        }
    }
    private func waitFor(_ condition: @escaping () -> Bool, timeoutMs: Int = 3000) async {
        var elapsed = 0
        while !condition() && elapsed < timeoutMs {
            try? await Task.sleep(for: .milliseconds(10))
            elapsed += 10
        }
    }

    /// A store whose scripted agent just completes a turn without touching files — these tests
    /// drive the worktree themselves so they control exactly what git sees.
    private func makeStore(repo: GitTestRepo) -> AppStore {
        let now: @Sendable () -> Date = { Date(timeIntervalSince1970: 1_700_000_000) }
        let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
            .appendingPathComponent("transcripts"))
        return AppStore(
            database: try! GRDBMetadataStore(path: nil),
            worktrees: GitWorktreeManager(now: now),
            transcriptsDir: transcriptsDir, now: now
        ) { session in
            let wtPath = session.worktreePath ?? ""
            return ScriptedBackend { e, _ in
                e.emit(.sessionStarted(SessionStarted(
                    backendSessionID: "be", model: "m", cwd: wtPath, toolNames: ["Write"])))
                e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
                e.emit(.runFinished(RunFinished(outcome: .completed)))
            }
        }
    }

    /// An errored turn must NOT drop locks on work that is still unmerged — the chat is resumable
    /// and its dirty edits are still on disk.
    @Test func erroredTurnKeepsLocksOnUnmergedWork() async throws {
        let repo = try await GitTestRepo(controlled: true)  // locking applies only to Control projects
        defer { repo.cleanup() }
        let now: @Sendable () -> Date = { Date(timeIntervalSince1970: 1_700_000_000) }
        let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
            .appendingPathComponent("transcripts"))
        // Turn 1 writes made.txt and completes. Turn 2 (driven by a follow-up message) ERRORS.
        let store = AppStore(
            database: try GRDBMetadataStore(path: nil),
            worktrees: GitWorktreeManager(now: now),
            transcriptsDir: transcriptsDir, now: now
        ) { session in
            let wtPath = session.worktreePath ?? ""
            // `resume` re-runs the body per turn, so count turns: turn 1 writes and completes,
            // turn 2 blows up — the ordinary "the CLI/API fell over" case.
            let turn = LockedBox(0)
            return ScriptedBackend(multiTurn: { e, _, _ in
                turn.set(turn.get() + 1)
                e.emit(.sessionStarted(SessionStarted(
                    backendSessionID: "be", model: "m", cwd: wtPath, toolNames: ["Write"])))
                guard turn.get() == 1 else {
                    e.emit(.runFinished(RunFinished(outcome: .errored)))
                    return
                }
                try? "by agent\n".write(
                    toFile: (wtPath as NSString).appendingPathComponent("made.txt"),
                    atomically: true, encoding: .utf8)
                e.emit(.fileChange(FileChange(path: "made.txt", kind: .add)))
                e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
                e.emit(.runFinished(RunFinished(outcome: .completed)))
            })
        }
        store.lockReconcileInterval = .milliseconds(20)
        let project = await store.addProject(
            name: "demo", rootPath: repo.root, defaultBranch: "main")!

        let doer = try await store.createSession(in: project, title: "doer", prompt: "go")
        let sibling = try await store.createSession(in: project, title: "sibling", prompt: "go")
        await waitFor {
            store.summaries.first { $0.id == doer }?.status == .awaitingInput
                && store.summaries.first { $0.id == sibling }?.status == .awaitingInput
        }

        // The doer holds made.txt, and that work genuinely has NOT landed in its parent.
        #expect(await store.arbitrate(
            sessionID: doer, task: "edit made", files: ["made.txt"]).resolution == .proceed)
        store.openSessionID = doer
        await waitFor { store.openSession?.id == doer && store.openSession?.worktreePath != nil }
        let worktree = try #require(store.openSession?.worktreePath)
        /// What `unmergedFiles` measures: the worktree's made.txt differs from the parent — here as
        /// an untracked add, which `git diff` doesn't list, so ask `status` (as `unmergedFiles`
        /// does by unioning in `untrackedPaths`).
        func stillUnmerged() async throws -> Bool {
            try await repo.git.checked(
                ["status", "--porcelain", "--", "made.txt"], in: worktree).contains("made.txt")
        }
        #expect(try await stillUnmerged())  // precondition: the work has NOT landed

        // The poll leaves it alone while the chat is alive — the correct behavior.
        try? await Task.sleep(for: .milliseconds(200))
        #expect(await store.lockQueueSnapshot().files.map(\.path) == ["made.txt"])

        // The chat's next turn errors out. Nothing has merged; the worktree is untouched.
        // `sendToSession` can await the turn, so don't block this test body on it.
        Task { await store.sendToSession(doer, "keep going") }
        await waitFor(
            { store.summaries.first { $0.id == doer }?.status == .error }, timeoutMs: 8000)
        #expect(store.summaries.first { $0.id == doer }?.status == .error)  // reached .error

        // The work is STILL unmerged …
        #expect(try await stillUnmerged())

        // … so the lock must survive. Give the reconcile poll (20 ms here) many passes to get it
        // wrong before asserting, so this fails loudly on a regression rather than racing past it.
        await waitForAsync({ await store.lockQueueSnapshot().files.isEmpty })
        let snapshot = await store.lockQueueSnapshot()
        #expect(
            snapshot.files.map(\.path) == ["made.txt"],
            "INTEGRITY VIOLATION: made.txt is unmerged, yet its lock was released because the errored turn made the session status terminal.")
        // …and it must still be held by the doer, so a sibling is still excluded from it.
        #expect(
            snapshot.files.first { $0.path == "made.txt" }?.holders
                .contains { $0.sessionID == doer } == true)
        #expect(await store.lockQueueSnapshot().files.first { $0.path == "made.txt" }?.holders
            .contains { $0.sessionID == sibling } != true)
    }

    /// Regression: the turn-end sweep must not reap a *live* turn's pre-write locks.
    ///
    /// The sweep runs from a detached classify task that awaits a seconds-long on-device
    /// classification, so it can land after a queued follow-up turn has already started. That
    /// turn's edit gate acquires a path BEFORE the CLI writes it (LOCKING §4.5), so the path is
    /// held and not-yet-divergent — indistinguishable from the ended turn's never-written
    /// declaration. Reaping it hands a sibling a file mid-write. The sweep now fails closed when a
    /// turn is in flight.
    @Test func turnEndSweepSkipsWhileANewTurnIsInFlight() async throws {
        let repo = try await GitTestRepo(controlled: true)
        defer { repo.cleanup() }
        let now: @Sendable () -> Date = { Date(timeIntervalSince1970: 1_700_000_000) }
        let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
            .appendingPathComponent("transcripts"))
        // Turn 1 completes normally. Turn 2 parks mid-turn (never emits `runFinished`), standing in
        // for a live turn that has declared a file and not yet written it.
        let started = LockedBox(false)
        let store = AppStore(
            database: try GRDBMetadataStore(path: nil),
            worktrees: GitWorktreeManager(now: now),
            transcriptsDir: transcriptsDir, now: now
        ) { session in
            let wtPath = session.worktreePath ?? ""
            let turn = LockedBox(0)
            return ScriptedBackend(multiTurn: { e, _, _ in
                turn.set(turn.get() + 1)
                e.emit(.sessionStarted(SessionStarted(
                    backendSessionID: "be", model: "m", cwd: wtPath, toolNames: ["Write"])))
                guard turn.get() == 1 else {
                    started.set(true)
                    e.emit(.assistantText(TextChunk(
                        messageID: "a2", text: "working…", isPartial: false)))
                    try? await Task.sleep(for: .seconds(30))  // park: turn stays in flight
                    return
                }
                e.emit(.assistantText(TextChunk(
                    messageID: "a1", text: "Done with the first pass.", isPartial: false)))
                e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
                e.emit(.runFinished(RunFinished(outcome: .completed)))
            })
        }
        store.lockReconcileInterval = .seconds(3600)  // isolate the sweep from the poll
        let project = await store.addProject(
            name: "demo", rootPath: repo.root, defaultBranch: "main")!
        let doer = try await store.createSession(in: project, title: "doer", prompt: "go")
        await waitFor { store.summaries.first { $0.id == doer }?.status == .awaitingInput }

        // Turn 2 starts and is in flight.
        Task { await store.sendToSession(doer, "keep going") }
        await waitFor({ started.get() }, timeoutMs: 8000)
        await waitFor {
            store.summaries.first { $0.id == doer }?.status.hasTurnInFlight == true
        }

        // Its edit gate declares phantom.txt and takes the lock — before any write, so the file
        // matches the parent and looks exactly like a never-written declaration.
        #expect(await store.arbitrate(
            sessionID: doer, task: "edit phantom", files: ["phantom.txt"]).resolution == .proceed)
        #expect(await store.lockQueueSnapshot().files.map(\.path) == ["phantom.txt"])

        // A sweep for the PREVIOUS turn now lands late. It must not touch the live turn's lock.
        await store.releaseCompletedSessionLocks(doer)
        #expect(
            await store.lockQueueSnapshot().files.map(\.path) == ["phantom.txt"],
            "INTEGRITY VIOLATION: a stale turn-end sweep reaped a live turn's pre-write lock.")
    }

    /// Regression: "no longer differs from the parent" is not the same as "landed".
    ///
    /// `git stash` (like `reset --hard`, `restore`, `checkout <parent> -- <path>`) parks the work
    /// and leaves the worktree matching the parent — which the old content-equality check read as
    /// a landing and released. Nothing merged: the parent never moved, and the agent can `stash
    /// pop` its work straight back onto a file a sibling has since been granted. Landing now
    /// additionally requires the PARENT's own blob for the path to have moved.
    @Test func stashedWorkIsNotMistakenForALanding() async throws {
        let repo = try await GitTestRepo(controlled: true)
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        store.lockReconcileInterval = .milliseconds(20)
        let project = await store.addProject(
            name: "demo", rootPath: repo.root, defaultBranch: "main")!
        let doer = try await store.createSession(in: project, title: "doer", prompt: "go")
        await waitFor { store.summaries.first { $0.id == doer }?.status == .awaitingInput }
        store.openSessionID = doer
        await waitFor { store.openSession?.id == doer && store.openSession?.worktreePath != nil }
        let worktree = try #require(store.openSession?.worktreePath)

        // The agent edits a file that EXISTS in the parent, and commits it — so the change is
        // stashable and the parent's blob for it is well-defined.
        try repo.write("tracked.txt", "original\n")
        try await repo.run(["add", "-A"])
        try await repo.run(["commit", "-m", "add tracked.txt"])
        try await repo.run(["merge", "--no-edit", "main"], in: worktree)
        try repo.write("tracked.txt", "by agent\n", in: worktree)
        try await repo.run(["add", "-A"], in: worktree)
        try await repo.run(["commit", "-m", "agent edit"], in: worktree)

        #expect(await store.arbitrate(
            sessionID: doer, task: "edit tracked", files: ["tracked.txt"]).resolution == .proceed)
        // Let the poll observe the divergence, so the path is armed as materialized.
        await waitForAsync({ await store.lockQueueSnapshot().files.isEmpty })
        #expect(await store.lockQueueSnapshot().files.map(\.path) == ["tracked.txt"])

        // The agent parks the work. The worktree now matches the parent — but the parent never
        // moved and nothing landed.
        try await repo.run(["reset", "--hard", "main"], in: worktree)
        #expect(
            try await repo.git.checked(
                ["diff", "--name-only", "main", "--"], in: worktree).contains("tracked.txt")
                == false)  // precondition: content equality now holds

        // Several poll passes must NOT release it.
        try? await Task.sleep(for: .milliseconds(300))
        #expect(
            await store.lockQueueSnapshot().files.map(\.path) == ["tracked.txt"],
            "INTEGRITY VIOLATION: a discarded/parked edit was read as a landing and the lock released.")
    }

    /// Regression (CARBON_SHARDING §17.5): the wire release verb must be authorized.
    ///
    /// `carbonLockRelease` took the session id straight off the frame and released, never reading
    /// the authenticated `deviceID`. Any control-scope peer could therefore free ANY session's
    /// locks — including a session running locally on this host, mid-edit — by naming its id.
    /// Release is now honored only from the device recorded as the session's origin, and a remote
    /// acquire can't claim that origin for a locally-running session either.
    @Test func wireLockReleaseIsRefusedFromANonOwningDevice() async throws {
        let repo = try await GitTestRepo(controlled: true)
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        store.lockReconcileInterval = .seconds(3600)  // isolate from the poll
        let project = await store.addProject(
            name: "demo", rootPath: repo.root, defaultBranch: "main")!
        let local = try await store.createSession(in: project, title: "local", prompt: "go")
        await waitFor { store.summaries.first { $0.id == local }?.status == .awaitingInput }
        store.openSessionID = local
        await waitFor { store.openSession?.id == local && store.openSession?.worktreePath != nil }
        let worktree = try #require(store.openSession?.worktreePath)
        // The real project UUID, so the refusal is proven to come from ownership — not from the
        // request naming the wrong project.
        let projectUUID = UUID(uuidString: project.id.rawValue)
            ?? CarbonService.derivedUUID(fromLegacyID: project.id.rawValue)

        // A locally-running chat is mid-edit on made.txt and holds its lock.
        try repo.write("made.txt", "by the local agent\n", in: worktree)
        #expect(await store.arbitrate(
            sessionID: local, task: "edit made", files: ["made.txt"]).resolution == .proceed)
        #expect(await store.lockQueueSnapshot().files.map(\.path) == ["made.txt"])

        func request(paths: [String]?) -> CarbonLockRequest {
            CarbonLockRequest(
                requestID: "r1", projectUUID: projectUUID, domainRef: "main",
                sessionID: local.rawValue, paths: paths, requiredHeads: nil)
        }

        // A hostile/confused peer names that session and asks for a release. Refused outright.
        await store.carbonLockRelease(request(paths: nil), from: "peer-device")
        #expect(
            await store.lockQueueSnapshot().files.map(\.path) == ["made.txt"],
            "INTEGRITY VIOLATION: a peer released a local session's lock over the wire.")
        await store.carbonLockRelease(request(paths: ["made.txt"]), from: "peer-device")
        #expect(await store.lockQueueSnapshot().files.map(\.path) == ["made.txt"])

        // Nor can the peer launder it by first claiming the session id via the acquire verb: a
        // session with a local controller is never a remote session.
        let claim = await store.carbonLockAcquire(
            request(paths: ["other.txt"]), from: "peer-device")
        #expect(claim == .cancelled)
        await store.carbonLockRelease(request(paths: nil), from: "peer-device")
        #expect(
            await store.lockQueueSnapshot().files.map(\.path) == ["made.txt"],
            "INTEGRITY VIOLATION: a peer claimed a local session id and then released its lock.")
    }

    /// Regression: a lock on a DIRECTORY must be held while unmerged work sits under it.
    ///
    /// `check_conflict` invites "files/areas", and the lock table blocks siblings on a directory
    /// via `pathsOverlap` — but `git --name-only` never emits a directory, so the release side's
    /// exact-string subtraction always classified it as nothing-to-protect and freed it, while the
    /// files beneath it were still unmerged.
    @Test func directoryLockIsHeldWhileWorkUnderItIsUnmerged() async throws {
        let repo = try await GitTestRepo(controlled: true)
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        store.lockReconcileInterval = .milliseconds(20)
        let project = await store.addProject(
            name: "demo", rootPath: repo.root, defaultBranch: "main")!
        let doer = try await store.createSession(in: project, title: "doer", prompt: "go")
        await waitFor { store.summaries.first { $0.id == doer }?.status == .awaitingInput }
        store.openSessionID = doer
        await waitFor { store.openSession?.id == doer && store.openSession?.worktreePath != nil }
        let worktree = try #require(store.openSession?.worktreePath)

        // The agent declares an AREA and writes a file inside it — genuinely unmerged work.
        try repo.write("Sources/Feature/impl.swift", "let x = 1\n", in: worktree)
        #expect(await store.arbitrate(
            sessionID: doer, task: "build the feature", files: ["Sources/Feature"]).resolution
            == .proceed)
        #expect(await store.lockQueueSnapshot().files.map(\.path) == ["Sources/Feature"])

        // Many poll passes, plus the turn-end sweep, must all leave it held.
        try? await Task.sleep(for: .milliseconds(300))
        await store.releaseCompletedSessionLocks(doer)
        #expect(
            await store.lockQueueSnapshot().files.map(\.path) == ["Sources/Feature"],
            "INTEGRITY VIOLATION: a directory lock was freed while unmerged work sat under it.")
    }

    /// Regression: renaming an edited file must not read as that file having landed.
    ///
    /// The footprint diffs ran with `--find-renames`, and `--name-only` prints only a rename's
    /// *destination* — so once git detected the rename the source path vanished from the unmerged
    /// set entirely, and the turn-end sweep freed its lock while the deletion was still unmerged,
    /// letting a sibling recreate the path.
    ///
    /// The file is deliberately long and the edit deliberately small: rename detection is a
    /// similarity heuristic, so a short file rewritten wholesale is reported as delete+add and the
    /// hole never opens. This is the shape that actually reproduces it.
    @Test func renamingAnEditedFileDoesNotReleaseTheSourcePathLock() async throws {
        let repo = try await GitTestRepo(controlled: true)
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        store.lockReconcileInterval = .milliseconds(20)
        let project = await store.addProject(
            name: "demo", rootPath: repo.root, defaultBranch: "main")!

        // A file that exists in the parent, long enough that a one-line edit leaves it ~95%
        // similar — so `--find-renames` really does detect the rename below.
        let body = (1...40).map { "let line\($0) = \($0)\n" }.joined()
        try repo.write("Old.swift", body)
        try await repo.run(["add", "-A"])
        try await repo.run(["commit", "-m", "add Old.swift"])

        let doer = try await store.createSession(in: project, title: "doer", prompt: "go")
        await waitFor { store.summaries.first { $0.id == doer }?.status == .awaitingInput }
        store.openSessionID = doer
        await waitFor { store.openSession?.id == doer && store.openSession?.worktreePath != nil }
        let worktree = try #require(store.openSession?.worktreePath)
        try await repo.run(["merge", "--no-edit", "main"], in: worktree)

        // The agent edits one line of Old.swift and takes the lock on it.
        try repo.write("Old.swift", body.replacingOccurrences(of: "let line7 = 7", with: "let line7 = 700"), in: worktree)
        #expect(await store.arbitrate(
            sessionID: doer, task: "edit", files: ["Old.swift"]).resolution == .proceed)
        await waitForAsync { await store.lockQueueSnapshot().files.contains { $0.path == "Old.swift" } }
        try await repo.run(["add", "-A"], in: worktree)
        try await repo.run(["commit", "-m", "edit line 7"], in: worktree)

        // Then it renames the file. Nothing has merged upward — main still has Old.swift.
        try await repo.run(["mv", "Old.swift", "New.swift"], in: worktree)
        try await repo.run(["commit", "-m", "rename"], in: worktree)
        // Sanity: git really does collapse this to a single rename, which is what hid the source
        // path. If this stops holding the test would pass for the wrong reason.
        let detected = try await repo.git.checked(
            ["diff", "--name-only", "--find-renames", "main...HEAD", "--"], in: worktree)
        #expect(!detected.contains("Old.swift"))
        #expect(detected.contains("New.swift"))

        // Both release paths must keep the source path locked: its deletion hasn't landed.
        await store.observeGitOp(sessionID: doer, argv: ["commit", "-m", "rename"], exitCode: 0)
        try? await Task.sleep(for: .milliseconds(200))
        await store.releaseCompletedSessionLocks(doer)
        #expect(
            await store.lockQueueSnapshot().files.contains { $0.path == "Old.swift" },
            "INTEGRITY VIOLATION: renaming an edited file released the source path's lock while its deletion was unmerged.")
    }
}
