import SwiftUI
import PhysarumEngine
/// Owns the live `PresetSlots`, persists every mutation through a `SlotBackend`, seeds from the factory on
/// first run, and reloads when another device changes the store. `@Observable` so the library + ATV react.
/// Main-only (like the rest of the model); the KVS backend hops its notification to main before calling back.
@Observable
final class SlotStore {
private(set) var slots: PresetSlots
private let backend: SlotBackend
private let factory: PresetSlots.Factory
/// Fired (on main) after an external change reloaded the slots — AppState uses it to reconcile
/// its selection/working copy against the new slot set (e.g. the selected slot was cleared or
/// edited on another device).
var onReload: (() -> Void)?
/// Whether the backend's authoritative state has been adopted yet (immediately for local backends;
/// after the initial iCloud download / fallback for KVS).
private(set) var isLoaded = false
/// Work deferred until the store is loaded (e.g. the legacy migration) — so it never runs against,
/// or persists over, a not-yet-downloaded cloud state.
private var pendingWhenLoaded: [() -> Void] = []
init(backend: SlotBackend, factory: PresetSlots.Factory) {
self.backend = backend
self.factory = factory
// Seed in memory for immediate UI, but DON'T persist yet: on a fresh device the first read can
// be empty before iCloud finishes its async download — persisting factory here would clobber the
// account's real slots/pins (the bug). The authoritative reconcile happens once loaded.
var model = PresetSlots(payload: backend.read())
model.seed(factory)
self.slots = model
backend.onExternalChange = { [weak self] in self?.reloadFromBackend() }
backend.onLoaded = { [weak self] authoritative in self?.reconcileAfterLoad(authoritative: authoritative) }
if backend.isLoaded { reconcileAfterLoad(authoritative: true) }
}
/// The backend's authoritative state is now available. Adopt it, seed any empty/untouched slots
/// (true-first-run, or a newer factory version), and persist ONLY when `authoritative` AND seeding
/// actually changed something — so a synced device never clobbers the cloud with factory, a fresh
/// account gets established, and a mere fallback-timeout never pushes factory upward.
private func reconcileAfterLoad(authoritative: Bool) {
guard !isLoaded else { return }
isLoaded = true
var model = PresetSlots(payload: backend.read())
let beforeSeed = model
model.seed(factory)
slots = model
if authoritative, model != beforeSeed { backend.write(model.payload) }
onReload?()
let blocks = pendingWhenLoaded
pendingWhenLoaded = []
blocks.forEach { $0() }
}
/// Run `block` once the store is loaded (immediately if already loaded). Used to defer the legacy
/// migration past the initial iCloud sync so it doesn't clobber not-yet-downloaded cloud data.
func whenLoaded(_ block: @escaping () -> Void) {
if isLoaded { block() } else { pendingWhenLoaded.append(block) }
}
/// Adopt another device's change. Does NOT write back (avoids echoing). Guards against a degenerate
/// EMPTY read mid-download: never replace a populated model with an empty payload (a partial sync
/// artifact) — real edits that empty everything don't happen (a reset writes the factory set).
func reloadFromBackend() {
let incoming = PresetSlots(payload: backend.read())
if incoming.filled.isEmpty && !slots.filled.isEmpty { return }
slots = incoming
onReload?()
}
/// Force the backend to push/pull pending changes (the app calls this on background).
func flush() { backend.flush() }
func save(_ preset: Preset, toIndex index: Int) {
var p = preset
p.thumbnail = nil // thumbnails render locally per device — never let one bloat the KVS payload
slots.fill(index: index, preset: p, userOwned: true); persist()
}
func clear(slotID: String) { slots.clear(slotID: slotID); persist() }
func pin(_ slotID: String) { slots.pin(slotID); persist() }
func unpin(_ slotID: String) { slots.unpin(slotID); persist() }
func togglePin(_ slotID: String) { slots.isPinned(slotID) ? slots.unpin(slotID) : slots.pin(slotID); persist() }
func resetToFactory() { slots.resetToFactory(factory); persist() }
func keepOnlyPinned() { slots.keepOnlyPinned(); persist() }
/// Returns overflow customs that didn't fit (caller exports them).
@discardableResult func migrate(customs: [Preset]) -> [Preset] {
let overflow = slots.migrate(customs: customs); persist(); return overflow
}
private func persist() { backend.write(slots.payload) }
}