import Foundation
import IndieSync
import SwiftData
import Observation
import os

enum ICloudStatus: Equatable {
    case checking
    case available
    case unavailable
}

/// Orchestrates the iCloud Drive file layer and the SwiftData cache. iCloud is
/// the sole source of truth. Every save/delete mirrors into the cache
/// immediately (a same-process write doesn't reliably wake the `NSMetadataQuery`
/// observer — and never does in the simulator — so waiting on it leaves the UI
/// blind to the user's own action) and queues the file write in a per-document
/// slot backlog (`WriteBacklog`), drained serially with backoff. A failed write
/// therefore never silently evaporates an edit: it stays in its slot, the cache
/// keeps showing it, and `writeQueueState` surfaces the stall to the UI.
/// The observer and the connect-time reconcile re-apply idempotently and remain
/// the sole channel for *remote* changes; both skip documents superseded by a
/// queued-but-unwritten local change.
@Observable
@MainActor
final class SyncEngine {
    nonisolated static let containerIdentifier = "iCloud.dev.rzen.indie.Workouts"

    private(set) var iCloudStatus: ICloudStatus = .checking
    private(set) var isSyncing = false

    /// Resolved `Documents/` directory inside the ubiquity container — the root of
    /// the on-disk JSON document tree (`Splits/`, `Workouts/`, `Stubs/`) and the
    /// backup layer's `backupRoot`. Set once the container resolves in `connect()`;
    /// nil until then. Read-only for callers.
    private(set) var containerDocumentsURL: URL?

    /// True while a backup restore is replacing the document tree. The restore
    /// suspends the metadata observer itself (`beginRestore`/`endRestore`); this
    /// flag additionally vetoes a concurrent `connect()`/reconcile (e.g. a
    /// scenePhase foreground-return) so the two can't fight over the cache while a
    /// bulk file mirror is in flight.
    private(set) var isRestoring = false

    /// Last non-fatal sync failure, surfaced for the UI to render. Cleared on the
    /// next successful write or full reconcile.
    private(set) var lastSyncError: String?

    /// Rollup of the write backlog for the UI banner tiers (see `WriteBacklog`).
    private(set) var writeQueueState: WriteQueueState = .idle

    /// Number of queued-but-unwritten document writes.
    var pendingWriteCount: Int { backlog.count }

    /// Maps a seed's id to its clone's id after a clone-on-edit fork, so a view still
    /// holding the seed's id resolves to the live clone. In-memory only (@Observable
    /// notifies on change); not persisted — durability comes from `repointWorkouts`,
    /// which rewrites `splitID` on the workout documents themselves at fork time.
    /// This map only bridges views that captured the seed's id before the swap.
    private(set) var cloneRedirects: [String: String] = [:]

    /// Follow the redirect chain from `id` to the current live split id. A seed
    /// redirects at most once, but the loop tolerates (and breaks) any chain or cycle.
    func currentSplitID(for id: String) -> String {
        var current = id
        var seen: Set<String> = [current]
        while let next = cloneRedirects[current], seen.insert(next).inserted {
            current = next
        }
        return current
    }

    /// Called after the cache changes (local or remote). The watch bridge uses
    /// this to push fresh state to the watch.
    var onCacheChanged: (() -> Void)?

    private let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "sync")
    private let modelContainer: ModelContainer
    private var store: DocumentFileStore?
    private var tombstones: TombstoneStore?
    private var monitor: MetadataObserver?
    private var monitorTask: Task<Void, Never>?
    private var connectAttempt = 0
    private var backlog: WriteBacklog
    private let backlogURL: URL
    private var drainTask: Task<Void, Never>?

    /// How long `connect()` keeps polling for a still-provisioning iCloud
    /// container before falling to the end-of-the-line gate. Deliberately long
    /// (~10 min): as long as the user is signed into iCloud, a container that's
    /// slow to come online should never be misreported as unavailable. Impatient
    /// users bail sooner via the connecting screen's escape hatch (28s).
    private static let connectTimeoutSeconds: TimeInterval = 600

    private var context: ModelContext { modelContainer.mainContext }

    init(container: ModelContainer, backlogURL: URL = WorkoutsModelContainer.pendingWritesURL) {
        self.modelContainer = container
        self.backlogURL = backlogURL
        // Writes queued in a previous run survive relaunch; anything stuck past
        // the TTL is dropped rather than drained stale into a container that has
        // moved on. (An account change wipes the sidecar file before this load —
        // see `WorkoutsModelContainer.wipeIfAccountChanged`.)
        var loaded = WriteBacklogFile.load(from: backlogURL)
        loaded.pruneExpired(now: Date())
        self.backlog = loaded
        refreshQueueState()
    }

    // MARK: - Connection (deferred, patient)

    func connect() async {
        // Never reconnect/reconcile mid-restore: the backup layer is rewriting the
        // whole document tree and rebuilding the cache itself. Balanced by the
        // restore clearing `isRestoring` in `endRestore()`.
        guard !isRestoring else { log.info("connect: skipped — restore in progress"); return }
        guard iCloudStatus != .available else { return }
        connectAttempt += 1
        let attempt = connectAttempt
        iCloudStatus = .checking
        log.info("connect[\(attempt)]: resolving container \(Self.containerIdentifier, privacy: .public)")

        // Definitive failure first: if the user isn't signed into iCloud at all,
        // no container is ever coming — go straight to the end-of-the-line gate
        // rather than spinning. (Signed-in-but-Drive-off still reports a token;
        // that case falls through to the patient poll below and the escape hatch.)
        let signedIn = await Task.detached {
            FileManager.default.ubiquityIdentityToken != nil
        }.value
        guard attempt == connectAttempt else { return }
        guard signedIn else {
            log.error("connect[\(attempt)]: not signed into iCloud → unavailable")
            iCloudStatus = .unavailable
            return
        }

        // Signed in, but the container may still be provisioning — common right
        // after enabling iCloud Drive. Keep polling patiently: we'd rather hold the
        // spinner than misreport a working account as unavailable. We only give up
        // after a considerable timeout; the user can bail sooner via the connecting
        // screen's escape hatch (which bumps connectAttempt and stops this loop).
        var resolved: URL?
        let deadline = Date().addingTimeInterval(Self.connectTimeoutSeconds)
        while resolved == nil {
            let url = await Task.detached {
                FileManager.default.url(forUbiquityContainerIdentifier: Self.containerIdentifier)
            }.value
            guard attempt == connectAttempt else { return }
            if let url {
                resolved = url
                break
            }
            if Date() >= deadline {
                log.error("connect[\(attempt)]: container still nil after \(Int(Self.connectTimeoutSeconds))s → unavailable")
                iCloudStatus = .unavailable
                return
            }
            log.info("connect[\(attempt)]: container nil — still provisioning, retrying")
            try? await Task.sleep(for: .seconds(2))
            guard attempt == connectAttempt else { return }
        }
        guard let containerURL = resolved else { return }
        log.info("connect[\(attempt)]: container URL = \(containerURL.path, privacy: .public)")

        let store = DocumentFileStore(root: containerURL.appendingPathComponent("Documents", isDirectory: true))

        // Safety net only: prepareDirectories is a local op that effectively never
        // blocks, but if the first container file op ever wedges we don't want an
        // eternal spinner. This is generous — it isn't the connect path's clock.
        let safety = Task { [weak self] in
            try? await Task.sleep(for: .seconds(30))
            guard let self, !Task.isCancelled else { return }
            if self.iCloudStatus == .checking, attempt == self.connectAttempt {
                self.log.error("connect[\(attempt)]: prepareDirectories wedged 30s → unavailable")
                self.iCloudStatus = .unavailable
            }
        }
        log.info("connect[\(attempt)]: preparing directories…")
        await store.prepareDirectories(["Splits", "Workouts", "Stubs"])
        safety.cancel()
        guard attempt == connectAttempt else { return }

        self.store = store
        self.containerDocumentsURL = store.rootURL
        self.tombstones = TombstoneStore(store: store)
        iCloudStatus = .available
        log.info("connect[\(attempt)]: directories ready → available")
        WorkoutsModelContainer.persistCurrentIdentityToken()

        // Deliver any backlog surviving from a previous run before reconcile
        // lists the tree — reconcile skips pending ids anyway, but the sooner
        // queued edits hit disk the smaller that window is.
        kickDrain()
        await reconcile()
        startMonitoring(documentsURL: store.rootURL)
        cleanupOldStubs()
        // Off the connect path so opening the gate isn't delayed by the settle wait.
        Task { await self.seedOrReconcile() }
    }

    /// Invoked from the connecting screen when the user chooses not to keep
    /// waiting. Bumps `connectAttempt` to stop the in-flight poll loop, then drops
    /// to the end-of-the-line gate (with its Try Again).
    func abandonWaiting() {
        guard iCloudStatus == .checking else { return }
        connectAttempt += 1
        iCloudStatus = .unavailable
        log.info("connect: abandoned by user → unavailable")
    }

    // MARK: - Backup restore lifecycle

    /// Rebuild the SwiftData cache from whatever documents are currently on disk.
    /// The cache is a pure read-through projection of the files, so a full
    /// `reconcile()` — import every live file, prune every entity with no backing
    /// file — *is* a rebuild. Called by the backup layer after a restore has
    /// replaced the document tree; safe whenever the engine is connected (a nil
    /// store, i.e. not yet connected, no-ops and the next `connect()` rebuilds).
    func rebuildCache() async {
        await reconcile()
    }

    /// Suspend live file-watching for the duration of a restore. A restore mirrors
    /// many files at once; without this the observer would replay each as a live
    /// edit. Sets `isRestoring` (so a concurrent `connect()` bails) and tears down
    /// the metadata observer. Always balanced by `endRestore()`.
    func beginRestore() {
        isRestoring = true
        monitorTask?.cancel()
        monitorTask = nil
        monitor?.stop()
        monitor = nil
        // Queued writes reference pre-restore state; draining them into the
        // restored tree would corrupt it with edits the user chose to roll back.
        drainTask?.cancel()
        drainTask = nil
        backlog.removeAll()
        persistBacklog()
        refreshQueueState()
        log.info("restore: began — metadata observer suspended, write backlog cleared")
    }

    /// Resume live file-watching after a restore. Starts a FRESH metadata observer
    /// (which re-baselines its known paths, so the just-restored files don't replay
    /// as a flood of add/remove events) and clears `isRestoring`. Balanced with
    /// `beginRestore()`; the backup layer calls this on both success and failure.
    func endRestore() {
        isRestoring = false
        if let store {
            startMonitoring(documentsURL: store.rootURL)
        }
        log.info("restore: ended — metadata observer resumed")
    }

    // MARK: - Monitoring

    private func startMonitoring(documentsURL: URL) {
        monitorTask?.cancel()
        let monitor = MetadataObserver(documentsURL: documentsURL)
        self.monitor = monitor
        monitor.start()
        monitorTask = Task { [weak self] in
            for await batch in monitor.events() {
                await self?.handle(batch)
            }
        }
    }

    private func handle(_ batch: [FileChangeEvent]) async {
        for event in batch {
            switch event {
            case .added(let path), .modified(let path):
                if path.hasPrefix("Stubs/") {
                    let id = idFromStubPath(path)
                    deleteCachedEntity(id: id)
                    // A stub can arrive before (or instead of) the deleting device's
                    // live-file removal; drop any live file for this id so the delete
                    // takes effect and reconcile can't re-import it.
                    await removeLiveFile(forID: id)
                } else {
                    await importFile(relativePath: path)
                }
            case .removed(let path):
                if !path.hasPrefix("Stubs/") {
                    deleteCachedEntity(jsonRelativePath: path)
                }
            }
        }
        do { try context.save() } catch { report("Cache save failed", error) }
        onCacheChanged?()
    }

    /// Apply a workout received from the watch — `save(workout:)` mirrors it into
    /// the cache and queues the file write, same as a local edit.
    func ingestFromWatch(_ doc: WorkoutDocument) async {
        // A workout deleted on the phone leaves a tombstone; a watch that missed the
        // delete may still push its stale copy. Honor the veto — never resurrect it —
        // and re-push authoritative state (via onCacheChanged → pushAll) so the watch
        // replaces its stale set and drops the deleted workout.
        if let tombstones, await tombstones.stubExists(id: doc.id) {
            onCacheChanged?()
            return
        }
        // A queued-but-unwritten delete is the same veto — its stub just hasn't
        // landed on disk yet.
        if case .delete = backlog.pendingWrite(for: doc.id)?.payload {
            onCacheChanged?()
            return
        }
        // `updatedAt` intake gate: the watch mixes `sendMessage` with a queued
        // `transferUserInfo` fallback, which are unordered — a failed-then-queued
        // older edit can arrive after a newer one. Accept only what's strictly
        // newer than the cache; the cache mirrors every accepted write
        // immediately, so it's always at least as new as any pending slot.
        // Strictly older means the watch is behind — re-push authoritative state
        // so it corrects. Equal is the duplicate/echo case — ignore silently.
        //
        // KNOWN LIMIT (deliberate, revisit if users report vanished sets): this
        // gate arbitrates *timestamps*, not *content*. A doc edited on a stale
        // watch snapshot carries a fresh stamp and replaces the whole workout —
        // concurrent edits inside one push round-trip (or a disconnection) lose
        // one side wholesale. The watch-side absorb (WorkoutLogListView's
        // onChange re-seed) narrows the stale window to one push latency; the
        // durable fix is per-log merge: give WorkoutLogDocument its own optional
        // `updatedAt` (same additive pattern as startedAt/completedAt), merge
        // incoming docs log-by-log (newer log wins per id) instead of replacing,
        // and carry log add/remove as explicit intents so an absent log is never
        // ambiguous between "deleted" and "not seen yet". That makes edits to
        // different logs commute, so delivery order and offline gaps stop
        // mattering entirely.
        if let cached = CacheMapper.fetchWorkout(id: doc.id, in: context) {
            if doc.updatedAt < cached.updatedAt {
                onCacheChanged?()
                return
            }
            if doc.updatedAt == cached.updatedAt { return }
        }
        await save(workout: doc)
    }

    // MARK: - Public CRUD (mirror-first: cache now, file via the write queue)

    /// Returns the *effective* id of the split that was written — normally `doc.id`,
    /// but the clone's fresh id when an edited seed forks. Open views follow the swap
    /// via `currentSplitID(for:)`.
    @discardableResult
    func save(split doc: SplitDocument) async -> String {
        // Seeds are immutable: a real edit forks the seed into a user-owned split and
        // soft-deletes the original, keeping the curated seed intact and restorable. A
        // pristine (no-op) save must NOT fork — the edit sheets stamp `updatedAt`
        // unconditionally, so merely opening and saving a seed's editor without a change
        // would otherwise clone it.
        if SeedLibrary.isSeed(id: doc.id), !SeedLibrary.isPristine(doc) {
            return await cloneSeedOnEdit(doc)
        }

        CacheMapper.upsertSplit(doc, relativePath: doc.relativePath, into: context)
        saveCacheAndNotify()
        enqueueWrite(.split(doc), timestamp: doc.updatedAt)
        return doc.id
    }

    /// Fork an edited seed into a user-owned split. Writes the clone under a fresh
    /// ULID and upserts it into the cache immediately (same rationale as
    /// `ingestFromWatch`: a same-process write doesn't reliably wake the metadata
    /// observer, and open screens must see the clone the moment `save` returns), then
    /// soft-deletes the seed and drops its cache entity. Records the id redirect so a
    /// view still holding the seed's id follows the identity swap.
    private func cloneSeedOnEdit(_ doc: SplitDocument) async -> String {
        guard let store else { return doc.id }
        var clone = doc
        clone.id = ULID.make()
        clone.createdAt = Date()
        // Exercise ids are kept as-is — they only need uniqueness within the document.
        do {
            try await store.write(clone, to: clone.relativePath)
            CacheMapper.upsertSplit(clone, relativePath: clone.relativePath, into: context)
            // Soft-delete the seed (writes its veto stub, then removes the live file),
            // then evict its now-orphaned cache entity.
            await softDelete(id: doc.id, kind: "split", livePath: doc.relativePath)
            deleteCachedEntity(id: doc.id)
            try context.save()
            cloneRedirects[doc.id] = clone.id
            lastSyncError = nil
            await repointWorkouts(from: doc.id, to: clone.id)
            onCacheChanged?()
            return clone.id
        } catch {
            report("Failed to fork edited starter split", error)
            return doc.id
        }
    }

    /// Rewrite `splitID` on every workout that references `oldID`, so split lookups
    /// (category grouping, add-exercise, plan mirroring, health estimates) keep
    /// resolving after a seed fork — durably, across relaunches, and on the watch,
    /// which the in-memory `cloneRedirects` map can't reach. `splitName` stays frozen
    /// at what the workout was started as, matching rename semantics for regular
    /// splits. Best effort per workout: a failed rewrite is reported and the redirect
    /// map still covers it for this session.
    private func repointWorkouts(from oldID: String, to newID: String) async {
        guard let store else { return }
        let referencing = (try? context.fetch(
            FetchDescriptor<Workout>(predicate: #Predicate { $0.splitID == oldID })
        )) ?? []
        guard !referencing.isEmpty else { return }
        for workout in referencing {
            var wDoc = WorkoutDocument(from: workout)
            wDoc.splitID = newID
            wDoc.updatedAt = Date()
            do {
                try await store.write(wDoc, to: wDoc.relativePath)
                CacheMapper.upsertWorkout(wDoc, relativePath: wDoc.relativePath, into: context)
            } catch {
                report("Failed to repoint workout \(wDoc.id) at edited split", error)
            }
        }
        do { try context.save() } catch { report("Cache save failed", error) }
    }

    /// Push edited machine settings onto the originating split's exercise (matched
    /// by name — logs reference exercises by name only), following the seed
    /// clone-on-edit redirect so the live clone is updated. Skips silently when the
    /// split is gone or no exercise matches, and when nothing changed (so a pristine
    /// seed isn't needlessly forked). Never caches the split's id across the save,
    /// since saving a seed mints a new one.
    func writeBackMachineSettings(_ settings: [MachineSetting], exerciseName: String, splitID: String?) async {
        guard let splitID else { return }
        let liveID = currentSplitID(for: splitID)
        guard let split = CacheMapper.fetchSplit(id: liveID, in: context) else { return }
        var splitDoc = SplitDocument(from: split)
        guard let idx = splitDoc.exercises.firstIndex(where: { $0.name == exerciseName }) else { return }
        guard splitDoc.exercises[idx].machineSettings != settings else { return }
        splitDoc.exercises[idx].machineSettings = settings
        splitDoc.updatedAt = Date()
        await save(split: splitDoc)
    }

    func save(workout doc: WorkoutDocument) async {
        // The month bucket in a workout's path derives from `start`, so editing the
        // start date (or a device in a different time zone) can move the file to a new
        // path. Capture the previously-written path before the upsert overwrites it —
        // the queued write removes it after landing, otherwise the same id would live
        // at two paths and the old copy would re-import on the next reconcile.
        let previousPath = CacheMapper.fetchWorkout(id: doc.id, in: context)?.jsonRelativePath
        CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
        saveCacheAndNotify()
        enqueueWrite(
            .workout(doc),
            timestamp: doc.updatedAt,
            stalePath: previousPath != doc.relativePath ? previousPath : nil
        )
    }

    func delete(split: Split) async {
        let id = split.id, livePath = split.jsonRelativePath
        deleteCachedEntity(id: id)
        saveCacheAndNotify()
        enqueueWrite(.delete(id: id, kind: "split", livePath: livePath), timestamp: Date())
    }

    func delete(workout: Workout) async {
        let id = workout.id, livePath = workout.jsonRelativePath
        deleteCachedEntity(id: id)
        saveCacheAndNotify()
        enqueueWrite(.delete(id: id, kind: "workout", livePath: livePath), timestamp: Date())
    }

    /// Persist pending cache mutations and fan out the change notification — the
    /// tail of every local write's immediate cache mirror.
    private func saveCacheAndNotify() {
        do { try context.save() } catch { report("Cache save failed", error) }
        onCacheChanged?()
    }

    /// Writes a tombstone stub then removes the live file, immediately and outside
    /// the write queue. Used only by the multi-step flows that need their file ops
    /// ordered within a larger transaction (`cloneSeedOnEdit`, `performCleanup`);
    /// plain user deletes go through the queue as `.delete` payloads.
    private func softDelete(id: String, kind: String, livePath: String) async {
        guard let store, let tombstones else { return }
        do {
            try await tombstones.writeTombstone(Tombstone(id: id, deletedAt: Date(), kind: kind))
            try await store.remove(at: livePath)
            lastSyncError = nil
        } catch {
            report("Failed to delete \(id)", error)
        }
    }

    // MARK: - Write queue

    /// Queue a file write whose cache mirror has already been applied, persist the
    /// backlog, and make sure the drain loop is running. The per-id slot keeps only
    /// the newest version (`WriteBacklog.enqueue` is newer-wins on `timestamp`).
    private func enqueueWrite(_ payload: PendingWrite.Payload, timestamp: Date, stalePath: String? = nil) {
        var write = PendingWrite(payload: payload, timestamp: timestamp, enqueuedAt: Date())
        if let stalePath { write.stalePaths.insert(stalePath) }
        backlog.enqueue(write)
        persistBacklog()
        refreshQueueState()
        kickDrain()
    }

    private func kickDrain() {
        guard drainTask == nil, store != nil, !isRestoring else { return }
        drainTask = Task { [weak self] in
            await self?.drainBacklog()
            guard let self else { return }
            self.drainTask = nil
            // A write enqueued in the same beat the loop was exiting finds
            // `drainTask` still set and skips its kick — re-check. No spin: the
            // loop only exits non-empty when the kick guard blocks anyway.
            if !self.backlog.isEmpty { self.kickDrain() }
        }
    }

    /// Serial delivery of the backlog: one write at a time, oldest due slot first,
    /// exponential backoff per entry. Sleeps in short quanta while nothing is due
    /// so a fresh enqueue never waits out a long backoff.
    private func drainBacklog() async {
        while store != nil, !isRestoring, !backlog.isEmpty {
            guard let entry = backlog.nextDue(at: Date()) else {
                try? await Task.sleep(for: .seconds(1))
                continue
            }
            await attempt(entry)
        }
    }

    /// Try every pending write immediately, ignoring backoff — the app is about to
    /// background (last chance before suspension), or a test needs determinism.
    func flushPendingWrites() async {
        guard store != nil, !isRestoring else { return }
        for entry in backlog.entries {
            await attempt(entry)
        }
    }

    private func attempt(_ entry: PendingWrite) async {
        switch await perform(entry) {
        case .success:
            backlog.resolve(id: entry.documentID, ifTimestampAtMost: entry.timestamp)
        case .retry:
            backlog.markFailed(id: entry.documentID, at: Date())
        case .fault(let message):
            backlog.markFailed(id: entry.documentID, at: Date(), fault: message)
        }
        persistBacklog()
        refreshQueueState()
    }

    private enum WriteOutcome {
        case success
        case retry
        case fault(String)
    }

    private func perform(_ entry: PendingWrite) async -> WriteOutcome {
        guard let store, let tombstones else { return .retry }
        do {
            switch entry.payload {
            case .split(let doc):
                try await store.write(doc, to: doc.relativePath)
            case .workout(let doc):
                try await store.write(doc, to: doc.relativePath)
            case .delete(let id, let kind, let livePath):
                // The stub is the authoritative delete record (it vetoes
                // resurrection everywhere); the live-file removal is best-effort —
                // the observer's stub handling and reconcile both reap a straggler.
                try await tombstones.writeTombstone(Tombstone(id: id, deletedAt: entry.timestamp, kind: kind))
                try? await store.remove(at: livePath)
            }
            for stale in entry.stalePaths where stale != entry.targetPath {
                try? await store.remove(at: stale)
            }
            return .success
        } catch {
            log.error("write queue: \(entry.documentID, privacy: .public) failed (attempt \(entry.attempts + 1)): \(error)")
            if let message = Self.unrecoverableDescription(for: error) { return .fault(message) }
            return .retry
        }
    }

    /// Errors no amount of retrying fixes — escalate the banner immediately.
    /// (The drain still retries them on the long cadence; conditions can change.)
    private static func unrecoverableDescription(for error: Error) -> String? {
        let ns = error as NSError
        guard ns.domain == NSCocoaErrorDomain else { return nil }
        switch ns.code {
        case NSFileWriteOutOfSpaceError:
            return "iPhone storage is full — changes can't be saved to iCloud."
        case NSFileWriteVolumeReadOnlyError:
            return "iCloud storage is read-only — changes can't be saved."
        default:
            return nil
        }
    }

    private func persistBacklog() {
        WriteBacklogFile.save(backlog, to: backlogURL)
    }

    private func refreshQueueState() {
        let state: WriteQueueState
        if backlog.isEmpty {
            state = .idle
        } else if let fault = backlog.firstFaultMessage {
            state = .fault(fault)
        } else if backlog.maxAttempts >= WriteBacklog.tier2AttemptThreshold {
            state = .fault("Changes aren't reaching iCloud.")
        } else if backlog.maxAttempts >= WriteBacklog.tier1AttemptThreshold {
            state = .retrying
        } else {
            state = .pending
        }
        if state != writeQueueState { writeQueueState = state }
    }

    /// True when a queued-but-unwritten local change for `id` is at least as new
    /// as the file content just read — the cache already reflects the newer
    /// pending version, so importing the older file would regress it until the
    /// drain rewrites. (A pending `.delete` compares via its deletion stamp and
    /// keeps the import from resurrecting the entity.)
    private func supersededByPendingWrite(id: String, fileTimestamp: Date) -> Bool {
        guard let pending = backlog.pendingWrite(for: id) else { return false }
        return pending.timestamp >= fileTimestamp
    }

    // MARK: - Import / reconcile

    private func importFile(relativePath: String) async {
        guard let store, let tombstones else { return }
        let data: Data
        do {
            data = try await store.readData(from: relativePath)
        } catch {
            // Includes eviction-download timeouts — log loudly, never silently
            // treat an unreadable file as absent. The next monitor event or
            // reconcile retries it.
            log.error("import: read failed for \(relativePath, privacy: .public): \(error)")
            report("Failed to read \(relativePath)", error)
            return
        }

        if relativePath.hasPrefix("Splits/") {
            guard let doc = try? DocumentCoder.decode(SplitDocument.self, from: data), doc.isReadable else { return }
            if await tombstones.stubExists(id: doc.id) { try? await store.remove(at: relativePath); return }
            if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { return }
            CacheMapper.upsertSplit(doc, relativePath: relativePath, into: context)
        } else if relativePath.hasPrefix("Workouts/") {
            guard let doc = try? DocumentCoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { return }
            if await tombstones.stubExists(id: doc.id) { try? await store.remove(at: relativePath); return }
            if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { return }
            CacheMapper.upsertWorkout(doc, relativePath: relativePath, into: context)
        }
    }

    /// Full sync against the current file set — imports new/changed files and
    /// prunes entities whose file is gone or tombstoned. Runs on connect so
    /// changes accumulated while the app was closed are picked up.
    private func reconcile() async {
        guard let store, let tombstones else { return }
        isSyncing = true
        defer { isSyncing = false }

        // IDs from stub filenames, not stub contents — an evicted stub must
        // still count as a tombstone or the deleted record resurrects.
        let tombstoned = await tombstones.listStubIDs()
        let dataFiles = await store.list().filter { !$0.hasPrefix("Stubs/") }

        var liveSplitIDs = Set<String>()
        var liveWorkoutIDs = Set<String>()
        var unreadablePaths = Set<String>()

        for path in dataFiles {
            let data: Data
            do {
                data = try await store.readData(from: path)
            } catch {
                // A failed read (eviction-download timeout, coordination error)
                // is not proof the record is gone — remember the path so the
                // prune below keeps its cache entity. Skipping silently here is
                // how evicted records used to vanish on storage-constrained
                // devices.
                log.error("reconcile: read failed for \(path, privacy: .public): \(error)")
                report("Failed to read \(path)", error)
                unreadablePaths.insert(path)
                continue
            }
            if path.hasPrefix("Splits/") {
                guard let doc = try? DocumentCoder.decode(SplitDocument.self, from: data), doc.isReadable else { continue }
                if tombstoned.contains(doc.id) { try? await store.remove(at: path); continue }
                liveSplitIDs.insert(doc.id)
                if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { continue }
                CacheMapper.upsertSplit(doc, relativePath: path, into: context)
            } else if path.hasPrefix("Workouts/") {
                guard let doc = try? DocumentCoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { continue }
                if tombstoned.contains(doc.id) { try? await store.remove(at: path); continue }
                liveWorkoutIDs.insert(doc.id)
                if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { continue }
                CacheMapper.upsertWorkout(doc, relativePath: path, into: context)
            }
        }

        // Prune cache entities no longer backed by a live file — but never for
        // a path that failed to read this pass (it may just be un-downloadable
        // right now; pruning would make an eviction look like a deletion), and
        // never for an id with a queued-but-unwritten write (its file simply
        // hasn't landed yet; pruning would evaporate the pending edit's mirror).
        if let splits = try? context.fetch(FetchDescriptor<Split>()) {
            for s in splits where !liveSplitIDs.contains(s.id)
                && !unreadablePaths.contains(s.jsonRelativePath)
                && backlog.pendingWrite(for: s.id) == nil {
                context.delete(s)
            }
        }
        if let workouts = try? context.fetch(FetchDescriptor<Workout>()) {
            for w in workouts where !liveWorkoutIDs.contains(w.id)
                && !unreadablePaths.contains(w.jsonRelativePath)
                && backlog.pendingWrite(for: w.id) == nil {
                context.delete(w)
            }
        }
        do {
            try context.save()
            // A fully clean pass supersedes any earlier failure; a pass with
            // unreadable files keeps its own report visible.
            if unreadablePaths.isEmpty { lastSyncError = nil }
        } catch {
            report("Cache save failed", error)
        }
        onCacheChanged?()
    }

    // MARK: - Cache deletes

    private func deleteCachedEntity(id: String) {
        if let s = CacheMapper.fetchSplit(id: id, in: context) { context.delete(s) }
        if let w = CacheMapper.fetchWorkout(id: id, in: context) { context.delete(w) }
    }

    /// Observer `.removed` handling. Skips entities with a queued-but-unwritten
    /// write: their file may simply not have landed yet (or an old-path removal
    /// raced the rewrite), and the pending edit's mirror must survive until the
    /// drain delivers it.
    private func deleteCachedEntity(jsonRelativePath path: String) {
        if let splits = try? context.fetch(FetchDescriptor<Split>(predicate: #Predicate { $0.jsonRelativePath == path })) {
            splits.filter { backlog.pendingWrite(for: $0.id) == nil }.forEach(context.delete)
        }
        if let workouts = try? context.fetch(FetchDescriptor<Workout>(predicate: #Predicate { $0.jsonRelativePath == path })) {
            workouts.filter { backlog.pendingWrite(for: $0.id) == nil }.forEach(context.delete)
        }
    }

    private func idFromStubPath(_ path: String) -> String {
        (path as NSString).lastPathComponent.replacingOccurrences(of: ".json", with: "")
    }

    /// Remove any live data file for `id` when its tombstone arrives. Matches by
    /// filename identity across every non-`Stubs/` path — deliberately NOT
    /// `resolveExistingPath(forID:)`, which would also match the stub itself.
    private func removeLiveFile(forID id: String) async {
        guard let store else { return }
        let target = "\(id).json"
        let livePaths = await store.list().filter {
            !$0.hasPrefix("Stubs/") && ($0 as NSString).lastPathComponent == target
        }
        for path in livePaths {
            try? await store.remove(at: path)
        }
    }

    // MARK: - Maintenance

    private func cleanupOldStubs() {
        guard let tombstones else { return }
        let exempt = SeedLibrary.seedIDs
        Task.detached(priority: .utility) {
            // Downloads evicted stubs to read their deletedAt, prunes past the
            // 30-day grace period. Seed tombstones veto resurrection permanently, so
            // they're exempt and never pruned.
            try? await tombstones.prune(exempting: exempt)
        }
    }

    // MARK: - Seeding

    /// True when the container holds no documents at all — not one data file and not
    /// one tombstone. A user with only workouts, or any tombstone (i.e. a prior
    /// delete), is not a new user, so we never auto-seed over them. Placeholder-aware
    /// by construction (`store.list()` sees evicted files too).
    private func containerEmpty() async -> Bool {
        guard let store else { return false }
        return await store.list().isEmpty
    }

    /// Write the starter library exactly once, only into a verifiably empty container
    /// (the true first run). Deferred and re-checked after a settle delay: an empty
    /// listing right after connect can just mean the metadata index hasn't populated
    /// yet, and seeding into that illusion would duplicate a library that's about to
    /// arrive.
    private func autoSeedIfEmpty() async {
        guard let store, await containerEmpty() else { return }
        // Give the metadata index a chance to surface existing files before we conclude
        // the account is genuinely new.
        try? await Task.sleep(for: .seconds(5))
        guard iCloudStatus == .available, await containerEmpty() else { return }

        for seed in SeedLibrary.seeds {
            do {
                // Verbatim bundle bytes: byte-identical files across devices make a
                // same-path conflict (two devices seeding at once) semantically empty.
                try await store.writeData(seed.data, to: seed.doc.relativePath)
                CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
            } catch {
                report("Failed to seed \(seed.doc.name)", error)
            }
        }
        do { try context.save() } catch { report("Cache save failed", error) }
        onCacheChanged?()
    }

    /// Post-connect seed maintenance, deferred off the connect path. Settles first —
    /// an empty or sparse listing right after connect can just mean the metadata index
    /// / placeholder files haven't surfaced existing data yet — then branches so the
    /// two seeders can never both fire: an empty container is first-run auto-seed's
    /// alone; a non-empty one is reconciled against the current bundle.
    private func seedOrReconcile() async {
        // Give existing files a chance to surface before deciding empty-vs-reconcile
        // (the same caution `autoSeedIfEmpty` also takes internally).
        try? await Task.sleep(for: .seconds(5))
        guard iCloudStatus == .available else { return }
        if await containerEmpty() {
            await autoSeedIfEmpty()
        } else {
            await reconcileSeeds()
        }
    }

    /// Bring the on-disk seed set in line with the current bundle on a NON-empty
    /// container (autoSeedIfEmpty owns the empty case). For each bundled seed, decide
    /// via the pure `SeedReconcilePlanner`:
    ///   • live file present, up to date          → skip
    ///   • live file present, older revision       → overwrite with bundle bytes (upgrade)
    ///   • live file present, newer app version     → skip (quarantine — never downgrade)
    ///   • no live file, stub present              → skip (user deleted it; veto stands)
    ///   • no live file, no stub, name in use       → skip (don't duplicate a same-name split)
    ///   • no live file, no stub, name free         → write bundle bytes
    ///
    /// Safe even on a stale metadata index: writing bundle bytes another device also
    /// wrote is a semantically empty conflict, and the stub + name guards are re-checked
    /// right before each fresh write.
    private func reconcileSeeds() async {
        guard let store, let tombstones else { return }

        let livePaths = Set(await store.list().filter { !$0.hasPrefix("Stubs/") })
        let stubIDs = await tombstones.listStubIDs()
        let liveSplitNames = Set(((try? context.fetch(FetchDescriptor<Split>())) ?? []).map(\.name))

        var didChange = false
        for seed in SeedLibrary.seeds {
            // Decode the live file at the seed's fixed path, if one exists. A file that
            // exists but can't be read right now (evicted + download timed out) is left
            // untouched — a later connect retries it.
            var liveDoc: SplitDocument?
            if livePaths.contains(seed.doc.relativePath) {
                guard let data = try? await store.readData(from: seed.doc.relativePath),
                      let decoded = try? DocumentCoder.decode(SplitDocument.self, from: data) else {
                    continue
                }
                liveDoc = decoded
            }

            let input = SeedReconcileInput(
                seedID: seed.id, seedDoc: seed.doc, liveDoc: liveDoc,
                hasStub: stubIDs.contains(seed.id)
            )

            switch SeedReconcilePlanner.decision(for: input, liveSplitNames: liveSplitNames) {
            case .skip:
                continue
            case .upgrade:
                // Re-check the veto against fresh state before overwriting — symmetric
                // with `.write`. The batch above was gathered before a settle-window
                // race: if the user forked/soft-deleted this seed (clone-on-edit writes
                // a stub and removes the live file) after we listed the live paths,
                // rewriting the seed bytes here would transiently resurrect the deleted
                // seed alongside the user's clone. The stub now present vetoes that.
                // (No name-collision guard here: on a legitimate upgrade the seed itself
                // is a live split by that name, so a name check would block every upgrade.)
                if await tombstones.stubExists(id: seed.id) { continue }
                // Overwriting an existing seed-path file with canonical bundle bytes is
                // otherwise always safe (the file can only ever be an older seed revision).
                if await writeSeedBytes(seed) { didChange = true }
            case .write:
                // Re-check the veto and name guards against fresh state — the metadata
                // index may have moved since the batch was gathered.
                if await tombstones.stubExists(id: seed.id) { continue }
                let names = Set(((try? context.fetch(FetchDescriptor<Split>())) ?? []).map(\.name))
                if names.contains(seed.doc.name) { continue }
                if await writeSeedBytes(seed) { didChange = true }
            }
        }

        if didChange {
            do { try context.save() } catch { report("Cache save failed", error) }
            onCacheChanged?()
        }
    }

    /// Force-restore the starter library on explicit user request ("Restore Starter
    /// Splits"). For each seed with no live file and no live same-name split, lift its
    /// veto stub if present — the ONE place the forever-veto is deliberately lifted —
    /// and write the CURRENT bundle bytes (not the stub's old contents; the bundle is
    /// the canonical restore source). Seeds whose live file already exists are left for
    /// `reconcileSeeds` to upgrade. Returns how many seeds were (re)written.
    @discardableResult
    func restoreSeeds() async -> Int {
        guard let store, let tombstones else { return 0 }

        let livePaths = Set(await store.list().filter { !$0.hasPrefix("Stubs/") })

        var restored = 0
        for seed in SeedLibrary.seeds {
            let liveNames = Set(((try? context.fetch(FetchDescriptor<Split>())) ?? []).map(\.name))
            guard SeedReconcilePlanner.shouldRestore(
                hasLiveFile: livePaths.contains(seed.doc.relativePath),
                seedName: seed.doc.name,
                liveSplitNames: liveNames
            ) else { continue }

            do {
                // Lift the veto: drop the seed's tombstone stub if one exists, then
                // write the verbatim bundle bytes.
                if await tombstones.stubExists(id: seed.id) {
                    try await tombstones.removeStub(at: "\(seed.id).json")
                }
                try await store.writeData(seed.data, to: seed.doc.relativePath)
                CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
                restored += 1
                lastSyncError = nil
            } catch {
                report("Failed to restore starter split \(seed.doc.name)", error)
            }
        }

        if restored > 0 {
            do { try context.save() } catch { report("Cache save failed", error) }
            onCacheChanged?()
        }
        return restored
    }

    /// Write a seed's verbatim bundle bytes and mirror it into the cache. Returns
    /// whether the write succeeded (so the caller can flush + notify once at the end).
    @discardableResult
    private func writeSeedBytes(_ seed: SeedLibrary.Seed) async -> Bool {
        guard let store else { return false }
        do {
            try await store.writeData(seed.data, to: seed.doc.relativePath)
            CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
            lastSyncError = nil
            return true
        } catch {
            report("Failed to reconcile starter split \(seed.doc.name)", error)
            return false
        }
    }

    // MARK: - Duplicate cleanup (dev)

    enum DuplicateScanError: Error, Sendable {
        case notConnected
        case unreadableFiles([String])
    }

    /// Scans every live (non-stub) document and builds a plan for exact-content
    /// duplicate splits/workouts (see `DuplicateCleanupPlanner`). Read-only — no
    /// files are touched. Fails closed: if ANY file can't be read or decoded, no
    /// plan is produced, because an unreadable workout could reference any split
    /// and silently proceeding could misjudge that split as unreferenced.
    func scanForDuplicates() async throws -> DuplicateCleanupPlan {
        guard let store else { throw DuplicateScanError.notConnected }

        let paths = await store.list().filter { !$0.hasPrefix("Stubs/") }

        var splits: [SplitDocument] = []
        var workouts: [WorkoutDocument] = []
        var referencedSplitIDs = Set<String>()
        var failedPaths: [String] = []

        for path in paths {
            let data: Data
            do {
                data = try await store.readData(from: path)
            } catch {
                log.error("scanForDuplicates: read failed for \(path, privacy: .public): \(error)")
                failedPaths.append(path)
                continue
            }

            if path.hasPrefix("Splits/") {
                do {
                    let doc = try DocumentCoder.decode(SplitDocument.self, from: data)
                    // Quarantined (written by a newer app version) splits are never
                    // judged as duplicates or deleted.
                    if doc.isReadable { splits.append(doc) }
                } catch {
                    log.error("scanForDuplicates: decode failed for \(path, privacy: .public): \(error)")
                    failedPaths.append(path)
                }
            } else if path.hasPrefix("Workouts/") {
                do {
                    let doc = try DocumentCoder.decode(WorkoutDocument.self, from: data)
                    // A quarantined workout's splitID still protects that split from
                    // deletion even though the workout itself is excluded below.
                    if let splitID = doc.splitID {
                        referencedSplitIDs.insert(splitID)
                        referencedSplitIDs.insert(currentSplitID(for: splitID))
                    }
                    if doc.isReadable { workouts.append(doc) }
                } catch {
                    log.error("scanForDuplicates: decode failed for \(path, privacy: .public): \(error)")
                    failedPaths.append(path)
                }
            }
        }

        guard failedPaths.isEmpty else {
            throw DuplicateScanError.unreadableFiles(failedPaths.sorted())
        }

        referencedSplitIDs.formUnion(cloneRedirects.values)

        return DuplicateCleanupPlanner.plan(
            splits: splits,
            workouts: workouts,
            referencedSplitIDs: referencedSplitIDs,
            isSeed: SeedLibrary.isSeed(id:)
        )
    }

    /// Executes a previously scanned plan. Every deletion is re-checked
    /// immediately beforehand against the *current* cache state — a watch push or
    /// another device's write can land between scan and delete — so nothing
    /// referenced or active is ever removed even if the plan is a few seconds stale.
    func performCleanup(_ plan: DuplicateCleanupPlan) async -> (splitsDeleted: Int, workoutsDeleted: Int, skipped: Int) {
        var splitsDeleted = 0
        var workoutsDeleted = 0
        var skipped = 0

        for group in plan.splitGroups {
            for doc in group.delete {
                let splitID = doc.id
                if SeedLibrary.isSeed(id: splitID) {
                    skipped += 1
                    continue
                }
                let referencingWorkouts = (try? context.fetch(
                    FetchDescriptor<Workout>(predicate: #Predicate { $0.splitID == splitID })
                )) ?? []
                guard referencingWorkouts.isEmpty else {
                    skipped += 1
                    continue
                }
                if let cached = CacheMapper.fetchSplit(id: splitID, in: context) {
                    await softDelete(id: splitID, kind: "split", livePath: cached.jsonRelativePath)
                } else {
                    await softDelete(id: splitID, kind: "split", livePath: doc.relativePath)
                }
                deleteCachedEntity(id: splitID)
                splitsDeleted += 1
            }
        }

        for group in plan.workoutGroups {
            for doc in group.delete {
                let cached = CacheMapper.fetchWorkout(id: doc.id, in: context)
                if cached?.status == .inProgress {
                    skipped += 1
                    continue
                }
                let livePath = cached?.jsonRelativePath ?? doc.relativePath
                await softDelete(id: doc.id, kind: "workout", livePath: livePath)
                deleteCachedEntity(id: doc.id)
                workoutsDeleted += 1
            }
        }

        saveCacheAndNotify()
        return (splitsDeleted, workoutsDeleted, skipped)
    }

    // MARK: - Error Reporting

    /// Record a non-fatal sync failure: log it and publish it as `lastSyncError`
    /// for the UI to render. Replaces silent `try?` / print-only handling.
    private func report(_ message: String, _ error: Error? = nil) {
        let full = error.map { "\(message): \($0.localizedDescription)" } ?? message
        log.error("\(full, privacy: .public)")
        lastSyncError = full
    }
}
