import Foundation

/// Mesh dispatch — routing a new chat to "whatever runner fits" (mesh casting's
/// `runner.presence` state channel + the acknowledged `startChat` verb).
///
/// The dispatcher is the chooser: it ranks candidates locally and sends a directed
/// `startChat(requestID:)` to ONE host at a time — never a claim-race over casts. Convergence
/// contract: the target treats a repeated `requestID` as the same dispatch (acks the existing
/// session, starts nothing), so a timeout is retried once against the SAME candidate before
/// moving on, and at most one session exists per (candidate, requestID).
///
/// Pure and platform-neutral (lives here, not in NucleicCore) so the Mac cockpit and the
/// iPhone rank identically.
public enum MeshDispatchScorer {
    /// One rankable host: its presence card plus what only the dispatcher knows — whether it
    /// currently holds a live connection to it, and the capability its welcome advertised.
    public struct Candidate: Sendable, Equatable {
        public let hostID: String
        public let presence: RunnerPresence
        /// The dispatcher can reach it right now (its own live socket — presence casts are
        /// capability metadata, not liveness).
        public let live: Bool
        /// The host advertised `canAcknowledgeDispatch` (from its welcome).
        public let canAcknowledgeDispatch: Bool
        /// The host advertised `canReceiveSessionTransfer` (from its welcome). Only consulted by
        /// `rebalanceTarget` — a between-turn move rides the transfer engine, so a host that
        /// can't import a session isn't a rebalance destination. Irrelevant to a fresh dispatch.
        public let canReceiveSessionTransfer: Bool

        public init(
            hostID: String, presence: RunnerPresence, live: Bool, canAcknowledgeDispatch: Bool,
            canReceiveSessionTransfer: Bool = false
        ) {
            self.hostID = hostID
            self.presence = presence
            self.live = live
            self.canAcknowledgeDispatch = canAcknowledgeDispatch
            self.canReceiveSessionTransfer = canReceiveSessionTransfer
        }
    }

    /// A ranked pick. `projectID` is the TARGET host's own id for the matching repo when it
    /// already holds it; `nil` means the host must **clone the repo first** (it advertised
    /// `canCreateProjects` and a clone URL was supplied) before the `startChat` — the dispatcher
    /// sends `createProject` and uses the returned id. Holders always outrank cloners.
    public struct Pick: Sendable, Equatable {
        public let hostID: String
        public let projectID: ProjectID?
        public var mustClone: Bool { projectID == nil }
        public init(hostID: String, projectID: ProjectID?) {
            self.hostID = hostID
            self.projectID = projectID
        }
    }

    /// Rank the eligible candidates for dispatching a chat on `project`, best first.
    ///
    /// Eligibility: live, acknowledging, accepting, agent-capable, session-owning kind, the
    /// requested backend available (when the candidate reports its backends), under capacity,
    /// not excluded (cooldown), and **holding the project** (descriptor identity: UUID → root
    /// commit → normalized remote — the same matching session transfer uses).
    ///
    /// Order (ascending): must-clone (holders first), then load ratio (active / maxConcurrent,
    /// unbounded ⇒ assume 8), then a Mac-over-cloud bias on near ties (keep work near the user),
    /// then hostID (determinism).
    ///
    /// When `cloneURL` is non-nil, a candidate that does NOT hold the repo but advertises
    /// `canCreateProjects` becomes an eligible **cloner** (ranked after every holder) — the
    /// dispatcher clones the repo onto it first. `cloneURL == nil` ⇒ holders only (the phone,
    /// which has no local remote to hand over).
    public static func rank(
        candidates: [Candidate], project: ProjectDescriptor, backend: BackendID?,
        excluding: Set<String> = [], cloneURL: String? = nil
    ) -> [Pick] {
        struct Scored {
            let pick: Pick
            let mustClone: Bool
            let loadRatio: Double
            let kind: PeerKind
            let hostID: String
        }
        let scored: [Scored] = candidates.compactMap { candidate in
            guard candidate.live, candidate.canAcknowledgeDispatch,
                  !excluding.contains(candidate.hostID),
                  candidate.presence.acceptsMeshDispatch,
                  candidate.presence.canRunAgents,
                  candidate.presence.kind.canOwnSessions
            else { return nil }
            if let backend, !candidate.presence.backends.isEmpty,
               !candidate.presence.backends.contains(backend) { return nil }
            if let cap = candidate.presence.maxConcurrent,
               candidate.presence.activeSessionCount >= cap { return nil }
            let held = candidate.presence.projects.first(where: { $0.matches(project) })
            let pick: Pick
            let mustClone: Bool
            if let held {
                pick = Pick(hostID: candidate.hostID, projectID: held.projectID)
                mustClone = false
            } else if cloneURL != nil, candidate.presence.canCreateProjects {
                pick = Pick(hostID: candidate.hostID, projectID: nil)
                mustClone = true
            } else {
                return nil
            }
            let denominator = Double(candidate.presence.maxConcurrent ?? 8)
            return Scored(
                pick: pick, mustClone: mustClone,
                loadRatio: Double(candidate.presence.activeSessionCount) / max(denominator, 1),
                kind: candidate.presence.kind,
                hostID: candidate.hostID)
        }
        return scored.sorted { a, b in
            if a.mustClone != b.mustClone { return !a.mustClone }  // holders before cloners
            // Near-tied load (within 0.1): prefer a Mac over a cloud runner.
            if abs(a.loadRatio - b.loadRatio) <= 0.1, a.kind != b.kind {
                if a.kind == .mac { return true }
                if b.kind == .mac { return false }
            }
            if a.loadRatio != b.loadRatio { return a.loadRatio < b.loadRatio }
            return a.hostID < b.hostID
        }.map(\.pick)
    }

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

    /// Hysteresis margin for between-turn rebalancing: a candidate must beat the current owner's
    /// load ratio by MORE than this to justify moving the session. At the default denominator
    /// (8), one running session is 0.125 — under the margin, so a single-session imbalance never
    /// moves anyone; two (0.25) does. The move itself shifts one session (source −0.125,
    /// destination +0.125), collapsing a just-over-margin gap to ~0, so a completed rebalance
    /// can't immediately re-trigger in reverse — no ping-pong.
    public static let rebalanceMargin = 0.15

    /// A presence card's load ratio — the same running/capacity fraction `rank` orders by
    /// (unbounded capacity ⇒ assume 8).
    public static func loadRatio(_ presence: RunnerPresence) -> Double {
        Double(presence.activeSessionCount) / max(Double(presence.maxConcurrent ?? 8), 1)
    }

    /// Where an idle Covalence-managed session should move between turns, if anywhere: the best
    /// eligible peer that beats the current owner's load by more than `rebalanceMargin`, or nil
    /// to stay put. Holders only (`cloneURL == nil` — a rebalance never clones a repo just to
    /// move a chat) and transfer-capable only (the move rides the session-transfer engine).
    /// The caller pre-excludes itself and the session's Covalence origin device via `excluding`
    /// (or by omitting them from `candidates`).
    public static func rebalanceTarget(
        current: RunnerPresence, candidates: [Candidate], project: ProjectDescriptor,
        backend: BackendID? = nil, excluding: Set<String> = []
    ) -> Pick? {
        let movable = candidates.filter(\.canReceiveSessionTransfer)
        guard let best = rank(
            candidates: movable, project: project, backend: backend,
            excluding: excluding, cloneURL: nil).first,
            let candidate = movable.first(where: { $0.hostID == best.hostID })
        else { return nil }
        guard loadRatio(current) - loadRatio(candidate.presence) > rebalanceMargin else {
            return nil
        }
        return best
    }
}
