import Foundation
import NucleicProtocol

/// A device the user has paired (SYNC §4.2). The host pins `staticPublicKey` at pairing and
/// checks it on every reconnect; revoking a device removes the row so its next handshake
/// fails `.unauthorized`.
public struct PairedDevice: Sendable, Codable, Equatable {
    public let deviceID: String
    public var label: String
    public var staticPublicKey: Data
    public var scope: DeviceScope
    public var pushToken: String?
    public var pairedAt: Date
    public var lastSeenAt: Date?
    /// What kind of device this is (mesh P4). Defaults to `.iphone` — every pin written before
    /// the field existed is a phone — so existing JSON stores load unchanged.
    public var kind: PeerKind
    /// What the peer can do (host / run agents). Defaults to none for pre-mesh pins.
    public var capabilities: PeerCapabilities
    /// Where this peer can be dialed (mesh P4) — refreshed from `Hello.addresses` on every
    /// connect and by `ClientMsg.addressUpdate`. A Mac uses these (plus the pinned static key)
    /// to dial a sibling Mac back without a new pairing QR. Nil for phones and pre-mesh pins.
    public var addresses: PeerAddresses?
    /// The relay membership token this *peer* issued us — pushed as `HostMsg.relayMembership` on
    /// every connect. It admits us to the peer's stable relay room (its permanent identity), so a
    /// sibling Mac stays dialable over the relay after its LAN port churns on relaunch/update — the
    /// port-independent fallback that heals a mesh no live LAN connection could bootstrap. Paired
    /// with `PeerAddresses.relayRoomID`; nil until the peer pushes one (or the relay is off).
    public var relayMembershipToken: String?
    /// A dev/self-host relay base-URL override the peer's membership was minted against; nil ⇒ the
    /// production relay. Mirrors `WireRelayMembership.url`.
    public var relayURL: String?

    public init(
        deviceID: String, label: String, staticPublicKey: Data,
        scope: DeviceScope = .approve, pushToken: String? = nil,
        pairedAt: Date, lastSeenAt: Date? = nil,
        kind: PeerKind = .iphone, capabilities: PeerCapabilities = .none,
        addresses: PeerAddresses? = nil,
        relayMembershipToken: String? = nil, relayURL: String? = nil
    ) {
        self.deviceID = deviceID
        self.label = label
        self.staticPublicKey = staticPublicKey
        self.scope = scope
        self.pushToken = pushToken
        self.pairedAt = pairedAt
        self.lastSeenAt = lastSeenAt
        self.kind = kind
        self.capabilities = capabilities
        self.addresses = addresses
        self.relayMembershipToken = relayMembershipToken
        self.relayURL = relayURL
    }

    private enum CodingKeys: String, CodingKey {
        case deviceID, label, staticPublicKey, scope, pushToken, pairedAt, lastSeenAt
        case kind, capabilities, addresses, relayMembershipToken, relayURL
    }

    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        self.deviceID = try c.decode(String.self, forKey: .deviceID)
        self.label = try c.decode(String.self, forKey: .label)
        self.staticPublicKey = try c.decode(Data.self, forKey: .staticPublicKey)
        self.scope = try c.decode(DeviceScope.self, forKey: .scope)
        self.pushToken = try c.decodeIfPresent(String.self, forKey: .pushToken)
        self.pairedAt = try c.decode(Date.self, forKey: .pairedAt)
        self.lastSeenAt = try c.decodeIfPresent(Date.self, forKey: .lastSeenAt)
        // Mesh P4 — pins from before these fields default to an iPhone with no capabilities.
        self.kind = try c.decodeIfPresent(PeerKind.self, forKey: .kind) ?? .iphone
        self.capabilities = try c.decodeIfPresent(PeerCapabilities.self, forKey: .capabilities) ?? .none
        self.addresses = try c.decodeIfPresent(PeerAddresses.self, forKey: .addresses)
        self.relayMembershipToken = try c.decodeIfPresent(String.self, forKey: .relayMembershipToken)
        self.relayURL = try c.decodeIfPresent(String.self, forKey: .relayURL)
    }
}

public protocol PairedDeviceStore: Sendable {
    func device(_ id: String) async -> PairedDevice?
    func all() async -> [PairedDevice]
    func upsert(_ device: PairedDevice) async
    func remove(_ id: String) async

    // Mesh "join" — group-wide revocation. A tombstone records that a device was removed so it
    // can't be re-gossiped back in by a member that hasn't heard yet. See `MeshRosterPush`.
    /// Every revocation this store knows, for gossiping onward.
    func tombstones() async -> [MeshTombstone]
    /// Record a revocation. Keep-newer by `revokedAt`, and drop the live record for that device —
    /// *unless* a live record for it was paired *after* the revocation (a deliberate re-join),
    /// in which case the tombstone is stale and ignored. Pairs with `upsert`'s tombstone-clearing.
    func tombstone(_ t: MeshTombstone) async
    /// Drop a tombstone (pruning after its TTL, or an explicit clear).
    func removeTombstone(_ id: String) async
    func isTombstoned(_ id: String) async -> Bool
}

/// Defaults so a minimal/mock conformer (e.g. a test double) still compiles — the real stores
/// (`InMemoryPairedDeviceStore`, `FilePairedDeviceStore`) override all of these.
public extension PairedDeviceStore {
    func tombstones() async -> [MeshTombstone] { [] }
    func tombstone(_ t: MeshTombstone) async { await remove(t.deviceID) }
    func removeTombstone(_ id: String) async {}
    func isTombstoned(_ id: String) async -> Bool { false }
}

/// Default store. The app can persist these (GRDB) later; held in memory for now so pairing
/// works without a schema migration, and so tests are hermetic.
public actor InMemoryPairedDeviceStore: PairedDeviceStore {
    private var devices: [String: PairedDevice] = [:]
    private var deadTombstones: [String: MeshTombstone] = [:]

    public init(seed: [PairedDevice] = [], tombstones: [MeshTombstone] = []) {
        for d in seed { devices[d.deviceID] = d }
        for t in tombstones { deadTombstones[t.deviceID] = t }
    }

    public func device(_ id: String) async -> PairedDevice? { devices[id] }
    public func all() async -> [PairedDevice] { Array(devices.values) }

    public func upsert(_ device: PairedDevice) async {
        // A fresh pin (larger `pairedAt`) supersedes an older revocation — the deliberate-re-join
        // path. Drop the stale tombstone so this device stops being suppressed and re-gossiped dead.
        if let ts = deadTombstones[device.deviceID], ts.revokedAt < device.pairedAt {
            deadTombstones[device.deviceID] = nil
        }
        devices[device.deviceID] = device
    }

    public func remove(_ id: String) async { devices[id] = nil }

    public func tombstones() async -> [MeshTombstone] { Array(deadTombstones.values) }

    public func tombstone(_ t: MeshTombstone) async {
        // A re-pair after the revocation wins — ignore the now-stale death.
        if let dev = devices[t.deviceID], dev.pairedAt > t.revokedAt { return }
        if let existing = deadTombstones[t.deviceID], existing.revokedAt >= t.revokedAt {
            devices[t.deviceID] = nil  // keep the newer death, but ensure the record is gone
            return
        }
        deadTombstones[t.deviceID] = t
        devices[t.deviceID] = nil
    }

    public func removeTombstone(_ id: String) async { deadTombstones[id] = nil }
    public func isTombstoned(_ id: String) async -> Bool { deadTombstones[id] != nil }
}
