import Foundation
import IndieSync
import SwiftData

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

/// Orchestrates iCloud JSON files (the sole source of truth) and the
/// SwiftData cache. Also exposes the domain operations the UI calls: create,
/// update, delete a note.
///
/// The file layer lives in the IndieSync package: `DocumentFileStore` for
/// coordinated, conflict-resolved, eviction-safe I/O; `TombstoneStore` for
/// soft-delete stubs; `MetadataObserver` for change events. Data flows one
/// way — writes touch files only; the observers are the sole writers of the
/// cache, so a local save reaches it through the same pipeline as a change
/// from another device.
///
/// Layout inside the container (record paths in this engine are
/// Records-relative and get prefixed via `dataPath(_:)` at the store
/// boundary):
/// ```
/// Documents/
///   Records/YYYY/MM/<ULID>.json
///   Stubs/YYYY/MM/<ULID>.json      ← soft-delete markers
/// ```
@Observable
@MainActor
final class SyncEngine {
    private(set) var iCloudStatus: ICloudStatus = .checking
    private(set) var isSyncing = false

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

    /// Fired after a watch recording is ingested, carrying the new note's id, so
    /// the composition root can enqueue on-device transcription. Wired by
    /// `AppServices`; nil until then, so ingestion is safe before the
    /// transcription stack exists.
    var onAudioNoteIngested: ((ULID) -> Void)?

    /// Fired when a staged watch recording reaches a **terminal** state on this
    /// device — durably ingested, or permanently vetoed (tombstoned / already
    /// present). The composition root wires this to send the ingest ack that
    /// lets the watch delete its local copy; the watch keeps (and redelivers)
    /// anything never acked, so transient failures stay silent here on purpose.
    var onWatchRecordingHandled: ((String) -> Void)?

    static let recordsDirectory = "Records"
    static let stubsDirectory = "Stubs"

    /// Store-rooted path of a record (record paths are Records-relative
    /// everywhere inside this engine).
    static func dataPath(_ recordsRelativePath: String) -> String {
        "\(recordsDirectory)/\(recordsRelativePath)"
    }

    private let modelContainer: ModelContainer
    private var store: DocumentFileStore?
    private var tombstones: TombstoneStore?
    private var dataMonitor: MetadataObserver?
    private var stubMonitor: MetadataObserver?
    private var dataMonitorTask: Task<Void, Never>?
    private var stubMonitorTask: Task<Void, Never>?

    /// Guards `ingestStagedWatchRecordings` against overlapping triggers (connect,
    /// foreground reconcile, receiver callback) double-ingesting the same inbox.
    private var isDrainingInbox = false

    private var cacheContext: ModelContext { modelContainer.mainContext }

    init(modelContainer: ModelContainer) {
        self.modelContainer = modelContainer
    }

    // MARK: - Connection

    private var connectAttempt = 0

    /// Resolves the ubiquity container and connects. Safe to call repeatedly —
    /// the gate's "Try Again" button and foreground reactivation reuse it.
    func connect() async {
        guard iCloudStatus != .available else { return }
        connectAttempt += 1
        let attempt = connectAttempt
        iCloudStatus = .checking

        // Blocking call — never on the main thread.
        let url = await Task.detached {
            FileManager.default.url(forUbiquityContainerIdentifier: nil)
        }.value

        guard let containerURL = url else {
            if attempt == connectAttempt {
                iCloudStatus = .unavailable
            }
            return
        }

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

        // File ops inside the container can hang indefinitely while the
        // iCloud account is in a bad state — flip the gate to "unavailable"
        // after a timeout, but let the attempt keep running and finish
        // connecting if it eventually succeeds.
        let timeout = Task { [weak self] in
            try? await Task.sleep(for: .seconds(20))
            guard let self, !Task.isCancelled else { return }
            if self.iCloudStatus == .checking, attempt == self.connectAttempt {
                self.iCloudStatus = .unavailable
            }
        }

        await store.prepareDirectories([Self.recordsDirectory, Self.stubsDirectory])
        timeout.cancel()
        guard attempt == connectAttempt else { return }   // superseded by a retry

        self.store = store
        self.tombstones = TombstoneStore(store: store, stubsDirectory: Self.stubsDirectory)
        iCloudStatus = .available

        startMonitoring()
        await syncCacheOnConnect()
        performLaunchMaintenance()
    }

    // MARK: - Domain operations

    @discardableResult
    func createNote(
        text: String,
        source: Note.Source = .typed,
        tags: [String] = [],
        context: CaptureContext,
        project: String? = nil
    ) async throws -> Note {
        let note = Note.create(text: text, source: source, tags: tags, context: context, project: project)
        try await save(note)
        return note
    }

    func updateNote(_ note: Note) async throws {
        var updated = note
        updated.meta.modifiedAt = Date()
        try await save(updated)
    }

    func deleteNote(id: ULID) async throws {
        // Without the file layer only the cache row could go — and the iCloud
        // file would re-import on the next rebuild, resurrecting the note.
        // Refuse instead.
        guard let tombstones else {
            report("Delete failed — iCloud not connected")
            throw SyncError.iCloudUnavailable
        }
        let path = Note.relativePath(forID: id)
        try await tombstones.softDelete(dataRelativePath: Self.dataPath(path), at: path)
        // Drop the audio sidecar too. Best-effort and harmless when the note had
        // no audio (nothing to remove); the sidecar never gets its own stub, so
        // this is the only thing that reaps it.
        if let store {
            try? await store.remove(at: Self.dataPath(Note.audioRelativePath(forJSONPath: path)))
        }
        lastSyncError = nil
        // Cache cleanup happens via the monitors (file removal + stub
        // appearance both drop the row), but do it eagerly too so the UI
        // updates instantly even while iCloud event delivery lags.
        removeFromCache(jsonRelativePath: path)
    }

    /// Reads the JSON file — the authority — so read-after-write flows never
    /// act on a stale cache. Falls back to the near-lossless cache copy only
    /// if the file is unreadable (offline or not yet downloaded).
    func loadNote(id: ULID) async -> Note? {
        let path = Note.relativePath(forID: id)
        if let store,
           let note = try? await store.read(Note.self, from: Self.dataPath(path)),
           note.isReadable {
            return migrateIfNeeded(note)
        }
        guard let entity = NoteMapper.fetch(id: id.stringValue, in: cacheContext) else { return nil }
        return NoteMapper.note(from: entity)
    }

    // MARK: - Audio notes

    /// Create a voice note: write the `.m4a` sidecar **before** the JSON, so a
    /// note that claims audio can never sync to another device ahead of its
    /// bytes (which would surface a pending audio note whose `.m4a` doesn't yet
    /// exist). The note starts with empty (or provisional) text and a `.pending`
    /// transcription owned by `transcriberDeviceID`; the transcript arrives later
    /// through `applyTranscription`. Like every other write, the cache catches up
    /// via the monitor — no direct upsert here.
    @discardableResult
    func createAudioNote(
        audioFileURL: URL,
        duration: TimeInterval,
        text: String = "",
        tags: [String] = [],
        context: CaptureContext,
        transcriberDeviceID: String,
        project: String? = nil
    ) async throws -> Note {
        guard let store else {
            report("Note not saved — iCloud not connected")
            throw SyncError.iCloudUnavailable
        }
        let audio = Note.AudioInfo(
            duration: duration,
            transcriberDeviceID: transcriberDeviceID,
            transcriptionStatus: .pending,
            transcriptionLocaleID: nil,
            transcribedAt: nil
        )
        let note = Note.create(text: text, source: .transcribed, tags: tags, context: context, audio: audio, project: project)
        do {
            let audioData = try Data(contentsOf: audioFileURL)
            try await store.writeData(audioData, to: Self.dataPath(Note.audioRelativePath(forID: note.id)))
            _ = try await store.write(note, to: Self.dataPath(note.relativePath))
        } catch {
            report("Failed to save audio note", error)
            throw error
        }
        lastSyncError = nil
        return note
    }

    /// Load a note's audio bytes for playback. Fail-fast (`.requestOnly`): an
    /// evicted file kicks off a download and returns nil immediately rather than
    /// stalling the player ~30s — the UI shows a "downloading" retry state and
    /// the bytes land on a later attempt (or via `requestAudioDownload`).
    func loadAudioData(for id: ULID) async -> Data? {
        guard let store else { return nil }
        return try? await store.readData(from: audioPath(for: id), downloadPolicy: .requestOnly)
    }

    /// Load a note's audio with a bounded wait for an evicted file to download —
    /// for re-transcription, where blocking to fetch the source is acceptable
    /// (unlike interactive playback, which uses `loadAudioData`). Throws on a
    /// download timeout rather than yielding a dataless placeholder.
    func loadAudioDataWaiting(for id: ULID) async throws -> Data {
        guard let store else { throw SyncError.iCloudUnavailable }
        return try await store.readData(from: audioPath(for: id), downloadPolicy: .waitBounded)
    }

    /// Warm an evicted audio file ahead of an anticipated playback, so a later
    /// `loadAudioData` finds the bytes already local. Fire-and-forget.
    func requestAudioDownload(for id: ULID) async {
        guard let store else { return }
        await store.requestDownload(at: audioPath(for: id))
    }

    /// Store-rooted `.m4a` sidecar path for a note. Audio exists only on v2+
    /// records, which file under the current UTC bucket, so recomputing the path
    /// from the id is always correct here.
    private func audioPath(for id: ULID) -> String {
        Self.dataPath(Note.audioRelativePath(forID: id))
    }

    // MARK: - Transcription

    /// Land a completed transcription onto a note as a new attempt. Re-reads
    /// the file (the authority) so a concurrent edit isn't clobbered.
    /// `payload.text` — the user's own writing — is never touched: transcripts
    /// accumulate in `audio.attempts` and the latest one is what the UI shows.
    /// The cache updates through the monitor as usual (via `updateNote`).
    func applyTranscription(
        id: ULID,
        text: String,
        localeID: String,
        confidence: Double? = nil,
        languageWasForced: Bool = false
    ) async throws {
        guard let store else { throw SyncError.iCloudUnavailable }
        guard let raw = try? await store.read(Note.self, from: Self.dataPath(Note.relativePath(forID: id))),
              raw.isReadable else { return }
        var note = migrateIfNeeded(raw)
        guard var audio = note.payload.audio else { return }

        let now = Date()
        audio.attempts.append(Note.TranscriptionAttempt(
            text: text,
            localeID: localeID,
            confidence: confidence,
            transcribedAt: now,
            languageWasForced: languageWasForced
        ))
        audio.transcriptionStatus = .done
        audio.transcriptionLocaleID = localeID
        audio.transcribedAt = now
        note.payload.audio = audio
        try await updateNote(note)
    }

    /// Flag a note's transcription as failed (retryable only by an explicit
    /// re-transcribe). Re-reads first so it can't resurrect a since-deleted or
    /// non-audio note.
    func markTranscriptionFailed(id: ULID) async throws {
        guard let store else { throw SyncError.iCloudUnavailable }
        guard var note = try? await store.read(Note.self, from: Self.dataPath(Note.relativePath(forID: id))),
              note.isReadable, var audio = note.payload.audio else { return }
        audio.transcriptionStatus = .failed
        note.payload.audio = audio
        try await updateNote(note)
    }

    /// Reassign a note's transcription to *this* device and reset it to
    /// `.pending`, optionally forcing a language (`localeID`, else auto-detect).
    /// Claiming `transcriberDeviceID` is also the recovery path when the original
    /// recording device is gone — any device can take a stuck note over.
    func requestRetranscription(id: ULID, localeID: String?) async throws {
        guard let store else { throw SyncError.iCloudUnavailable }
        guard var note = try? await store.read(Note.self, from: Self.dataPath(Note.relativePath(forID: id))),
              note.isReadable, var audio = note.payload.audio else { return }
        audio.transcriptionStatus = .pending
        audio.transcriberDeviceID = InstallIdentity.installID
        audio.transcriptionLocaleID = localeID
        note.payload.audio = audio
        try await updateNote(note)
    }

    // MARK: - Organization

    /// Assign a note to a project (or clear it with nil). Re-reads the file —
    /// the authority — so a concurrent edit isn't clobbered; the cache catches
    /// up via the monitor as usual.
    func assignProject(id: ULID, project: String?) async throws {
        guard let store else { throw SyncError.iCloudUnavailable }
        guard let raw = try? await store.read(Note.self, from: Self.dataPath(Note.relativePath(forID: id))),
              raw.isReadable else { return }
        var note = migrateIfNeeded(raw)
        note.payload.project = project
        try await updateNote(note)
    }

    // MARK: - Watch ingestion

    /// Local directory where the phone receiver (`PhoneWatchReceiver`, a later
    /// stage) stages recordings handed over from the watch before they are
    /// ingested. It sits outside the iCloud container on purpose: the watch has
    /// no iCloud, so a delivered recording becomes durable only once
    /// `ingestWatchRecording` writes it into the container. Each staged
    /// recording is a `<ULID>.m4a` plus a `<ULID>.meta.plist` sidecar carrying
    /// `id` / `recordedAt` / `duration` / `timeZoneID`.
    /// `nonisolated` so the phone receiver can stage a delivery synchronously in
    /// its nonisolated `WCSessionDelegate` callback (the system reclaims the inbox
    /// file the moment that returns). Pure — no actor state — so it is safe to
    /// share this one source of truth for the inbox path with the drain.
    nonisolated static var watchInboxURL: URL {
        URL.applicationSupportDirectory.appending(path: "WatchInbox", directoryHint: .isDirectory)
    }

    /// Idempotent drain of the watch staging inbox. Called after `connect()`, on
    /// foreground reconcile, and (a later stage) on the receiver callback, so a
    /// recording delivered while the app was dead still lands. Serialized against
    /// itself so overlapping triggers can't double-ingest.
    func ingestStagedWatchRecordings() async {
        guard store != nil, !isDrainingInbox else { return }
        isDrainingInbox = true
        defer { isDrainingInbox = false }

        for staged in Self.stagedWatchRecordings() {
            await ingestWatchRecording(
                audioURL: staged.audioURL,
                id: staged.id,
                recordedAt: staged.recordedAt,
                duration: staged.duration,
                context: staged.context
            )
        }

        let orphans = Self.watchInboxOrphanCount()
        if orphans > 0 {
            print("[Sync] Watch inbox holds \(orphans) recording(s) without readable metadata — not ingestible by this build")
        }
    }

    /// Ingest one recording handed over from the watch — the single sanctioned
    /// direct-cache-upsert path (icloud-sync-engine §9). The phone is where the
    /// note first becomes durable, so: veto if the note was already deleted
    /// (tombstone) or already ingested (duplicate redelivery), then write audio
    /// **before** JSON (same ordering invariant as `createAudioNote`), upsert the
    /// cache directly (a WatchConnectivity delivery has no other live trigger to
    /// refresh an open list), remove the staged files, and fire
    /// `onAudioNoteIngested` so transcription can enqueue. Returns whether a new
    /// note was created.
    @discardableResult
    func ingestWatchRecording(
        audioURL: URL,
        id: ULID,
        recordedAt: Date,
        duration: TimeInterval,
        context: CaptureContext
    ) async -> Bool {
        guard let store, let tombstones else { return false }

        // Permanent skips — clear the stale staged copy so it stops re-draining,
        // and ack so the watch drops its copy too (redelivery would just loop).
        if await tombstones.stubExists(id: id.stringValue) {
            Self.removeStagedFiles(audioURL: audioURL)
            onWatchRecordingHandled?(id.stringValue)
            return false
        }
        let jsonPath = Note.relativePath(forID: id)
        if await store.fileExists(Self.dataPath(jsonPath)) {
            Self.removeStagedFiles(audioURL: audioURL)
            onWatchRecordingHandled?(id.stringValue)
            return false
        }

        let note = Note.createIngested(
            id: id,
            duration: duration,
            transcriberDeviceID: InstallIdentity.installID,
            context: context
        )
        do {
            let audioData = try Data(contentsOf: audioURL)
            try await store.writeData(audioData, to: Self.dataPath(Note.audioRelativePath(forID: id)))
            try await store.write(note, to: Self.dataPath(jsonPath))
        } catch {
            // Transient (iCloud write, or the staged file vanished mid-drain):
            // leave the staged files for the next drain to retry.
            report("Failed to ingest watch recording \(id.stringValue)", error)
            return false
        }

        NoteMapper.upsert(note, relativePath: jsonPath, into: cacheContext)
        try? cacheContext.save()
        Self.removeStagedFiles(audioURL: audioURL)
        lastSyncError = nil

        print("[Sync] Ingested watch recording \(id.stringValue) recorded \(recordedAt)")
        onWatchRecordingHandled?(id.stringValue)
        onAudioNoteIngested?(id)
        return true
    }

    /// `.m4a` files sitting in the watch inbox with no readable metadata sidecar
    /// — quarantined forward-gate deliveries or the residue of a failed staging.
    /// The drain can never ingest them; surfaced so they're at least visible.
    nonisolated static func watchInboxOrphanCount() -> Int {
        let fm = FileManager.default
        let entries = (try? fm.contentsOfDirectory(at: watchInboxURL, includingPropertiesForKeys: nil)) ?? []
        let sidecarBases = Set(entries.filter { $0.lastPathComponent.hasSuffix(".meta.plist") }
            .map { $0.lastPathComponent.replacingOccurrences(of: ".meta.plist", with: "") })
        return entries.filter {
            $0.pathExtension == "m4a" && !sidecarBases.contains($0.deletingPathExtension().lastPathComponent)
        }.count
    }

    /// One recording staged in the watch inbox: the `.m4a` and the metadata the
    /// receiver wrote in its `<ULID>.meta.plist` sidecar.
    private struct StagedRecording {
        var id: ULID
        var recordedAt: Date
        var duration: TimeInterval
        var context: CaptureContext
        var audioURL: URL
    }

    /// Enumerate the staging inbox, pairing each `<ULID>.meta.plist` with its
    /// `.m4a`. A sidecar whose id doesn't parse, whose required fields are
    /// missing, or whose audio is absent is skipped (a half-delivered pair can't
    /// be ingested; the next full delivery re-stages it).
    private static func stagedWatchRecordings() -> [StagedRecording] {
        let fm = FileManager.default
        guard let entries = try? fm.contentsOfDirectory(
            at: watchInboxURL, includingPropertiesForKeys: nil) else { return [] }

        var staged: [StagedRecording] = []
        for metaURL in entries where metaURL.pathExtension == "plist" {
            // `<ULID>.meta.plist` → drop `.plist` then `.meta` to the ULID base.
            let base = metaURL.deletingPathExtension().deletingPathExtension().lastPathComponent
            guard let id = ULID(string: base),
                  let data = try? Data(contentsOf: metaURL),
                  let dict = try? PropertyListSerialization.propertyList(
                    from: data, options: [], format: nil) as? [String: Any],
                  let duration = dict["duration"] as? Double,
                  let recordedAt = dict["recordedAt"] as? Date else { continue }

            let audioURL = watchInboxURL.appending(path: "\(base).m4a")
            guard fm.fileExists(atPath: audioURL.path) else { continue }

            var context = CaptureContext.empty
            context.device = "Watch"
            context.timeZoneID = dict["timeZoneID"] as? String
            staged.append(StagedRecording(
                id: id, recordedAt: recordedAt, duration: duration,
                context: context, audioURL: audioURL))
        }
        return staged
    }

    /// Remove a staged recording's `.m4a` and its `<ULID>.meta.plist` sibling.
    private static func removeStagedFiles(audioURL: URL) {
        let fm = FileManager.default
        try? fm.removeItem(at: audioURL)
        try? fm.removeItem(at: audioURL.deletingPathExtension().appendingPathExtension("meta.plist"))
    }

    // MARK: - Persistence plumbing

    /// Writes touch the file layer ONLY. iCloud JSON is the source of truth:
    /// with no file store the write would exist nowhere durable (a cache-only
    /// copy is wiped by the next rebuild), so refuse the save. The UI gate is
    /// a courtesy; this throw is the invariant.
    private func save(_ note: Note) async throws {
        guard let store else {
            report("Note not saved — iCloud not connected")
            throw SyncError.iCloudUnavailable
        }
        do {
            _ = try await store.write(note, to: Self.dataPath(note.relativePath))
        } catch {
            report("Failed to save note", error)
            throw error
        }
        lastSyncError = nil
    }

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

    private func removeFromCache(jsonRelativePath: String) {
        let entities = NoteMapper.fetch(jsonRelativePath: jsonRelativePath, in: cacheContext)
        guard !entities.isEmpty else { return }
        entities.forEach(cacheContext.delete)
        try? cacheContext.save()
    }

    private func removeFromCache(ids: Set<String>) {
        for id in ids {
            if let entity = NoteMapper.fetch(id: id, in: cacheContext) {
                cacheContext.delete(entity)
            }
        }
        try? cacheContext.save()
    }

    // MARK: - Monitoring

    private func startMonitoring() {
        guard let store else { return }

        // Idempotent: tear down any existing monitors first so a reconnect
        // can't leave two live NSMetadataQuery monitors feeding duplicate events.
        stopMonitoring()

        let recordsURL = store.rootURL.appendingPathComponent(Self.recordsDirectory, isDirectory: true)
        let dataMonitor = MetadataObserver(documentsURL: recordsURL)
        self.dataMonitor = dataMonitor
        dataMonitor.start()
        dataMonitorTask = Task { [weak self] in
            for await batch in dataMonitor.events() {
                for event in batch {
                    await self?.handleFileEvent(event)
                }
            }
        }

        let stubsURL = store.rootURL.appendingPathComponent(Self.stubsDirectory, isDirectory: true)
        let stubMonitor = MetadataObserver(documentsURL: stubsURL)
        self.stubMonitor = stubMonitor
        stubMonitor.start()
        stubMonitorTask = Task { [weak self] in
            for await batch in stubMonitor.events() {
                for event in batch {
                    if case .added(let path) = event {
                        await self?.handleStubAdded(relativePath: path)
                    }
                }
            }
        }
    }

    private func stopMonitoring() {
        dataMonitorTask?.cancel()
        stubMonitorTask?.cancel()
        dataMonitorTask = nil
        stubMonitorTask = nil
        dataMonitor?.stop()
        stubMonitor?.stop()
        dataMonitor = nil
        stubMonitor = nil
    }

    private func handleFileEvent(_ event: FileChangeEvent) async {
        guard let store, let tombstones else { return }
        switch event {
        case .added(let path), .modified(let path):
            // Match stubs by document id, not path equality, so an evicted
            // stub still vetoes resurrection.
            let id = DocumentFileStore.documentID(fromRelativePath: path)
            if await tombstones.stubExists(id: id) {
                try? await store.remove(at: Self.dataPath(path))
                try? await store.remove(at: Self.dataPath(Note.audioRelativePath(forJSONPath: path)))
                return
            }
            await importRecord(at: path, using: store)

        case .removed(let path):
            removeFromCache(jsonRelativePath: path)
        }
    }

    private func handleStubAdded(relativePath: String) async {
        if let store {
            try? await store.remove(at: Self.dataPath(relativePath))
            try? await store.remove(at: Self.dataPath(Note.audioRelativePath(forJSONPath: relativePath)))
        }
        removeFromCache(jsonRelativePath: relativePath)
    }

    private func importRecord(at path: String, using store: DocumentFileStore) async {
        // The store's read resolves conflicts and force-downloads evicted
        // files (bounded wait) before the coordinated read.
        guard let note = try? await store.read(Note.self, from: Self.dataPath(path)) else { return }
        // Forward gate: a document written by a newer app version is
        // quarantined — Codable would silently drop its unknown fields.
        guard note.isReadable else {
            print("[Sync] Skipping \(path) — schema \(note.schemaVersion) is newer than this app understands")
            return
        }
        NoteMapper.upsert(migrateIfNeeded(note), relativePath: path, into: cacheContext)
        try? cacheContext.save()
    }

    // MARK: - Cache rebuild / reconcile

    /// Bring the cache in line with iCloud on connect: full rebuild when the
    /// cache is empty (first launch, post-wipe), incremental reconcile
    /// otherwise — records synced down while the app was closed must appear.
    private func syncCacheOnConnect() async {
        guard store != nil else { return }
        let count = (try? cacheContext.fetchCount(FetchDescriptor<NoteEntity>())) ?? 0
        if count == 0 {
            await rebuildCache()
        } else {
            await reconcile()
        }
    }

    /// Diff iCloud's files against the cache and settle the difference:
    /// import records that synced down while the app was closed/backgrounded,
    /// drop rows whose file was deleted elsewhere, and clean up files a stub
    /// has since tombstoned. Runs on every connect and foreground return.
    func reconcile() async {
        guard let store, let tombstones else { return }

        let filePaths = await listRecordPaths(in: store)
        let stubIDs = await tombstones.listStubIDs()

        var livePathsByID: [String: String] = [:]
        var orphanedPaths: [String] = []
        for path in filePaths {
            let id = DocumentFileStore.documentID(fromRelativePath: path)
            if stubIDs.contains(id) {
                orphanedPaths.append(path)
            } else {
                livePathsByID[id] = path
            }
        }

        var descriptor = FetchDescriptor<NoteEntity>()
        descriptor.propertiesToFetch = [\.id]
        let cachedIDs = Set(((try? cacheContext.fetch(descriptor)) ?? []).map(\.id))

        let toImport = Set(livePathsByID.keys).subtracting(cachedIDs)
        let toRemove = cachedIDs.subtracting(livePathsByID.keys)

        guard !toImport.isEmpty || !toRemove.isEmpty || !orphanedPaths.isEmpty else {
            return  // already in sync — the common foreground case, kept cheap
        }
        print("[Sync] Reconcile: +\(toImport.count)/-\(toRemove.count) notes, \(orphanedPaths.count) orphaned files")

        isSyncing = true
        defer { isSyncing = false }

        // Clean up files a stub has tombstoned (mirrors the rebuild path),
        // including each record's audio sidecar sibling.
        for path in orphanedPaths {
            try? await store.remove(at: Self.dataPath(path))
            try? await store.remove(at: Self.dataPath(Note.audioRelativePath(forJSONPath: path)))
        }

        removeFromCache(ids: toRemove)

        // Import the missing records through the same conflict-resolved,
        // eviction-safe, forward-gated path the monitors use.
        for id in toImport.sorted() {
            guard let path = livePathsByID[id] else { continue }
            await importRecord(at: path, using: store)
        }
    }

    func rebuildCache() async {
        guard let store, let tombstones else { return }
        isSyncing = true
        defer { isSyncing = false }

        await store.downloadEvictedFiles(inSubdirectory: Self.recordsDirectory)
        for _ in 0..<30 {
            if await store.countEvictedFiles(inSubdirectory: Self.recordsDirectory) == 0 { break }
            try? await Task.sleep(for: .seconds(2))
        }

        do {
            try cacheContext.delete(model: NoteEntity.self)
            try cacheContext.save()
        } catch {
            report("Cache rebuild failed", error)
            return
        }

        let stubIDs = await tombstones.listStubIDs()
        for path in await listRecordPaths(in: store) {
            if stubIDs.contains(DocumentFileStore.documentID(fromRelativePath: path)) {
                try? await store.remove(at: Self.dataPath(path))
                try? await store.remove(at: Self.dataPath(Note.audioRelativePath(forJSONPath: path)))
                continue
            }
            await importRecord(at: path, using: store)
        }
        lastSyncError = nil
    }

    /// Records-relative paths of all live record files (placeholder-aware —
    /// includes evicted iCloud-only files).
    private func listRecordPaths(in store: DocumentFileStore) async -> [String] {
        let prefix = Self.recordsDirectory + "/"
        return await store.list(inSubdirectory: Self.recordsDirectory)
            .map { String($0.dropFirst(prefix.count)) }
            .sorted()
    }

    // MARK: - Restore support

    /// Stops the metadata observers so a backup restore's bulk file
    /// replacement isn't observed as live sync events.
    func suspendForRestore() {
        stopMonitoring()
    }

    /// Restarts fresh observers after a restore. The new NSMetadataQuery
    /// re-baselines its known files on gather, so the restored tree does not
    /// replay as a flood of add/remove events.
    func resumeAfterRestore() {
        startMonitoring()
    }

    // MARK: - Maintenance

    private func performLaunchMaintenance() {
        Task(priority: .utility) { [tombstones] in
            _ = try? await tombstones?.prune()
        }
    }

    // MARK: - Schema migration

    /// Read-time upgrade for documents behind the current schema. Wired into
    /// both read paths so a schema bump migrates instead of silently skipping.
    /// Write-back happens lazily on the next save — never from live monitor
    /// events.
    ///
    /// v2 → v3: transcripts moved from `payload.text` into `audio.attempts`.
    /// A v2 voice note whose transcription completed holds its transcript (or
    /// the transcript merged with user typing — indistinguishable by then) in
    /// `text`; move that whole text into the first attempt so the v3 invariant
    /// "text is user writing only" holds. Confidence was never recorded ⇒ nil.
    /// v3 → v4 is additive-only (new optional `payload.project`), so no
    /// migration action is needed beyond the version stamp.
    /// Internal (not private) so the migration policy is unit-testable.
    func migrateIfNeeded(_ note: Note) -> Note {
        guard note.meta.schemaVersion < Note.currentSchemaVersion else { return note }
        var migrated = note
        if note.meta.schemaVersion < 3,
           var audio = migrated.payload.audio,
           audio.transcriptionStatus == .done,
           audio.attempts.isEmpty,
           !migrated.payload.text.isEmpty {
            audio.attempts = [Note.TranscriptionAttempt(
                text: migrated.payload.text,
                localeID: audio.transcriptionLocaleID ?? "",
                confidence: nil,
                transcribedAt: audio.transcribedAt ?? migrated.meta.modifiedAt,
                languageWasForced: false
            )]
            migrated.payload.text = ""
            migrated.payload.audio = audio
        }
        migrated.meta.schemaVersion = Note.currentSchemaVersion
        return migrated
    }
}
