import Foundation
import Testing
import NucleicProtocol

@testable import NucleicCore

/// Mesh "join": the roster-merge policy — auto-trust an introduced member, reject a forged
/// `(deviceID, key)` pair, never overwrite a pin from gossip, and honor the revocation-tombstone
/// ordering. Pure store-level, so these are hermetic.
@Suite struct MeshRosterMergeTests {
    private let selfID = "self-host-id"

    /// A self-consistent mac member (`deviceID == sha256(staticPublicKey)`), like a real host.
    private func macMember(
        label: String, pairedAt: Date, addresses: PeerAddresses? = nil
    ) -> (member: MeshMember, identity: DeviceIdentity) {
        let identity = DeviceIdentity()
        let member = MeshMember(
            deviceID: identity.hostID, label: label, kind: .mac, capabilities: .hostAndAgents,
            staticPublicKey: identity.staticPublicKey, addresses: addresses, pairedAt: pairedAt)
        return (member, identity)
    }

    @Test func autoPinsIntroducedMacMember() async throws {
        let store = InMemoryPairedDeviceStore()
        let (c, cIdentity) = macMember(
            label: "Mac C", pairedAt: Date(timeIntervalSince1970: 1_000),
            addresses: PeerAddresses(lanHint: "c.local:5000"))
        let changed = await MeshRosterMerge.apply(
            MeshRosterPush(members: [c]), into: store, selfDeviceID: selfID)

        #expect(changed)
        let pinned = await store.device(cIdentity.hostID)
        #expect(pinned?.kind == .mac)
        #expect(pinned?.scope == .control)
        #expect(pinned?.staticPublicKey == cIdentity.staticPublicKey)
        #expect(pinned?.pairedAt == Date(timeIntervalSince1970: 1_000))  // origin birth preserved
        #expect(pinned?.addresses?.lanHint == "c.local:5000")
    }

    /// A record whose `deviceID` isn't `sha256(staticPublicKey)` can't be a real mac identity —
    /// reject it, so a compromised member can't inject a mac with a key it controls.
    @Test func rejectsForgedMacMember() async throws {
        let store = InMemoryPairedDeviceStore()
        let forged = MeshMember(
            deviceID: "not-a-real-hash", label: "Evil", kind: .mac, capabilities: .hostAndAgents,
            staticPublicKey: Data(repeating: 0x09, count: 32), pairedAt: Date())
        let changed = await MeshRosterMerge.apply(
            MeshRosterPush(members: [forged]), into: store, selfDeviceID: selfID)
        #expect(changed == false)
        #expect(await store.device("not-a-real-hash") == nil)
    }

    /// A phone member (id isn't a key hash) is pinned without the hostID check — phones are visible
    /// members, and a Mac never dials them (`reconcile` filters on `canHost`).
    @Test func pinsPhoneMemberWithoutIntegrityCheck() async throws {
        let store = InMemoryPairedDeviceStore()
        let phone = MeshMember(
            deviceID: "iphone-abc", label: "iPhone", kind: .iphone,
            staticPublicKey: Data(repeating: 0x02, count: 32), pairedAt: Date())
        _ = await MeshRosterMerge.apply(
            MeshRosterPush(members: [phone]), into: store, selfDeviceID: selfID)
        #expect(await store.device("iphone-abc")?.kind == .iphone)
    }

    /// A key change from gossip must never overwrite an existing pin — a genuine rotation needs a
    /// fresh scan. Otherwise one bad push could lock you out of a peer forever.
    @Test func neverOverwritesExistingPinFromGossip() async throws {
        let store = InMemoryPairedDeviceStore()
        let (c, cIdentity) = macMember(label: "Mac C", pairedAt: Date(timeIntervalSince1970: 1_000))
        await store.upsert(PairedDevice(
            deviceID: cIdentity.hostID, label: "Mac C", staticPublicKey: cIdentity.staticPublicKey,
            scope: .control, pairedAt: Date(timeIntervalSince1970: 1_000), kind: .mac,
            capabilities: .hostAndAgents))
        // Same deviceID, a *different* key — a forged rotation.
        let rotated = MeshMember(
            deviceID: cIdentity.hostID, label: "Hijack", kind: .mac, capabilities: .hostAndAgents,
            staticPublicKey: Data(repeating: 0xFF, count: 32), pairedAt: Date(timeIntervalSince1970: 2_000))
        _ = await MeshRosterMerge.apply(
            MeshRosterPush(members: [rotated]), into: store, selfDeviceID: selfID)
        // Pin unchanged; the hijack label didn't take either.
        #expect(await store.device(cIdentity.hostID)?.staticPublicKey == cIdentity.staticPublicKey)
        #expect(await store.device(cIdentity.hostID)?.label == "Mac C")
        _ = c
    }

    @Test func refreshesKnownMemberMetadata() async throws {
        let store = InMemoryPairedDeviceStore()
        let (c, cIdentity) = macMember(
            label: "New Name", pairedAt: Date(timeIntervalSince1970: 1_000),
            addresses: PeerAddresses(lanHint: "c.local:6000", updatedAt: Date(timeIntervalSince1970: 5_000)))
        await store.upsert(PairedDevice(
            deviceID: cIdentity.hostID, label: "Old Name", staticPublicKey: cIdentity.staticPublicKey,
            scope: .control, pairedAt: Date(timeIntervalSince1970: 1_000), kind: .mac,
            capabilities: .hostAndAgents,
            addresses: PeerAddresses(lanHint: "c.local:1", updatedAt: Date(timeIntervalSince1970: 1_000))))
        let changed = await MeshRosterMerge.apply(
            MeshRosterPush(members: [c]), into: store, selfDeviceID: selfID)
        #expect(changed)
        #expect(await store.device(cIdentity.hostID)?.label == "New Name")
        #expect(await store.device(cIdentity.hostID)?.addresses?.lanHint == "c.local:6000")
    }

    /// A tombstone suppresses a member whose membership predates the revocation — it stays dead and
    /// isn't re-pinned by a stale gossip.
    @Test func tombstoneDominatesStaleMember() async throws {
        let store = InMemoryPairedDeviceStore()
        let (c, cIdentity) = macMember(label: "Mac C", pairedAt: Date(timeIntervalSince1970: 1_000))
        let push = MeshRosterPush(
            members: [c],
            tombstones: [MeshTombstone(deviceID: cIdentity.hostID, revokedAt: Date(timeIntervalSince1970: 2_000))])
        _ = await MeshRosterMerge.apply(push, into: store, selfDeviceID: selfID)
        #expect(await store.device(cIdentity.hostID) == nil)
        #expect(await store.isTombstoned(cIdentity.hostID))
    }

    /// A deliberate re-join (member paired *after* the revocation) beats the tombstone and re-enters.
    @Test func rePairedMemberBeatsTombstone() async throws {
        let store = InMemoryPairedDeviceStore()
        let (c, cIdentity) = macMember(label: "Mac C", pairedAt: Date(timeIntervalSince1970: 3_000))
        let push = MeshRosterPush(
            members: [c],
            tombstones: [MeshTombstone(deviceID: cIdentity.hostID, revokedAt: Date(timeIntervalSince1970: 2_000))])
        _ = await MeshRosterMerge.apply(push, into: store, selfDeviceID: selfID)
        #expect(await store.device(cIdentity.hostID) != nil)
        #expect(await store.isTombstoned(cIdentity.hostID) == false)  // stale tombstone cleared
    }

    @Test func skipsSelfMemberAndSelfTombstone() async throws {
        let store = InMemoryPairedDeviceStore()
        let selfMember = MeshMember(
            deviceID: selfID, label: "Me", kind: .mac, capabilities: .hostAndAgents,
            staticPublicKey: Data(repeating: 0x03, count: 32), pairedAt: Date())
        _ = await MeshRosterMerge.apply(
            MeshRosterPush(
                members: [selfMember],
                tombstones: [MeshTombstone(deviceID: selfID, revokedAt: Date())]),
            into: store, selfDeviceID: selfID)
        #expect(await store.device(selfID) == nil)
        #expect(await store.isTombstoned(selfID) == false)  // we never tombstone ourselves
    }

    /// Re-applying the same roster reports no change — the idempotence the gossip loop relies on to
    /// converge instead of ping-ponging.
    @Test func reMergeIsIdempotent() async throws {
        let store = InMemoryPairedDeviceStore()
        let (c, _) = macMember(label: "Mac C", pairedAt: Date(timeIntervalSince1970: 1_000))
        let push = MeshRosterPush(members: [c])
        #expect(await MeshRosterMerge.apply(push, into: store, selfDeviceID: selfID))
        #expect(await MeshRosterMerge.apply(push, into: store, selfDeviceID: selfID) == false)
    }
}
