import Foundation
import Testing

@testable import NucleicCore

@Suite("Conflict detection")
struct ConflictDetectorTests {
    private func work(_ id: String, _ task: String, files: [String]) -> ActiveWork {
        ActiveWork(sessionID: SessionID(rawValue: id), title: id, task: task, changedFiles: files)
    }

    @Test func exactFileOverlapIsAConflict() {
        let candidate = ConflictCandidate(task: "tweak login copy", files: ["src/auth/Login.swift"])
        let active = [work("a", "auth refactor", files: ["src/auth/Login.swift", "README.md"])]
        let matches = ConflictDetector.detect(candidate, against: active)
        #expect(matches.count == 1)
        #expect(matches.first?.sessionID == SessionID(rawValue: "a"))
        #expect(matches.first?.sharedPaths == ["src/auth/Login.swift"])
    }

    @Test func directoryPrefixOverlapIsAConflict() {
        // Candidate names a directory; another session edits a file inside it.
        let candidate = ConflictCandidate(task: "rework auth", files: ["src/auth"])
        let active = [work("a", "fix login", files: ["src/auth/Login.swift"])]
        let matches = ConflictDetector.detect(candidate, against: active)
        #expect(matches.count == 1)
        // The more specific path is reported as the shared path.
        #expect(matches.first?.sharedPaths == ["src/auth/Login.swift"])
    }

    @Test func siblingDirectoriesDoNotOverlap() {
        // `src` must not match `src2/...` — componentwise comparison, not substring.
        let candidate = ConflictCandidate(task: "x", files: ["src"])
        let active = [work("a", "y", files: ["src2/Thing.swift"])]
        #expect(ConflictDetector.detect(candidate, against: active).isEmpty)
    }

    @Test func normalizesLeadingDotSlashAndTrailingSlash() {
        let candidate = ConflictCandidate(task: "x", files: ["./src/auth/"])
        let active = [work("a", "y", files: ["src/auth/Login.swift"])]
        #expect(ConflictDetector.detect(candidate, against: active).count == 1)
    }

    // The allocation-free byte-wise rewrite of `pathsOverlap` must preserve the old
    // `split(separator: "/")` semantics exactly — including the odd corners.
    @Test func pathsOverlapSemantics() {
        // Same file / ancestor-descendant.
        #expect(ConflictDetector.pathsOverlap("a/b/c", "a/b/c"))
        #expect(ConflictDetector.pathsOverlap("a/b", "a/b/c"))
        #expect(ConflictDetector.pathsOverlap("a/b/c", "a"))
        // Component boundaries: `src` never matches `src2/x`, `a/bc` never matches `a/b`.
        #expect(!ConflictDetector.pathsOverlap("src", "src2/x"))
        #expect(!ConflictDetector.pathsOverlap("src2", "src/x"))
        #expect(!ConflictDetector.pathsOverlap("a/bc", "a/b"))
        #expect(!ConflictDetector.pathsOverlap("a/b", "a/bc/d"))
        // Siblings.
        #expect(!ConflictDetector.pathsOverlap("a/b/c", "a/b/d"))
        // Empty components from repeated slashes are skipped, like `split` did.
        #expect(ConflictDetector.pathsOverlap("a//b", "a/b/c"))
        #expect(ConflictDetector.pathsOverlap("a/b/", "a/b/c"))
        #expect(ConflictDetector.pathsOverlap("/a/b", "a/b"))
        // Empty inputs never overlap; an all-slash path has zero components (universal prefix).
        #expect(!ConflictDetector.pathsOverlap("", "a"))
        #expect(!ConflictDetector.pathsOverlap("a", ""))
        #expect(ConflictDetector.pathsOverlap("/", "a/b"))
        // Unicode component content still compares correctly.
        #expect(ConflictDetector.pathsOverlap("docs/日本語", "docs/日本語/x"))
        #expect(!ConflictDetector.pathsOverlap("docs/日本語", "docs/日本語x"))
    }

    // `AppStore.divergentHeldPaths` must answer exactly like the brute-force
    // `held × unmerged` `pathsOverlap` scan it replaced (the main-thread-hang fix).
    @Test func divergentHeldPathsMatchesBruteForce() {
        var seed: UInt64 = 0x5eed
        func rand(_ bound: Int) -> Int {
            seed = seed &* 6364136223846793005 &+ 1442695040888963407
            return Int(seed >> 33) % bound
        }
        let components = ["a", "b", "src", "src2", "docs", "sub", "x.swift", "y", "deep"]
        func randomPath() -> String {
            let depth = 1 + rand(4)
            var parts: [String] = []
            for _ in 0..<depth { parts.append(components[rand(components.count)]) }
            var p = parts.joined(separator: "/")
            if rand(8) == 0 { p = "./" + p }
            if rand(8) == 0 { p += "/" }
            if rand(8) == 0 { p = p.replacingOccurrences(of: "/", with: "//") }
            return p
        }
        for _ in 0..<50 {
            let held = Set((0..<(1 + rand(12))).map { _ in randomPath() })
            let unmerged = Set((0..<(1 + rand(30))).map { _ in randomPath() })
            let fast = AppStore.divergentHeldPaths(held: held, unmerged: unmerged)
            let brute = held.filter { h in
                unmerged.contains { ConflictDetector.pathsOverlap(h, $0) }
            }
            #expect(fast == brute)
        }
        // Degenerate corners the fuzz loop is unlikely to hit.
        #expect(AppStore.divergentHeldPaths(held: [], unmerged: ["a"]).isEmpty)
        #expect(AppStore.divergentHeldPaths(held: ["a"], unmerged: []).isEmpty)
        #expect(AppStore.divergentHeldPaths(held: ["a/b"], unmerged: ["/"]) == ["a/b"])
        #expect(AppStore.divergentHeldPaths(held: ["/"], unmerged: ["a/b"]) == ["/"])
        #expect(AppStore.divergentHeldPaths(held: [""], unmerged: ["a"]).isEmpty)
    }

    @Test func noOverlapYieldsNoMatches() {
        let candidate = ConflictCandidate(task: "edit docs", files: ["docs/README.md"])
        let active = [work("a", "auth refactor", files: ["src/auth/Login.swift"])]
        #expect(ConflictDetector.detect(candidate, against: active).isEmpty)
    }

    @Test func intentOverlapFlagsWhenNoFilesGiven() {
        // No files to compare, but the task descriptions strongly overlap.
        let candidate = ConflictCandidate(task: "redesign the billing subscription flow", files: [])
        let active = [work("a", "rework billing subscription invoices", files: [])]
        let matches = ConflictDetector.detect(candidate, against: active)
        #expect(matches.count == 1)
        #expect(matches.first?.sharedPaths.isEmpty == true)
    }

    @Test func unrelatedTasksDoNotIntentMatch() {
        let candidate = ConflictCandidate(task: "add a dark mode toggle", files: [])
        let active = [work("a", "optimize database query performance", files: [])]
        #expect(ConflictDetector.detect(candidate, against: active).isEmpty)
    }

    @Test func detectsAcrossMultipleSessions() {
        let candidate = ConflictCandidate(
            task: "x", files: ["src/auth/Login.swift", "src/ui/Home.swift"])
        let active = [
            work("a", "auth", files: ["src/auth/Login.swift"]),
            work("b", "docs", files: ["docs/guide.md"]),
            work("c", "home view", files: ["src/ui/Home.swift"]),
        ]
        let ids = Set(ConflictDetector.detect(candidate, against: active).map(\.sessionID))
        #expect(ids == [SessionID(rawValue: "a"), SessionID(rawValue: "c")])
    }
}

@Suite("Conflict coordinator")
struct ConflictCoordinatorTests {
    @Test func failsOpenWithoutArbiter() async {
        let coordinator = ConflictCoordinator()
        let resolution = await coordinator.arbitrate(
            sessionID: SessionID(rawValue: "s"), task: "anything", files: ["a.swift"])
        #expect(resolution.resolution == .noConflict)
    }

    @Test @MainActor func forwardsToArbiter() async {
        let coordinator = ConflictCoordinator()
        let arbiter = StubArbiter(resolution: .deferred)
        await coordinator.setArbiter(arbiter)
        let resolution = await coordinator.arbitrate(
            sessionID: SessionID(rawValue: "s"), task: "t", files: [])
        #expect(resolution.resolution == .deferred)
    }

    // MARK: Raw-shell writes (docs/NASH.md §5.2 → LOCKING §4)

    @Test @MainActor func shellWriteAcquiresLocksAndClears() async {
        // The point of the feature: `echo … > f` is no longer refused — it acquires the same lock
        // an `Edit` of that file would, resolved against the run's workdir, and then runs.
        let coordinator = ConflictCoordinator()
        let arbiter = StubArbiter(resolution: .noConflict)
        await coordinator.setArbiter(arbiter)
        let clearance = await coordinator.arbitrateShellWrites(
            sessionID: SessionID(rawValue: "s"),
            command: "cd Sources && echo 'x' > Foo.swift", workdir: "/w/repo", toolUseID: "t1")
        #expect(clearance == .clear)
        #expect(arbiter.arbitrated == [["/w/repo/Sources/Foo.swift"]])
    }

    @Test @MainActor func shellWriteDeniesOnConflict() async {
        let coordinator = ConflictCoordinator()
        let arbiter = StubArbiter(resolution: .deferred)  // held weakly — keep it alive here
        await coordinator.setArbiter(arbiter)
        let clearance = await coordinator.arbitrateShellWrites(
            sessionID: SessionID(rawValue: "s"), command: "echo x > Foo.swift",
            workdir: "/w/repo", toolUseID: nil)
        guard case .deny(let reason) = clearance else { Issue.record("expected a denial"); return }
        #expect(reason.contains("Foo.swift"))
        #expect(reason.contains("deferred"))
    }

    @Test @MainActor func shellWriteRelaysTheRegroundNotice() async {
        // Update-on-grant pulled a parent change into the file this command overwrites: block the
        // command once with the notice, exactly as the edit gate does (LOCKING §4.5).
        let coordinator = ConflictCoordinator()
        let arbiter = StubArbiter(resolution: .proceed, refreshNotice: "re-read Foo.swift")
        await coordinator.setArbiter(arbiter)  // held weakly — keep it alive here
        let clearance = await coordinator.arbitrateShellWrites(
            sessionID: SessionID(rawValue: "s"), command: "echo x > Foo.swift",
            workdir: "/w/repo", toolUseID: nil)
        #expect(clearance == .deny("re-read Foo.swift"))
    }

    @Test @MainActor func shellWriteSkipsArbitrationForNonWrites() async {
        let coordinator = ConflictCoordinator()
        let arbiter = StubArbiter(resolution: .deferred)  // would deny if it were consulted
        await coordinator.setArbiter(arbiter)
        for command in ["swift build", "make > build.log", "echo hi > /dev/null", "git status"] {
            #expect(await coordinator.arbitrateShellWrites(
                sessionID: SessionID(rawValue: "s"), command: command,
                workdir: "/w/repo", toolUseID: nil) == .clear)
        }
        #expect(arbiter.arbitrated.isEmpty)
    }

    @Test @MainActor func shellWriteRefusesAnUnlockableTarget() async {
        // A target we can't name can't be locked, so it is refused before the arbiter is consulted.
        let coordinator = ConflictCoordinator()
        let arbiter = StubArbiter(resolution: .noConflict)
        await coordinator.setArbiter(arbiter)
        let clearance = await coordinator.arbitrateShellWrites(
            sessionID: SessionID(rawValue: "s"), command: "patch -p1 < changes.diff",
            workdir: "/w/repo", toolUseID: nil)
        guard case .deny = clearance else { Issue.record("expected a denial"); return }
        #expect(arbiter.arbitrated.isEmpty)
    }

    @Test func shellWriteFailsOpenWithoutArbiter() async {
        // No arbiter (tests / early startup): nothing to lock, so a nameable write proceeds — but
        // the unlockable-target refusal is a rule about the command and still applies.
        let coordinator = ConflictCoordinator()
        #expect(await coordinator.arbitrateShellWrites(
            sessionID: SessionID(rawValue: "s"), command: "echo x > Foo.swift",
            workdir: "/w/repo", toolUseID: nil) == .clear)
        guard case .deny = await coordinator.arbitrateShellWrites(
            sessionID: SessionID(rawValue: "s"), command: "patch -p1 < changes.diff",
            workdir: "/w/repo", toolUseID: nil)
        else { Issue.record("expected a denial"); return }
    }

    @Test func hostCommandFailsOpenWithoutArbiter() async {
        // No arbiter attached → host-command coordination must not block a command (HOST_EXEC).
        let coordinator = ConflictCoordinator()
        let clearance = await coordinator.beginHostCommand(
            sessionID: SessionID(rawValue: "s"), command: "swift build", override: false)
        #expect(clearance == .clear)
        await coordinator.endHostCommand(sessionID: SessionID(rawValue: "s"))  // no-op, must not crash
    }

    @Test func queuedHostCommandFailsOpenWithoutArbiter() async {
        // No arbiter attached → a queued waiter must never be trapped: claim fails open to .clear,
        // and enqueue/dequeue are harmless no-ops (HOST_EXEC §concurrency).
        let coordinator = ConflictCoordinator()
        let s = SessionID(rawValue: "s")
        await coordinator.enqueueHostCommandWait(sessionID: s, command: "swift build")  // no-op
        let clearance = await coordinator.claimQueuedHostCommand(sessionID: s, command: "swift build")
        #expect(clearance == .clear)
        await coordinator.dequeueHostCommandWait(sessionID: s)  // no-op, must not crash
    }
}

/// Minimal arbiter for the coordinator forwarding test.
@MainActor
private final class StubArbiter: ConflictArbiter {
    let resolution: ConflictResolution
    let refreshNotice: String?
    /// Every path set handed to `arbitrate`, so a caller's path derivation can be asserted.
    private(set) var arbitrated: [[String]] = []
    init(resolution: ConflictResolution, refreshNotice: String? = nil) {
        self.resolution = resolution
        self.refreshNotice = refreshNotice
    }
    func arbitrate(sessionID: SessionID, task: String, files: [String], toolUseID: String?) async -> ArbitrationOutcome {
        arbitrated.append(files)
        return ArbitrationOutcome(resolution, refreshNotice: refreshNotice)
    }
    func observeGitOp(sessionID: SessionID, argv: [String], exitCode: Int) async {}
    func observeGhOp(sessionID: SessionID, argv: [String], exitCode: Int) async {}
    func observeCommand(sessionID: SessionID, call: MCPApprovalServer.CommandReportCall) async {}
    func observeShellEvents(sessionID: SessionID, calls: [MCPApprovalServer.ShellReportCall]) async {}
    func beginHostCommand(sessionID: SessionID, command: String, override: Bool) async -> HostCommandClearance {
        .clear
    }
    func endHostCommand(sessionID: SessionID) async {}
    func enqueueHostCommandWait(sessionID: SessionID, command: String) async {}
    func claimQueuedHostCommand(sessionID: SessionID, command: String) async -> HostCommandClearance {
        .clear
    }
    func dequeueHostCommandWait(sessionID: SessionID) async {}
}
