import Foundation
import NucleicProtocol

/// A `PairedDeviceStore` that persists its pins to a JSON file on disk, so paired-device
/// records survive a host restart — including the frequent relaunches a canary build does when
/// it auto-updates.
///
/// Why this matters: the host pins each phone's static key at pairing and checks it on every
/// reconnect. With the old in-memory-only store, every sync-server restart forgot every paired
/// phone, so the phone's next IK reconnect looked up an *unknown* device and was rejected
/// `.unauthorized`. Because that rejection happens *before* the Noise session is established, the
/// host can't seal an error frame to explain it — it just drops the socket, and the phone
/// surfaces a bare "handshake closed" error with no way to recover but re-pair. Persisting the
/// pins keeps reconnects working across launches.
///
/// The dataset is tiny (a handful of devices) and writes are infrequent (pair, the metadata
/// refresh on each reconnect, revoke), so the whole table lives in memory and is rewritten
/// atomically on every change. Nothing secret is stored — only the device's *public* static key,
/// its label, granted scope, and APNs token; the one-time pairing secret never touches disk — so
/// a plain file (not the Keychain) is appropriate. Load failures degrade to an empty set rather
/// than throwing, so a corrupt or unreadable file never blocks the sync server from starting: the
/// user simply re-pairs.
public actor FilePairedDeviceStore: PairedDeviceStore {
    private let url: URL
    private var devices: [String: PairedDevice]
    private var deadTombstones: [String: MeshTombstone]

    public init(url: URL) {
        self.url = url
        let loaded = Self.load(from: url)
        self.devices = loaded.devices
        self.deadTombstones = loaded.tombstones
    }

    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 drops 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
        persist()
    }

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

    // MARK: - Tombstones (mesh "join" group-wide revocation)

    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 {
            if devices[t.deviceID] != nil { devices[t.deviceID] = nil; persist() }
            return
        }
        deadTombstones[t.deviceID] = t
        devices[t.deviceID] = nil
        persist()
    }

    public func removeTombstone(_ id: String) async {
        guard deadTombstones[id] != nil else { return }
        deadTombstones[id] = nil
        persist()
    }

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

    // MARK: - Disk

    /// On-disk shape: an envelope `{devices, tombstones}`. A store written before tombstones
    /// existed is a bare `[PairedDevice]` JSON array — decoded via the fallback as
    /// `{devices, tombstones: []}`, so old pins load unchanged.
    private struct Envelope: Codable {
        var devices: [PairedDevice]
        var tombstones: [MeshTombstone]
    }

    private static func load(
        from url: URL
    ) -> (devices: [String: PairedDevice], tombstones: [String: MeshTombstone]) {
        guard let data = try? Data(contentsOf: url) else { return ([:], [:]) }
        if let env = try? JSONDecoder().decode(Envelope.self, from: data) {
            return (
                Dictionary(env.devices.map { ($0.deviceID, $0) }, uniquingKeysWith: { _, b in b }),
                Dictionary(env.tombstones.map { ($0.deviceID, $0) }, uniquingKeysWith: { _, b in b }))
        }
        // Legacy bare array (pre-tombstone store).
        if let list = try? JSONDecoder().decode([PairedDevice].self, from: data) {
            return (Dictionary(list.map { ($0.deviceID, $0) }, uniquingKeysWith: { _, b in b }), [:])
        }
        return ([:], [:])
    }

    private func persist() {
        do {
            try FileManager.default.createDirectory(
                at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
            let env = Envelope(
                devices: Array(devices.values), tombstones: Array(deadTombstones.values))
            let data = try JSONEncoder().encode(env)
            try data.write(to: url, options: .atomic)
        } catch {
            // Best-effort: a write failure (disk full, permissions) leaves the in-memory set
            // authoritative for this run; the pin just won't survive the next restart.
        }
    }
}
