import Foundation
import Testing

@testable import NucleicCore

@Suite("Host-command conflict detection")
struct HostCommandConflictTests {
    private func sim(_ a: String, _ b: String) -> Bool {
        HostCommandConflictDetector.similarity(a, b) != nil
    }

    // MARK: - similarity()

    @Test func identicalCommandsConflict() {
        #expect(sim("swift build", "swift build"))
    }

    @Test func normalizesWhitespaceAndCase() {
        // Trivial formatting/case differences must not defeat the match.
        #expect(sim("SWIFT   build", "swift build"))
        #expect(sim("  make  ", "make"))
    }

    @Test func anyTwoBuildsConflict() {
        // Within a project, ANY two builds contend (same or different toolchain): they share the
        // toolchain and, for nvrsion sessions, the same trunk build directory. Project scoping lives
        // in `conflicts`; at the command level every build pair matches.
        #expect(sim("swift build", "swift test"))
        #expect(sim("make", "make test"))
        #expect(sim("swift build", "swift build && swift test"))
        #expect(sim("swift build", "npm run build"))
        #expect(sim("cargo build", "go build"))
    }

    @Test func nearIdenticalNonBuildCommandsConflict() {
        // Outside the modelled build category, only a near-identical command (same op, minor
        // arg/flag differences) counts — caught by the token-set Jaccard net.
        #expect(sim("python run.py --fast", "python run.py"))
    }

    @Test func unrelatedReadsDoNotConflict() {
        #expect(!sim("cat a.txt", "cat b.txt"))
        #expect(!sim("ls src", "grep TODO src"))
    }

    @Test func emptyCommandsNeverConflict() {
        #expect(!sim("", "swift build"))
        #expect(!sim("   ", "   "))
    }

    // MARK: - conflicts()

    private let projectP = ProjectID(rawValue: "p")

    private func running(_ id: String, _ command: String, project: String = "p") -> RunningHostCommand {
        RunningHostCommand(
            sessionID: SessionID(rawValue: id), projectID: ProjectID(rawValue: project),
            title: "Session \(id)", command: command, startedAt: Date(timeIntervalSince1970: 0))
    }

    @Test func conflictsExcludesOwnSession() {
        // A session never conflicts with its own running entry.
        let running = [running("self", "swift build")]
        let hits = HostCommandConflictDetector.conflicts(
            command: "swift build", sessionID: SessionID(rawValue: "self"), projectID: projectP,
            against: running)
        #expect(hits.isEmpty)
    }

    @Test func conflictsFindsSimilarRunningCommandAndNamesSession() {
        let running = [
            running("a", "swift build"),
            running("b", "cat notes.md"),
            running("c", "go build"),  // different toolchain, same project → still a build conflict
        ]
        let hits = HostCommandConflictDetector.conflicts(
            command: "swift build", sessionID: SessionID(rawValue: "me"), projectID: projectP,
            against: running)
        let ids = Set(hits.map(\.sessionID))
        #expect(ids == [SessionID(rawValue: "a"), SessionID(rawValue: "c")])
        // Each conflict carries the other session's title + command for the in-chat prompt.
        let a = hits.first { $0.sessionID == SessionID(rawValue: "a") }
        #expect(a?.sessionTitle == "Session a")
        #expect(a?.command == "swift build")
        #expect(a?.reason.isEmpty == false)
    }

    @Test func conflictsScopedToSameProject() {
        // A build in another project shares only host CPU, which we tolerate — not a conflict.
        let running = [running("other", "swift build", project: "q")]
        let hits = HostCommandConflictDetector.conflicts(
            command: "swift build", sessionID: SessionID(rawValue: "me"), projectID: projectP,
            against: running)
        #expect(hits.isEmpty)
    }

    @Test func conflictsEmptyWhenNothingSimilarRuns() {
        let running = [running("a", "cat a.txt"), running("b", "ls src")]
        let hits = HostCommandConflictDetector.conflicts(
            command: "swift build", sessionID: SessionID(rawValue: "me"), projectID: projectP,
            against: running)
        #expect(hits.isEmpty)
    }

    // MARK: - queuedWaiterMayClaim() (FIFO wait)

    private func queued(_ id: String, _ command: String, project: String = "p") -> QueuedHostCommand {
        QueuedHostCommand(
            sessionID: SessionID(rawValue: id), projectID: ProjectID(rawValue: project),
            command: command, enqueuedAt: Date(timeIntervalSince1970: 0))
    }

    private func mayClaim(
        _ id: String, _ command: String,
        running: [RunningHostCommand], queue: [QueuedHostCommand], project: String = "p"
    ) -> Bool {
        HostCommandConflictDetector.queuedWaiterMayClaim(
            sessionID: SessionID(rawValue: id), command: command,
            projectID: ProjectID(rawValue: project), running: running, queue: queue)
    }

    @Test func waiterBlockedWhileTwinStillRunning() {
        // The whole point of Wait: don't claim while the conflicting command is still on the host.
        let running = [running("a", "swift build")]
        let queue = [queued("me", "swift test")]
        #expect(!mayClaim("me", "swift test", running: running, queue: queue))
    }

    @Test func waiterClaimsOnceTwinFinishes() {
        // Twin gone from the running set → the sole waiter may claim.
        let queue = [queued("me", "swift test")]
        #expect(mayClaim("me", "swift test", running: [], queue: queue))
    }

    @Test func waitersProceedInFifoOrder() {
        // Two contending builds both waiting, nothing running: only the front of the queue may claim;
        // the one behind holds until the leader has taken (and later released) the slot.
        let queue = [queued("first", "swift build"), queued("second", "cargo build")]
        #expect(mayClaim("first", "swift build", running: [], queue: queue))
        #expect(!mayClaim("second", "cargo build", running: [], queue: queue))
    }

    @Test func independentWaiterNotHeldBehindContendingLeader() {
        // A non-contending waiter (a plain read) behind a queued build isn't blocked by it — it
        // contends with neither the running set nor the earlier waiter, so it may claim right away.
        let queue = [queued("builder", "swift build"), queued("reader", "cat notes.md")]
        #expect(mayClaim("reader", "cat notes.md", running: [], queue: queue))
    }

    @Test func waiterIgnoresEarlierWaiterInOtherProject() {
        // FIFO fairness is per-project: an earlier waiter building in project q doesn't gate a
        // waiter in project p (their build dirs are independent — only host CPU is shared).
        let queue = [
            queued("other", "swift build", project: "q"),
            queued("me", "swift build", project: "p"),
        ]
        #expect(mayClaim("me", "swift build", running: [], queue: queue, project: "p"))
    }
}
