import Foundation
import NucleicProtocol

public extension MeshMember {
    /// Build a gossipable member from a local pin. Every field a receiver needs to pin and dial
    /// this device — including the Noise static key and last-known addresses — already lives on
    /// `PairedDevice`, so the roster is a straight projection of the store.
    init(pinned d: PairedDevice) {
        self.init(
            deviceID: d.deviceID, label: d.label, kind: d.kind, capabilities: d.capabilities,
            staticPublicKey: d.staticPublicKey, addresses: d.addresses,
            pairedAt: d.pairedAt, lastSeenAt: d.lastSeenAt)
    }
}

/// The one merge path for a gossiped `MeshRosterPush` (mesh "join"), shared by the accepting-host
/// side (`ConnectionHandler` → `SyncHostBridge.mergeMeshRoster`) and the Mac-dialer side
/// (`PeerClient`). Pure store-level policy: it auto-trusts members an already-trusted peer
/// introduced (locked decision — scanning any member joins you to the whole group), but rejects a
/// forged `(deviceID, staticPublicKey)` pair, never overwrites an existing pin from gossip, and
/// honors the revocation-tombstone ordering. Returns whether the store changed, so the caller
/// knows to reconcile dial loops and re-gossip onward (and the `changed`-guard breaks the gossip
/// loop: an idempotent re-merge reports no change, so it doesn't re-push).
public enum MeshRosterMerge {
    public static func apply(
        _ push: MeshRosterPush, into store: any PairedDeviceStore, selfDeviceID: String
    ) async -> Bool {
        var changed = false

        // 1. Tombstones first — a death dominates a concurrent re-add in the same push. Never
        //    record a tombstone for *ourselves* (we'd then gossip "self is dead" forever, even
        //    after a re-join); others tombstone us, and we learn by our dials being rejected.
        for t in push.tombstones where t.deviceID != selfDeviceID {
            let devBefore = await store.device(t.deviceID) != nil
            let tombBefore = await store.isTombstoned(t.deviceID)
            await store.tombstone(t)
            let devAfter = await store.device(t.deviceID) != nil
            let tombAfter = await store.isTombstoned(t.deviceID)
            if devAfter != devBefore || tombAfter != tombBefore { changed = true }
        }

        // Snapshot the death set after step 1 for the domination check below.
        let deaths = Dictionary(
            (await store.tombstones()).map { ($0.deviceID, $0) }, uniquingKeysWith: { a, _ in a })

        // 2. Members.
        for m in push.members where m.deviceID != selfDeviceID {
            // A mac/cloud deviceID *is* `sha256(staticPublicKey)`, so a forged (id, key) pair can't
            // be self-consistent — reject it. (A phone id isn't a hash, so this only gates the
            // kinds we actually pin-and-dial.)
            if m.kind.canOwnSessions,
               DeviceIdentity.hostID(ofStaticKey: m.staticPublicKey) != m.deviceID { continue }
            // Dominated by a known revocation → stays dead until deliberately re-paired later
            // (a fresh pin with `pairedAt > revokedAt`).
            if let death = deaths[m.deviceID], death.revokedAt >= m.pairedAt { continue }

            if let existing = await store.device(m.deviceID) {
                // Never trust a *key* change from gossip — a genuine rotation needs a fresh scan.
                guard existing.staticPublicKey == m.staticPublicKey else { continue }
                var upd = existing
                upd.label = m.label
                upd.kind = m.kind
                upd.capabilities = m.capabilities
                upd.addresses = fresher(existing.addresses, m.addresses)
                // "Changed" is what the caller reconciles dial loops and re-gossips on — so it
                // must mean something actionable moved: identity metadata or a dialable address
                // FIELD. Freshness bookkeeping alone (a lastSeenAt bump — which every hello
                // anywhere in the mesh produces — or an addresses.updatedAt restamp with
                // identical fields) is persisted silently below; counting it re-gossiped the
                // roster mesh-wide per hello and echoed between members indefinitely.
                let meaningful = upd.label != existing.label
                    || upd.kind != existing.kind
                    || upd.capabilities != existing.capabilities
                    || !dialFieldsEqual(upd.addresses, existing.addresses)
                upd.lastSeenAt = later(existing.lastSeenAt, m.lastSeenAt)
                if upd != existing { await store.upsert(upd) }
                if meaningful { changed = true }
            } else {
                // Unknown member vouched for by a trusted introducer → auto-pin (control scope,
                // matching a directly-paired Mac). Preserve the origin `pairedAt` so the tombstone
                // ordering stays correct across every hop.
                await store.upsert(PairedDevice(
                    deviceID: m.deviceID, label: m.label, staticPublicKey: m.staticPublicKey,
                    scope: .control, pairedAt: m.pairedAt, lastSeenAt: m.lastSeenAt,
                    kind: m.kind, capabilities: m.capabilities, addresses: m.addresses))
                changed = true
            }
        }
        return changed
    }

    /// Merge two address sets per field, letting the one with the later `updatedAt` win each field
    /// it actually carries while the other backfills the fields it omits. A blob-replace here would
    /// let a fresher LAN-only gossip drop a still-valid tailnet IP (see `PeerAddresses.merged`),
    /// which is exactly what strands a peer once it's no longer on the same LAN.
    private static func fresher(_ existing: PeerAddresses?, _ incoming: PeerAddresses?) -> PeerAddresses? {
        guard let incoming, !incoming.isEmpty else { return existing }
        guard let existing, !existing.isEmpty else { return incoming }
        let e = existing.updatedAt ?? .distantPast
        let i = incoming.updatedAt ?? .distantPast
        // `merged(with:)` lets the argument's present fields win, so pass the fresher set as the
        // argument and the staler as the receiver (which only fills the gaps).
        return i >= e ? existing.merged(with: incoming) : incoming.merged(with: existing)
    }

    private static func later(_ a: Date?, _ b: Date?) -> Date? {
        switch (a, b) {
        case (let a?, let b?): return max(a, b)
        default: return a ?? b
        }
    }

    /// Whether two address sets agree on every *dialable* field — `updatedAt` deliberately
    /// excluded (a restamp with identical endpoints isn't a change anyone can act on).
    static func dialFieldsEqual(_ a: PeerAddresses?, _ b: PeerAddresses?) -> Bool {
        a?.lanHint == b?.lanHint && a?.tailnet == b?.tailnet && a?.relayRoomID == b?.relayRoomID
    }
}
