import Foundation
import NucleicProtocol

/// The mesh's **topology**, derived once from the same four sources the Covalence Mesh Viewer's
/// host list reads (dialed peers, mirrored presence cards, paired devices, live inbound
/// connections) plus a fifth the list can't use: each connected peer's *own* peer list
/// (`PeerPresence.remotePeers`), which is the only way this Mac learns about links it isn't an
/// endpoint of — and about hosts it has never been paired with.
///
/// Pure value type, built by a pure function, so the graph view renders a snapshot and the
/// topology rules (who is a node, who is collapsed away, which links exist) are unit-testable
/// without a window. Structural metadata only — host names, kinds, counts, transports — never
/// prompt or draft content (OBSERVABILITY §A.1).
public struct MeshGraph: Sendable, Equatable {
    /// One host in the mesh: this Mac, a peer Mac, a cloud runner, or a paired phone/iPad.
    public struct Node: Identifiable, Sendable, Equatable {
        public let id: String
        /// Display name, already resolved through the peer label / presence card / id-prefix
        /// fallback chain. The "(this Mac)" suffix is the view's business, not the model's.
        public let label: String
        public let kind: PeerKind
        public let isSelf: Bool
        /// Reachable right now: this Mac, a live outbound dial, or a live inbound connection.
        public let online: Bool
        /// Online only through a connection the *peer* opened to us (how phones always connect).
        public let inbound: Bool
        /// Only ever seen in another host's peer list — this Mac has no pin and no card for it.
        /// Present in the graph (it's a real mesh member) but not in the viewer's host list.
        public let isIndirect: Bool
        /// Which path the live outbound dial went over; nil when disconnected or inbound-only.
        public let transport: SyncTransportHint?
        /// Whether a `runner.presence` card was folded for this host — without one it can't be a
        /// Covalence dispatch candidate at all, whatever else it advertises.
        public let hasPresenceCard: Bool
        public let acceptsMeshDispatch: Bool
        public let canCreateProjects: Bool
        public let canReceiveSessionTransfer: Bool
        public let activeSessionCount: Int
        public let maxConcurrent: Int?
        /// Running/capacity as the dispatch scorer ranks it; nil without a presence card.
        public let loadRatio: Double?
        public let projectCount: Int
        public let presenceUpdatedAt: Date?
        public let lastError: String?

        public init(
            id: String, label: String, kind: PeerKind, isSelf: Bool, online: Bool,
            inbound: Bool, isIndirect: Bool = false, transport: SyncTransportHint? = nil,
            hasPresenceCard: Bool = false, acceptsMeshDispatch: Bool = false,
            canCreateProjects: Bool = false, canReceiveSessionTransfer: Bool = false,
            activeSessionCount: Int = 0, maxConcurrent: Int? = nil, loadRatio: Double? = nil,
            projectCount: Int = 0, presenceUpdatedAt: Date? = nil, lastError: String? = nil
        ) {
            self.id = id
            self.label = label
            self.kind = kind
            self.isSelf = isSelf
            self.online = online
            self.inbound = inbound
            self.isIndirect = isIndirect
            self.transport = transport
            self.hasPresenceCard = hasPresenceCard
            self.acceptsMeshDispatch = acceptsMeshDispatch
            self.canCreateProjects = canCreateProjects
            self.canReceiveSessionTransfer = canReceiveSessionTransfer
            self.activeSessionCount = activeSessionCount
            self.maxConcurrent = maxConcurrent
            self.loadRatio = loadRatio
            self.projectCount = projectCount
            self.presenceUpdatedAt = presenceUpdatedAt
            self.lastError = lastError
        }
    }

    /// One link between two hosts. Undirected for display purposes — `from` is simply the end
    /// that knows about the link (this Mac for a dial, the reporting peer for a hearsay link).
    public struct Edge: Identifiable, Sendable, Equatable {
        public enum Kind: String, Sendable {
            /// An outbound dial this Mac owns — the only edges whose transport we know first-hand.
            case dialed
            /// A connection the other end opened into this Mac (every phone, and a peer Mac that
            /// dialed us before we dialed it).
            case inbound
            /// Hearsay: a link between two *other* hosts, learned from a connected peer's roster.
            case reported
        }

        public let from: String
        public let to: String
        public let kind: Kind
        /// Whether the link is up right now. A down `dialed` edge is a peer we know how to reach
        /// but currently can't — worth drawing, faintly, because that's the common failure.
        public let online: Bool
        public let transport: SyncTransportHint?
        /// Who told us about a `.reported` edge, or the dial error on a down `.dialed` one.
        public let detail: String?

        public var id: String { "\(kind.rawValue)|\(from)|\(to)" }

        public init(
            from: String, to: String, kind: Kind, online: Bool,
            transport: SyncTransportHint? = nil, detail: String? = nil
        ) {
            self.from = from
            self.to = to
            self.kind = kind
            self.online = online
            self.transport = transport
            self.detail = detail
        }

        /// Endpoint pair without direction, for de-duplicating a link both ends report.
        var unorderedKey: String { from < to ? "\(from)|\(to)" : "\(to)|\(from)" }
    }

    public var nodes: [Node]
    public var edges: [Edge]

    public init(nodes: [Node] = [], edges: [Edge] = []) {
        self.nodes = nodes
        self.edges = edges
    }

    public func node(_ id: String) -> Node? { nodes.first { $0.id == id } }

    /// Every link the given host is an endpoint of.
    public func edges(touching id: String) -> [Edge] {
        edges.filter { $0.from == id || $0.to == id }
    }

    // MARK: - Building

    /// Fold the four local sources (plus peer hearsay) into nodes and links.
    ///
    /// `hostLabel` is the store's `meshHostLabel` — the peer-label → presence-name → id-prefix
    /// chain — passed in so this stays a pure function.
    public static func build(
        selfID: String?,
        selfName: String,
        peers: [PeerClient.PeerPresence],
        presenceByHost: [String: RunnerPresence],
        devices: [PairedDevice],
        inbound: Set<String>,
        hostLabel: (String) -> String
    ) -> MeshGraph {
        let peerByID = Dictionary(peers.map { ($0.deviceID, $0) }, uniquingKeysWith: { a, _ in a })
        let deviceByID = Dictionary(
            devices.map { ($0.deviceID, $0) }, uniquingKeysWith: { a, _ in a })

        // Node order mirrors the host list: self, dialed peers, presence-only hosts, remaining
        // paired devices (iPhones/iPads have no peer entry and cast no card — this is the only
        // way they appear), then hosts only a peer knows about.
        var ids: [String] = []
        var seen: Set<String> = []
        if let selfID, seen.insert(selfID).inserted { ids.append(selfID) }
        for peer in peers where seen.insert(peer.deviceID).inserted { ids.append(peer.deviceID) }
        for hostID in presenceByHost.keys.sorted() where seen.insert(hostID).inserted {
            ids.append(hostID)
        }
        for device in devices where seen.insert(device.deviceID).inserted {
            ids.append(device.deviceID)
        }
        let directIDs = seen
        // Hearsay hosts, in a stable order (peer order, then the peer's own roster order).
        var summaryByID: [String: PeerSummary] = [:]
        for peer in peers where peer.connected {
            for summary in peer.remotePeers {
                summaryByID[summary.deviceID] = summaryByID[summary.deviceID] ?? summary
                if seen.insert(summary.deviceID).inserted { ids.append(summary.deviceID) }
            }
        }

        var drafts: [Draft] = ids.map { id in
            let peer = peerByID[id]
            let presence = presenceByHost[id]
            let device = deviceByID[id]
            let summary = summaryByID[id]
            let isSelf = id == selfID
            let isIndirect = !directIDs.contains(id)
            let isInbound = inbound.contains(id)
            let online = isSelf || peer?.connected == true || isInbound
                || (isIndirect && summary?.online == true)
            let label: String = {
                if isSelf { return selfName }
                let resolved = hostLabel(id)
                // A phone has no peer label and casts no card, so `meshHostLabel` bottoms out at
                // the id prefix — its paired-device (or roster) record is the only name we have.
                guard resolved == String(id.prefix(8)) else { return resolved }
                if let label = device?.label, !label.isEmpty { return label }
                if let label = summary?.label, !label.isEmpty { return label }
                return resolved
            }()
            let node = Node(
                id: id,
                label: label,
                kind: presence?.kind ?? peer?.kind ?? device?.kind ?? summary?.kind ?? .mac,
                isSelf: isSelf,
                online: online,
                inbound: isInbound,
                isIndirect: isIndirect,
                transport: peer?.connected == true ? peer?.transport : nil,
                hasPresenceCard: presence != nil,
                acceptsMeshDispatch: presence?.acceptsMeshDispatch ?? false,
                canCreateProjects: presence?.canCreateProjects ?? false,
                canReceiveSessionTransfer: peer?.canReceiveSessionTransfer ?? false,
                activeSessionCount: presence?.activeSessionCount ?? 0,
                maxConcurrent: presence?.maxConcurrent,
                loadRatio: presence.map(MeshDispatchScorer.loadRatio),
                projectCount: presence?.projects.count ?? 0,
                presenceUpdatedAt: presence.map(\.updatedAt),
                lastError: peer?.lastError)
            let freshness = presence?.updatedAt ?? device?.lastSeenAt ?? summary?.lastSeenAt
            return Draft(node: node, freshness: freshness ?? .distantPast)
        }
        drafts = collapseSupersededMacs(drafts)
        let nodes = drafts.map(\.node)
        let present = Set(nodes.map(\.id))

        var edges: [Edge] = []
        var placed: Set<String> = []
        func add(_ edge: Edge) {
            guard present.contains(edge.from), present.contains(edge.to),
                  edge.from != edge.to, placed.insert(edge.unorderedKey).inserted
            else { return }
            edges.append(edge)
        }
        if let selfID, present.contains(selfID) {
            // Our own dials first: they're the only links whose transport we know first-hand,
            // and claiming the endpoint pair here stops a peer's hearsay from overwriting one.
            for peer in peers {
                add(Edge(
                    from: selfID, to: peer.deviceID, kind: .dialed,
                    online: peer.connected, transport: peer.connected ? peer.transport : nil,
                    detail: peer.connected ? nil : peer.lastError))
            }
            for id in inbound.sorted() {
                add(Edge(from: selfID, to: id, kind: .inbound, online: true))
            }
        }
        for peer in peers where peer.connected {
            for summary in peer.remotePeers where summary.online {
                add(Edge(
                    from: peer.deviceID, to: summary.deviceID, kind: .reported,
                    online: true, detail: "reported by \(hostLabel(peer.deviceID))"))
            }
        }
        return MeshGraph(nodes: nodes, edges: edges)
    }

    private struct Draft {
        var node: Node
        var freshness: Date
    }

    /// Fold a Mac's superseded identities into one node — the same display-side safety net over
    /// `reconcileSupersededMacIdentities` the host list applies: an offline session-owning host
    /// whose name matches an *online* one is that same Mac's stale key (hidden); two offline
    /// same-named Macs collapse to the one with the fresher card. Phones and distinct online Macs
    /// are untouched, so two genuinely different Macs sharing a name (both online) stay separate.
    private static func collapseSupersededMacs(_ drafts: [Draft]) -> [Draft] {
        func name(_ d: Draft) -> String {
            d.node.label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
        }
        let liveMacNames = Set(drafts
            .filter { $0.node.kind.canOwnSessions && $0.node.online }
            .map(name))

        var result: [Draft] = []
        var offlineIndexByName: [String: Int] = [:]
        for d in drafts {
            guard d.node.kind.canOwnSessions, !d.node.online, !d.node.isSelf else {
                result.append(d)
                continue
            }
            let n = name(d)
            guard !n.isEmpty else { result.append(d); continue }
            if liveMacNames.contains(n) { continue }  // superseded by a live identity — hide it
            if let idx = offlineIndexByName[n] {
                if d.freshness > result[idx].freshness { result[idx] = d }  // keep the fresher card
                continue
            }
            offlineIndexByName[n] = result.count
            result.append(d)
        }
        return result
    }
}

// MARK: - Layout

/// A point in the graph's unit space: the origin is the mesh's center (this Mac) and the radius
/// of the outermost ring is 1, so a view maps it onto whatever rect it has.
public struct MeshGraphPoint: Sendable, Equatable {
    public var x: Double
    public var y: Double
    public init(x: Double, y: Double) {
        self.x = x
        self.y = y
    }
}

public extension MeshGraph {
    /// Place the nodes: this Mac at the center, everything it can reach right now on an inner
    /// ring, and everything known-but-unreachable on an outer one — so "who is actually in the
    /// mesh" reads as distance from the middle. Deterministic (a stable sort, fixed angles), so
    /// the layout doesn't shuffle between the viewer's refreshes.
    func layout() -> [String: MeshGraphPoint] {
        var positions: [String: MeshGraphPoint] = [:]
        var center: Node?
        var inner: [Node] = []
        var outer: [Node] = []
        for node in nodes {
            if node.isSelf { center = node } else if node.online { inner.append(node) }
            else { outer.append(node) }
        }
        // A mesh with no self node (Covalence off before an identity exists) still draws: the
        // reachable hosts take the inner ring and the center simply stays empty.
        if let center { positions[center.id] = MeshGraphPoint(x: 0, y: 0) }
        inner.sort(by: Self.stableOrder)
        outer.sort(by: Self.stableOrder)
        // One lonely ring reads better pulled inward — a single peer shouldn't sit at the rim.
        let innerRadius = outer.isEmpty ? 0.72 : 0.6
        place(inner, radius: innerRadius, startAngle: -.pi / 2, into: &positions)
        // Half-step offset so an outer node never hides directly behind an inner spoke.
        let step = outer.isEmpty ? 0 : (2 * Double.pi) / Double(outer.count)
        place(
            outer, radius: inner.isEmpty ? 0.72 : 1, startAngle: -.pi / 2 + step / 2,
            into: &positions)
        return positions
    }

    private func place(
        _ nodes: [Node], radius: Double, startAngle: Double,
        into positions: inout [String: MeshGraphPoint]
    ) {
        guard !nodes.isEmpty else { return }
        let step = (2 * Double.pi) / Double(nodes.count)
        for (index, node) in nodes.enumerated() {
            let angle = startAngle + step * Double(index)
            positions[node.id] = MeshGraphPoint(
                x: radius * cos(angle), y: radius * sin(angle))
        }
    }

    /// Ring order: session-owning hosts first (the dispatch candidates), then phones, then any
    /// future kind — alphabetically inside each group, with the id as the final tiebreak.
    private static func stableOrder(_ a: Node, _ b: Node) -> Bool {
        func rank(_ kind: PeerKind) -> Int {
            switch kind {
            case .mac: 0
            case .cloud: 1
            case .iphone: 2
            default: 3
            }
        }
        if rank(a.kind) != rank(b.kind) { return rank(a.kind) < rank(b.kind) }
        let (la, lb) = (a.label.lowercased(), b.label.lowercased())
        if la != lb { return la < lb }
        return a.id < b.id
    }
}

// MARK: - Store snapshot

public extension AppStore {
    /// The live mesh topology, for the Covalence Mesh Viewer's graph and host list. Computed from
    /// observable state, so a SwiftUI view reading it re-renders when a peer connects, a presence
    /// card folds, or a phone dials in.
    var meshGraph: MeshGraph {
        MeshGraph.build(
            selfID: syncHostID, selfName: syncHostName, peers: meshPeers,
            presenceByHost: runnerPresenceByHost, devices: pairedDevices,
            inbound: inboundConnectedDeviceIDs, hostLabel: { self.meshHostLabel($0) })
    }
}
