import Foundation
import Testing
import NucleicProtocol

@testable import NucleicCore

@Suite struct FilePairedDeviceStoreTests {
    private func tempURL() -> URL {
        FileManager.default.temporaryDirectory
            .appendingPathComponent("nucleic-paired-\(UUID().uuidString)", isDirectory: true)
            .appendingPathComponent("paired-devices.json")
    }

    private func device(_ id: String, key: UInt8 = 0xAB) -> PairedDevice {
        PairedDevice(
            deviceID: id, label: "iPhone", staticPublicKey: Data(repeating: key, count: 32),
            scope: .control, pushToken: "tok-\(id)",
            pairedAt: Date(timeIntervalSince1970: 1_700_000_000),
            lastSeenAt: Date(timeIntervalSince1970: 1_700_000_500))
    }

    @Test func persistsAcrossReopen() async throws {
        let url = tempURL()
        defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }

        let store = FilePairedDeviceStore(url: url)
        await store.upsert(device("iphone-1"))

        // A fresh store at the same path (a host restart) still knows the device — the
        // reconnect regression this fixes.
        let reopened = FilePairedDeviceStore(url: url)
        let loaded = await reopened.device("iphone-1")
        #expect(loaded == device("iphone-1"))
    }

    @Test func removeIsPersisted() async throws {
        let url = tempURL()
        defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }

        let store = FilePairedDeviceStore(url: url)
        await store.upsert(device("a"))
        await store.upsert(device("b"))
        await store.remove("a")

        let reopened = FilePairedDeviceStore(url: url)
        #expect(await reopened.device("a") == nil)
        #expect(await reopened.device("b") != nil)
        #expect(await reopened.all().count == 1)
    }

    @Test func missingFileLoadsEmpty() async {
        let store = FilePairedDeviceStore(url: tempURL())
        #expect(await store.all().isEmpty)
    }

    @Test func corruptFileDegradesToEmpty() async throws {
        let url = tempURL()
        defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
        try FileManager.default.createDirectory(
            at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
        try Data("not json".utf8).write(to: url)

        // A corrupt file must never block the sync server from starting.
        let store = FilePairedDeviceStore(url: url)
        #expect(await store.all().isEmpty)
        // …and it recovers by accepting a fresh pairing.
        await store.upsert(device("fresh"))
        #expect(await store.device("fresh") != nil)
    }

    /// Mesh P4: a store file written before `kind`/`capabilities` existed must load, defaulting
    /// to an iPhone with no capabilities — no migration, no data loss.
    @Test func preMeshPinDecodesWithDefaults() throws {
        // The exact JSON a pre-mesh host wrote (no kind / capabilities keys).
        let legacyJSON = """
        [{"deviceID":"iphone-1","label":"iPhone","staticPublicKey":"\(Data(repeating: 0xAB, count: 32).base64EncodedString())",\
        "scope":"control","pushToken":"tok","pairedAt":760000000,"lastSeenAt":760000500}]
        """
        let devices = try JSONDecoder().decode([PairedDevice].self, from: Data(legacyJSON.utf8))
        #expect(devices.count == 1)
        #expect(devices[0].kind == .iphone)
        #expect(devices[0].capabilities == .none)
        #expect(devices[0].deviceID == "iphone-1")
        // Mesh P4 remainder: addresses joined after kind/capabilities — same default rule.
        #expect(devices[0].addresses == nil)
    }

    /// A store file from the P4-foundation era (kind/capabilities, no addresses) also loads.
    @Test func foundationEraPinDecodesWithoutAddresses() throws {
        let json = """
        [{"deviceID":"mac-b","label":"Work Mac","staticPublicKey":"\(Data(repeating: 0xCD, count: 32).base64EncodedString())",\
        "scope":"control","pairedAt":760000000,"kind":"mac","capabilities":{"canHost":true,"canRunAgents":true}}]
        """
        let devices = try JSONDecoder().decode([PairedDevice].self, from: Data(json.utf8))
        #expect(devices.count == 1)
        #expect(devices[0].kind == .mac)
        #expect(devices[0].capabilities == .hostAndAgents)
        #expect(devices[0].addresses == nil)
        // A pin from before the relay-membership fields reads them as nil (no relay dial candidate).
        #expect(devices[0].relayMembershipToken == nil)
        #expect(devices[0].relayURL == nil)
    }

    /// The relay-membership fields survive a Codable round-trip so a sibling Mac's relay reconnect
    /// hint persists across a restart (mesh relay-dial healing).
    @Test func relayMembershipSurvivesRoundTrip() throws {
        let original = PairedDevice(
            deviceID: "mac-c", label: "Studio", staticPublicKey: Data(repeating: 0xEF, count: 32),
            scope: .control, pairedAt: Date(timeIntervalSince1970: 760_000_000),
            kind: .mac, capabilities: .hostAndAgents,
            addresses: PeerAddresses(relayRoomID: "mac-c"),
            relayMembershipToken: "memb-tok", relayURL: "https://relay.example")
        let decoded = try JSONDecoder().decode(
            PairedDevice.self, from: JSONEncoder().encode(original))
        #expect(decoded == original)
        #expect(decoded.relayMembershipToken == "memb-tok")
        #expect(decoded.relayURL == "https://relay.example")
    }

    // MARK: Mesh "join" — tombstones + envelope persistence

    /// The pre-tombstone on-disk shape (a bare `[PairedDevice]` array) still loads through the new
    /// envelope decoder — no migration, no data loss.
    @Test func legacyBareArrayFileLoads() async throws {
        let url = tempURL()
        defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
        try FileManager.default.createDirectory(
            at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
        let legacyJSON = """
        [{"deviceID":"mac-b","label":"Work Mac","staticPublicKey":"\(Data(repeating: 0xCD, count: 32).base64EncodedString())",\
        "scope":"control","pairedAt":760000000,"kind":"mac","capabilities":{"canHost":true,"canRunAgents":true}}]
        """
        try Data(legacyJSON.utf8).write(to: url)

        let store = FilePairedDeviceStore(url: url)
        #expect(await store.device("mac-b")?.kind == .mac)
        #expect(await store.tombstones().isEmpty)
    }

    @Test func tombstonePersistsAndRemovesDevice() async throws {
        let url = tempURL()
        defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
        let store = FilePairedDeviceStore(url: url)
        await store.upsert(device("mac-b"))
        await store.tombstone(MeshTombstone(
            deviceID: "mac-b", revokedAt: Date(timeIntervalSince1970: 1_700_001_000)))

        // The live record is gone and the death is recorded — and both survive a reopen.
        let reopened = FilePairedDeviceStore(url: url)
        #expect(await reopened.device("mac-b") == nil)
        #expect(await reopened.isTombstoned("mac-b"))
        #expect(await reopened.tombstones().count == 1)
    }

    @Test func tombstoneKeepsNewerRevocation() async throws {
        let store = InMemoryPairedDeviceStore()
        await store.tombstone(MeshTombstone(deviceID: "c", revokedAt: Date(timeIntervalSince1970: 200)))
        // An older death for the same device is ignored (keep-newer).
        await store.tombstone(MeshTombstone(deviceID: "c", revokedAt: Date(timeIntervalSince1970: 100)))
        #expect(await store.tombstones().first?.revokedAt == Date(timeIntervalSince1970: 200))
    }

    /// A deliberate re-join (a fresh pin paired *after* the revocation) supersedes a stale
    /// tombstone: the device comes back and the tombstone is cleared, so it stops being
    /// re-gossiped dead.
    @Test func rePairAfterRevokeClearsTombstone() async throws {
        let store = InMemoryPairedDeviceStore()
        await store.tombstone(MeshTombstone(deviceID: "c", revokedAt: Date(timeIntervalSince1970: 200)))
        #expect(await store.isTombstoned("c"))

        var revived = device("c")
        revived.pairedAt = Date(timeIntervalSince1970: 300)  // re-paired after the revoke
        await store.upsert(revived)
        #expect(await store.device("c") != nil)
        #expect(await store.isTombstoned("c") == false)
    }

    /// A *stale* tombstone (revoked before a live device was re-paired) must not kill that device.
    @Test func staleTombstoneDoesNotKillRepairedDevice() async throws {
        let store = InMemoryPairedDeviceStore()
        var revived = device("c")
        revived.pairedAt = Date(timeIntervalSince1970: 300)
        await store.upsert(revived)
        // A tombstone that predates the current pin is stale — ignored.
        await store.tombstone(MeshTombstone(deviceID: "c", revokedAt: Date(timeIntervalSince1970: 200)))
        #expect(await store.device("c") != nil)
        #expect(await store.isTombstoned("c") == false)
    }

    @Test func meshFieldsRoundTripThroughTheStore() async throws {
        let url = tempURL()
        defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
        var mac = device("mac-b")
        mac.kind = .mac
        mac.capabilities = .hostAndAgents
        mac.addresses = PeerAddresses(
            lanHint: "192.168.1.20:52341", tailnet: "100.64.0.7",
            updatedAt: Date(timeIntervalSince1970: 1_700_000_000))
        let store = FilePairedDeviceStore(url: url)
        await store.upsert(mac)

        let reopened = FilePairedDeviceStore(url: url)
        let loaded = await reopened.device("mac-b")
        #expect(loaded?.kind == .mac)
        #expect(loaded?.capabilities == .hostAndAgents)
        #expect(loaded?.addresses?.lanHint == "192.168.1.20:52341")
        #expect(loaded?.addresses?.tailnet == "100.64.0.7")
    }
}
