import Foundation
import Testing

@testable import NucleicProtocol

@Suite struct MeshDispatchScorerTests {
    private let repo = ProjectDescriptor(
        projectID: ProjectID(rawValue: "p-local"), name: "demo",
        rootCommitSHA: "root-sha", normalizedRemote: "github.com/x/demo", defaultBranch: "main")

    private func presence(
        name: String, kind: PeerKind = .mac, accepts: Bool = true, active: Int = 0,
        cap: Int? = nil, backends: [BackendID] = [], holdsRepo: Bool = true,
        canCreate: Bool = false, projectID: String = UUID().uuidString
    ) -> RunnerPresence {
        RunnerPresence(
            hostName: name, kind: kind, canRunAgents: true, acceptsMeshDispatch: accepts,
            activeSessionCount: active, maxConcurrent: cap, backends: backends,
            projects: holdsRepo ? [ProjectDescriptor(
                projectID: ProjectID(rawValue: projectID), name: "demo",
                rootCommitSHA: "root-sha", normalizedRemote: "github.com/x/demo",
                defaultBranch: "main")] : [],
            canCreateProjects: canCreate,
            updatedAt: Date(timeIntervalSince1970: 1))
    }

    private func candidate(
        _ hostID: String, _ presence: RunnerPresence, live: Bool = true, acks: Bool = true,
        canTransfer: Bool = false
    ) -> MeshDispatchScorer.Candidate {
        MeshDispatchScorer.Candidate(
            hostID: hostID, presence: presence, live: live, canAcknowledgeDispatch: acks,
            canReceiveSessionTransfer: canTransfer)
    }

    @Test func ranksByLoadWithMacBiasAndDeterministicTiebreak() {
        let picks = MeshDispatchScorer.rank(
            candidates: [
                candidate("cloud-idle", presence(name: "runner", kind: .cloud, active: 0, cap: 8)),
                candidate("mac-idle", presence(name: "mac", active: 0)),
                candidate("mac-busy", presence(name: "busy", active: 7)),
            ],
            project: repo, backend: nil)
        // Idle Mac beats the idle cloud runner (near-tie kind bias) beats the busy Mac.
        #expect(picks.map(\.hostID) == ["mac-idle", "cloud-idle", "mac-busy"])
        // Deterministic id tiebreak between true equals.
        let tied = MeshDispatchScorer.rank(
            candidates: [
                candidate("bb", presence(name: "b")),
                candidate("aa", presence(name: "a")),
            ],
            project: repo, backend: nil)
        #expect(tied.map(\.hostID) == ["aa", "bb"])
    }

    @Test func eligibilityGatesFilterCandidates() {
        let base = presence(name: "m")
        let picks = MeshDispatchScorer.rank(
            candidates: [
                candidate("offline", base, live: false),
                candidate("no-ack", base, acks: false),
                candidate("declines", presence(name: "d", accepts: false)),
                candidate("full", presence(name: "f", active: 4, cap: 4)),
                candidate("no-repo", presence(name: "n", holdsRepo: false)),
                candidate("cooled", base),
                candidate("good", base),
            ],
            project: repo, backend: nil, excluding: ["cooled"])
        #expect(picks.map(\.hostID) == ["good"])
    }

    @Test func projectMatchByIdentityYieldsTheTargetsOwnProjectID() {
        // The peer added the same repo independently — different UUID, same root commit.
        let picks = MeshDispatchScorer.rank(
            candidates: [candidate("peer", presence(name: "p", projectID: "p-remote"))],
            project: repo, backend: nil)
        #expect(picks.first?.projectID == ProjectID(rawValue: "p-remote"))
    }

    @Test func clonersAreEligibleOnlyWithACloneURLAndRankAfterHolders() {
        let holder = candidate("holder", presence(name: "h", active: 5))  // busy holder
        let cloner = candidate("cloner", presence(name: "c", active: 0, holdsRepo: false, canCreate: true))
        // No clone URL ⇒ holders only, the cloner is invisible even though it's idle.
        #expect(MeshDispatchScorer.rank(
            candidates: [holder, cloner], project: repo, backend: nil).map(\.hostID) == ["holder"])
        // With a clone URL ⇒ the cloner joins, but always ranked AFTER the holder (a clone is a
        // cost); the cloner pick names no projectID (must clone first).
        let picks = MeshDispatchScorer.rank(
            candidates: [holder, cloner], project: repo, backend: nil,
            cloneURL: "https://github.com/x/demo.git")
        #expect(picks.map(\.hostID) == ["holder", "cloner"])
        #expect(picks.last?.mustClone == true)
        #expect(picks.first?.mustClone == false)
        // A non-holder WITHOUT canCreateProjects never becomes a cloner.
        let incapable = candidate("plain", presence(name: "p", holdsRepo: false, canCreate: false))
        #expect(MeshDispatchScorer.rank(
            candidates: [incapable], project: repo, backend: nil,
            cloneURL: "https://github.com/x/demo.git").isEmpty)
    }

    // MARK: - Between-turn rebalancing (Covalence)

    @Test func rebalanceMovesOnlyPastTheHysteresisMargin() {
        let current = presence(name: "owner", active: 2)  // load 0.25
        // One session better (0.125 apart) — inside the margin, stay put.
        #expect(MeshDispatchScorer.rebalanceTarget(
            current: current,
            candidates: [candidate("slightly-better", presence(name: "s", active: 1), canTransfer: true)],
            project: repo) == nil)
        // Two sessions better (0.25 apart) — beats the margin, move.
        let pick = MeshDispatchScorer.rebalanceTarget(
            current: current,
            candidates: [candidate("clearly-better", presence(name: "c", active: 0), canTransfer: true)],
            project: repo)
        #expect(pick?.hostID == "clearly-better")
        // An equally-loaded or busier host never attracts the session.
        #expect(MeshDispatchScorer.rebalanceTarget(
            current: current,
            candidates: [candidate("equal", presence(name: "e", active: 2), canTransfer: true)],
            project: repo) == nil)
    }

    @Test func rebalanceRequiresTransferCapabilityAndAHolder() {
        let current = presence(name: "owner", active: 4)  // load 0.5 — plenty of headroom below
        // Idle but can't import sessions ⇒ not a destination.
        #expect(MeshDispatchScorer.rebalanceTarget(
            current: current,
            candidates: [candidate("no-transfer", presence(name: "n", active: 0), canTransfer: false)],
            project: repo) == nil)
        // Idle + transfer-capable but doesn't hold the repo ⇒ never a cloner on rebalance.
        #expect(MeshDispatchScorer.rebalanceTarget(
            current: current,
            candidates: [candidate(
                "would-clone", presence(name: "w", active: 0, holdsRepo: false, canCreate: true),
                canTransfer: true)],
            project: repo) == nil)
    }

    @Test func rebalanceHonorsExclusions() {
        // The Covalence origin device (and the owner itself) is excluded by the caller — even a
        // far better host never attracts the session when excluded.
        let current = presence(name: "owner", active: 6)
        let idle = candidate("origin", presence(name: "o", active: 0), canTransfer: true)
        #expect(MeshDispatchScorer.rebalanceTarget(
            current: current, candidates: [idle], project: repo, excluding: ["origin"]) == nil)
    }

    @Test func backendFilterAppliesOnlyWhenReported() {
        let reportsClaude = presence(name: "c", backends: [.claudeCode])
        let reportsNothing = presence(name: "u", backends: [])
        // A candidate reporting its backends is filtered; one reporting none (unknown) passes.
        let picks = MeshDispatchScorer.rank(
            candidates: [candidate("claude-only", reportsClaude), candidate("unknown", reportsNothing)],
            project: repo, backend: .codex)
        #expect(picks.map(\.hostID) == ["unknown"])
    }
}
