HealthProbe / HealthProbe / Services / SnapshotLifecycleService.swift
1 contributor
274 lines | 10.39kb
import Foundation
import SwiftData
import os.log

private let logger = Logger(subsystem: "ro.xdev.healthprobe", category: "SnapshotLifecycleService")

enum SnapshotLifecycleService {
    struct DeletionPreview {
        let target: HealthSnapshot
        let affectedDeltas: [SnapshotDelta]
        let mergedDelta: SnapshotDelta?
        let willBreakChain: Bool
        let description: String
    }

    static func previewDeletion(of snapshot: HealthSnapshot, context: ModelContext) throws -> DeletionPreview {
        let allDeltas = try fetchDeltas(context: context)
        let incomingDelta = allDeltas.first { $0.toSnapshotID == snapshot.id }
        let outgoingDelta = allDeltas.first { $0.fromSnapshotID == snapshot.id }

        var willBreakChain = false
        var description = ""

        let integrityResult = IntegrityService.validate(snapshot: snapshot, delta: incomingDelta)
        switch integrityResult {
        case .valid, .pendingSync:
            break
        case .checksumMismatch(_, let expected, let actual):
            willBreakChain = true
            description = "Checksum mismatch: expected \(expected.prefix(8))…, got \(actual.prefix(8))…"
        case .missingDelta(let fromID, _):
            willBreakChain = true
            description = "Missing delta from \(fromID)"
        case .corrupted(_, let reason):
            willBreakChain = true
            description = reason
        }

        var affectedDeltas: [SnapshotDelta] = []
        if let d = incomingDelta { affectedDeltas.append(d) }
        if let d = outgoingDelta { affectedDeltas.append(d) }

        // For intermediate deletion, compute the merged delta preview
        var mergedDelta: SnapshotDelta? = nil
        if let d1 = incomingDelta, let d2 = outgoingDelta,
           let prevSnap = try fetchSnapshot(id: d1.fromSnapshotID, context: context),
           let nextSnap = try fetchSnapshot(id: d2.toSnapshotID, context: context) {
            mergedDelta = DeltaService.mergeDeltas(
                d1: d1, d2: d2,
                snapshotBefore: prevSnap,
                snapshotAfter: nextSnap
            )
        }

        return DeletionPreview(
            target: snapshot,
            affectedDeltas: affectedDeltas,
            mergedDelta: mergedDelta,
            willBreakChain: willBreakChain,
            description: description
        )
    }

    static func delete(_ snapshot: HealthSnapshot, context: ModelContext) throws {
        let allDeltas = try fetchDeltas(context: context)
        let incomingDelta = allDeltas.first { $0.toSnapshotID == snapshot.id }
        let outgoingDelta = allDeltas.first { $0.fromSnapshotID == snapshot.id }

        let deviceID = snapshot.deviceID
        let version = Bundle.main.appBuildVersion

        // Build operation log before making changes
        let log = OperationLog(
            operationType: "delete",
            summary: buildSummary(snapshot: snapshot, incoming: incomingDelta, outgoing: outgoingDelta),
            deviceID: deviceID,
            appBuildVersion: version
        )
        log.affectedSnapshotIDs = [snapshot.id.uuidString]
        let logID = log.id
        context.insert(log)

        if incomingDelta == nil && outgoingDelta == nil {
            // Standalone snapshot — just delete
            context.delete(snapshot)
        } else if incomingDelta == nil, let outgoing = outgoingDelta {
            // Oldest snapshot: delete it and outgoing delta, set next as chain start
            if let nextSnap = try fetchSnapshot(id: outgoing.toSnapshotID, context: context) {
                nextSnap.previousSnapshotID = nil
                nextSnap.isChainStart = true
                refreshDetailCaches(for: nextSnap, baseline: nil)
            }
            context.delete(outgoing)
            context.delete(snapshot)
        } else if outgoingDelta == nil, let incoming = incomingDelta {
            // Latest snapshot: delete it and incoming delta
            context.delete(incoming)
            context.delete(snapshot)
        } else if let d1 = incomingDelta, let d2 = outgoingDelta {
            // Intermediate snapshot: merge deltas and delete
            guard let prevSnap = try fetchSnapshot(id: d1.fromSnapshotID, context: context),
                  let nextSnap = try fetchSnapshot(id: d2.toSnapshotID, context: context) else {
                logger.error("SnapshotLifecycleService: failed to find surrounding snapshots for merge")
                throw LifecycleError.missingNeighbor
            }

            let merged = DeltaService.mergeDeltas(
                d1: d1, d2: d2,
                snapshotBefore: prevSnap,
                snapshotAfter: nextSnap
            )
            merged.deviceID = deviceID
            context.insert(merged)
            for td in merged.typeDeltas ?? [] { context.insert(td) }

            nextSnap.previousSnapshotID = prevSnap.id
            refreshDetailCaches(for: nextSnap, baseline: prevSnap)
            context.delete(d1)
            context.delete(d2)
            context.delete(snapshot)
        }

        // Atomic save: log + destructive changes in same save call
        try context.save()

        // Post-save OperationLog verification
        let verifyDescriptor = FetchDescriptor<OperationLog>(
            predicate: #Predicate<OperationLog> { $0.id == logID }
        )
        if (try? context.fetch(verifyDescriptor).first) == nil {
            logger.critical("OperationLog not found after save — attempting recovery re-insert")
            let recovery = OperationLog(
                operationType: log.operationType,
                summary: log.summary,
                deviceID: log.operationDeviceID,
                appBuildVersion: log.operationAppBuildVersion
            )
            recovery.affectedSnapshotIDsJSON = log.affectedSnapshotIDsJSON
            context.insert(recovery)
            try? context.save()
        }
    }

    static func rebuildMissingDetailCaches(
        context: ModelContext,
        maxTypeCounts: Int
    ) throws -> Bool {
        guard maxTypeCounts > 0 else { return false }

        let descriptor = FetchDescriptor<HealthSnapshot>(
            sortBy: [SortDescriptor(\.timestamp, order: .forward)]
        )
        let snapshots = try context.fetch(descriptor)
        var rebuiltCount = 0

        for snapshot in snapshots {
            guard let baselineID = snapshot.previousSnapshotID,
                  let baseline = try fetchSnapshot(id: baselineID, context: context) else {
                continue
            }

            let baselineByType = Dictionary(
                uniqueKeysWithValues: (baseline.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
            )

            for typeCount in snapshot.typeCounts ?? [] {
                guard shouldBackfillDetailCache(
                    typeCount: typeCount,
                    baseline: baselineByType[typeCount.typeIdentifier],
                    baselineID: baseline.id
                ) else {
                    continue
                }

                typeCount.setDetailCache(
                    TypeCountDetailCacheBuilder.build(
                        current: typeCount,
                        previous: baselineByType[typeCount.typeIdentifier],
                        baselineSnapshotID: baseline.id
                    )
                )
                rebuiltCount += 1

                if rebuiltCount >= maxTypeCounts {
                    try context.save()
                    return false
                }
            }
        }

        if rebuiltCount > 0 {
            try context.save()
        }
        return true
    }

    // MARK: - Fetch helpers

    private static func fetchDeltas(context: ModelContext) throws -> [SnapshotDelta] {
        try context.fetch(FetchDescriptor<SnapshotDelta>())
    }

    private static func fetchSnapshot(id: UUID, context: ModelContext) throws -> HealthSnapshot? {
        let descriptor = FetchDescriptor<HealthSnapshot>(
            predicate: #Predicate<HealthSnapshot> { $0.id == id }
        )
        return try context.fetch(descriptor).first
    }

    private static func refreshDetailCaches(for snapshot: HealthSnapshot, baseline: HealthSnapshot?) {
        guard let baseline else {
            for typeCount in snapshot.typeCounts ?? [] {
                typeCount.setDetailCache(nil)
            }
            return
        }

        let baselineByType = Dictionary(
            uniqueKeysWithValues: (baseline.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
        )

        for typeCount in snapshot.typeCounts ?? [] {
            typeCount.setDetailCache(
                TypeCountDetailCacheBuilder.build(
                    current: typeCount,
                    previous: baselineByType[typeCount.typeIdentifier],
                    baselineSnapshotID: baseline.id
                )
            )
        }
    }

    @MainActor private static func shouldBackfillDetailCache(
        typeCount: TypeCount,
        baseline: TypeCount?,
        baselineID: UUID
    ) -> Bool {
        if typeCount.detailCache?.matchesBaseline(baselineID) == true {
            return false
        }

        guard canBuildDetailCache(typeCount),
              baseline.map(canBuildDetailCache(_:)) ?? true else {
            return false
        }

        return true
    }

    @MainActor private static func canBuildDetailCache(_ typeCount: TypeCount) -> Bool {
        typeCount.count <= 0 || typeCount.recordArchiveData != nil
    }

    private static func buildSummary(snapshot: HealthSnapshot, incoming: SnapshotDelta?, outgoing: SnapshotDelta?) -> String {
        let position: String
        if incoming == nil && outgoing == nil { position = "standalone" }
        else if incoming == nil { position = "oldest" }
        else if outgoing == nil { position = "latest" }
        else { position = "intermediate" }
        return "Deleted \(position) snapshot \(snapshot.id) at \(snapshot.timestamp)"
    }

    enum LifecycleError: Error {
        case missingNeighbor
    }
}

private extension Bundle {
    var appBuildVersion: String {
        let version = infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
        let build = infoDictionary?["CFBundleVersion"] as? String ?? ""
        return "\(version) (\(build))"
    }
}