1 contributor
409 lines | 16.342kb
import Foundation
import SwiftData
import os.log

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

enum DeltaService {
    @discardableResult
    static func computeAndSave(current: HealthSnapshot, context: ModelContext) throws -> SnapshotDelta? {
        // No previous snapshot → chain start, no delta to compute
        guard let prevID = current.previousSnapshotID else { return nil }

        let prevDescriptor = FetchDescriptor<HealthSnapshot>(
            predicate: #Predicate<HealthSnapshot> { $0.id == prevID }
        )
        guard let previous = try context.fetch(prevDescriptor).first else {
            logger.error("DeltaService: previousSnapshotID \(prevID) not found")
            return nil
        }

        let prevByID = Dictionary(
            uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
        )
        let currByID = Dictionary(
            uniqueKeysWithValues: (current.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
        )

        let delta = SnapshotDelta(
            fromSnapshotID: previous.id,
            toSnapshotID: current.id,
            deviceID: current.deviceID
        )
        delta.checksumBefore = HashService.snapshotChecksum(typeCounts: Array(prevByID.values))
        delta.checksumAfter  = HashService.snapshotChecksum(typeCounts: Array(currByID.values))

        if current.isContentAlias,
           current.contentEquivalentSnapshotID == previous.contentRepresentativeSnapshotID {
            delta.typeDeltas = []
            updateListSummary(for: delta, typeDeltas: [])
            context.insert(delta)
            try context.save()
            return delta
        }

        let allTypeIDs = Set(prevByID.keys).union(currByID.keys)
        var typeDeltas: [TypeDelta] = []

        for typeID in allTypeIDs {
            let prev = prevByID[typeID]
            let curr = currByID[typeID]

            if let prev,
               let curr,
               curr.contentEquivalentTypeCountID == prev.contentRepresentativeTypeCountID {
                continue
            }

            let effectivePrev = historicalBaselinePreviousTypeCount(
                typeID: typeID,
                prev: prev,
                curr: curr,
                previousSnapshot: previous,
                context: context
            ) ?? prev

            let td = buildTypeDelta(
                typeID: typeID,
                prev: effectivePrev,
                curr: curr,
                previous: previous,
                current: current
            )
            td.delta = delta
            typeDeltas.append(td)
            context.insert(td)
        }

        delta.typeDeltas = typeDeltas
        updateListSummary(for: delta, typeDeltas: typeDeltas)
        context.insert(delta)
        try context.save()
        return delta
    }

    @discardableResult
    static func rebuildMissingListSummaries(context: ModelContext, maxCount: Int) throws -> Bool {
        guard maxCount > 0 else { return false }

        let summaryVersion = SnapshotDelta.currentListSummaryVersion
        var descriptor = FetchDescriptor<SnapshotDelta>(
            predicate: #Predicate<SnapshotDelta> { $0.listSummaryVersion < summaryVersion }
        )
        descriptor.fetchLimit = maxCount

        let deltas = try context.fetch(descriptor)
        guard !deltas.isEmpty else { return false }

        for delta in deltas {
            updateListSummary(for: delta, typeDeltas: delta.typeDeltas ?? [])
        }

        try context.save()
        return true
    }

    // MARK: - Delta merge (for intermediate snapshot deletion)

    // snapshotBefore and snapshotAfter are the real surrounding snapshots (N-1 and N+1).
    // Their typeCounts are used to recompute fresh checksums.
    static func mergeDeltas(
        d1: SnapshotDelta,
        d2: SnapshotDelta,
        snapshotBefore: HealthSnapshot,
        snapshotAfter: HealthSnapshot
    ) -> SnapshotDelta {
        let merged = SnapshotDelta(
            fromSnapshotID: d1.fromSnapshotID,
            toSnapshotID: d2.toSnapshotID,
            deviceID: d1.deviceID
        )
        // Always recompute from the actual surrounding snapshots — never copy old checksums
        merged.checksumBefore = HashService.snapshotChecksum(typeCounts: snapshotBefore.typeCounts ?? [])
        merged.checksumAfter  = HashService.snapshotChecksum(typeCounts: snapshotAfter.typeCounts ?? [])

        let d1Map = Dictionary(uniqueKeysWithValues: (d1.typeDeltas ?? []).map { ($0.typeIdentifier, $0) })
        let d2Map = Dictionary(uniqueKeysWithValues: (d2.typeDeltas ?? []).map { ($0.typeIdentifier, $0) })
        let allIDs = Set(d1Map.keys).union(d2Map.keys)

        var mergedTypeDeltas: [TypeDelta] = []
        for typeID in allIDs {
            let td1 = d1Map[typeID]
            let td2 = d2Map[typeID]
            if let merged1 = td1, let merged2 = td2 {
                // Type present in both deltas
                if merged1.transition == .appeared && merged2.transition == .disappeared {
                    // Existed only in deleted snapshot N — remove from merged delta
                    continue
                }
                let td = mergeTypeDelta(d1td: merged1, d2td: merged2)
                td.delta = merged
                mergedTypeDeltas.append(td)
            } else if let only1 = td1 {
                let td = copyTypeDelta(only1)
                td.delta = merged
                mergedTypeDeltas.append(td)
            } else if let only2 = td2 {
                let td = copyTypeDelta(only2)
                td.delta = merged
                mergedTypeDeltas.append(td)
            }
        }
        merged.typeDeltas = mergedTypeDeltas
        updateListSummary(for: merged, typeDeltas: mergedTypeDeltas)
        return merged
    }

    // MARK: - Private helpers

    private static func buildTypeDelta(
        typeID: String,
        prev: TypeCount?,
        curr: TypeCount?,
        previous: HealthSnapshot,
        current: HealthSnapshot
    ) -> TypeDelta {
        let displayName = curr?.displayName ?? prev?.displayName ?? typeID
        let td = TypeDelta(typeIdentifier: typeID, displayName: displayName)
        td.qualityBefore = prev?.quality
        td.qualityAfter  = curr?.quality

        let prevCount = prev?.count ?? 0
        let currCount = curr?.count ?? 0
        let prevHash  = prev?.contentHash ?? ""
        let currHash  = curr?.contentHash ?? ""

        if let prev, let curr {
            // Type present in both snapshots
            // If either count is -1, do not compute a numeric delta
            if prev.count == -1 || curr.count == -1 {
                td.countDelta = 0
            } else {
                td.countDelta = currCount - prevCount
            }
            td.hashBefore = prevHash
            td.hashAfter  = currHash
            td.transition = (prevHash == currHash && prevCount == currCount) ? .unchanged : .changed
        } else if let curr {
            // Type appeared — missing in previous
            td.countDelta = curr.count == -1 ? 0 : curr.count
            td.hashBefore = ""
            td.hashAfter  = currHash
            td.transition = .appeared
        } else if let prev {
            // Type disappeared — missing in current
            td.countDelta = prev.count == -1 ? 0 : -prev.count
            td.hashBefore = prevHash
            td.hashAfter  = ""
            td.transition = .disappeared
        }

        // Reason assignment — explicit priority order (highest wins):
        // 1. authorizationChanged — type quality == .unauthorized
        // 2. unsupported — type cannot be instantiated by HK factory
        // 3. registryChanged — type appeared/disappeared AND monitoredTypeSetHash changed
        // 4. unknown — type quality == .failed for other reasons
        // 5. normal — none of the above
        td.reason = assignReason(
            prevQuality: prev?.quality,
            currQuality: curr?.quality,
            prevUnsupported: prev?.isUnsupported ?? false,
            currUnsupported: curr?.isUnsupported ?? false,
            transition: td.transition,
            typeSetHashChanged: previous.monitoredTypeSetHash != current.monitoredTypeSetHash
        )

        // YearlyCount timezone guard
        if previous.yearlyCountTimezoneIdentifier != current.yearlyCountTimezoneIdentifier {
            td.yearlyCountNote = "yearly attribution unreliable — timezone changed between snapshots"
        }

        return td
    }

    private static func updateListSummary(for delta: SnapshotDelta, typeDeltas: [TypeDelta]) {
        var absoluteRecordChangeCount = 0
        var changedMetricCount = 0
        var appearedMetricCount = 0
        var disappearedMetricCount = 0

        for typeDelta in typeDeltas {
            switch typeDelta.transition {
            case .unchanged:
                continue
            case .changed:
                changedMetricCount += 1
                if typeDelta.qualityBefore == .complete,
                   typeDelta.qualityAfter == .complete {
                    absoluteRecordChangeCount += abs(typeDelta.countDelta)
                }
            case .appeared:
                appearedMetricCount += 1
            case .disappeared:
                disappearedMetricCount += 1
            }
        }

        delta.absoluteRecordChangeCount = absoluteRecordChangeCount
        delta.changedMetricCount = changedMetricCount
        delta.appearedMetricCount = appearedMetricCount
        delta.disappearedMetricCount = disappearedMetricCount
        delta.listSummaryVersion = SnapshotDelta.currentListSummaryVersion
    }

    private static func historicalBaselinePreviousTypeCount(
        typeID: String,
        prev: TypeCount?,
        curr: TypeCount?,
        previousSnapshot: HealthSnapshot,
        context: ModelContext
    ) -> TypeCount? {
        guard let prev,
              let curr,
              prev.quality == .unauthorized,
              curr.quality == .complete,
              curr.count > 0 else {
            return nil
        }

        return findLastCompleteValuedTypeCount(
            typeID: typeID,
            before: previousSnapshot,
            context: context
        )
    }

    private static func findLastCompleteValuedTypeCount(
        typeID: String,
        before snapshot: HealthSnapshot,
        context: ModelContext
    ) -> TypeCount? {
        var visited: Set<UUID> = []
        var cursorID = snapshot.previousSnapshotID

        while let snapshotID = cursorID, !visited.contains(snapshotID) {
            visited.insert(snapshotID)

            guard let historicalSnapshot = fetchSnapshot(id: snapshotID, context: context) else {
                break
            }

            if let candidate = historicalSnapshot.typeCounts?.first(where: { $0.typeIdentifier == typeID }),
               candidate.quality == .complete,
               candidate.count > 0 {
                return candidate
            }

            cursorID = historicalSnapshot.previousSnapshotID
        }

        return nil
    }

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

    private static func assignReason(
        prevQuality: SnapshotQuality?,
        currQuality: SnapshotQuality?,
        prevUnsupported: Bool,
        currUnsupported: Bool,
        transition: TypeTransition,
        typeSetHashChanged: Bool
    ) -> TypeDeltaReason {
        // Priority 1: authorizationChanged
        if prevQuality == SnapshotQuality.unauthorized || currQuality == SnapshotQuality.unauthorized {
            return .authorizationChanged
        }
        // Priority 2: unsupported
        if prevUnsupported || currUnsupported {
            return .unsupported
        }
        // Priority 3: registryChanged (only for appeared/disappeared transitions)
        if (transition == .appeared || transition == .disappeared) && typeSetHashChanged {
            return .registryChanged
        }
        // Priority 4: unknown (failed)
        if prevQuality == SnapshotQuality.failed || currQuality == SnapshotQuality.failed {
            return .unknown
        }
        return .normal
    }

    private static func mergeTypeDelta(d1td: TypeDelta, d2td: TypeDelta) -> TypeDelta {
        let td = TypeDelta(typeIdentifier: d1td.typeIdentifier, displayName: d1td.displayName)

        if d1td.transition == .disappeared && d2td.transition == .appeared {
            // Type disappeared in N, reappeared in N+1 → treat as changed
            td.transition = .changed
            td.hashBefore = d1td.hashBefore
            td.hashAfter  = d2td.hashAfter
            td.qualityBefore = d1td.qualityBefore
            td.qualityAfter  = d2td.qualityAfter
            // Unavailable count guard: if either source has quality != complete, force countDelta = 0
            let d1Impaired = (d1td.qualityBefore != SnapshotQuality.complete)
            let d2Impaired = (d2td.qualityAfter  != SnapshotQuality.complete)
            td.countDelta = (d1Impaired || d2Impaired) ? 0 : d1td.countDelta + d2td.countDelta
        } else {
            // Both transitions are the same type (e.g. both unchanged, both changed)
            td.transition = deriveTransition(hashBefore: d1td.hashBefore, hashAfter: d2td.hashAfter,
                                             d1: d1td, d2: d2td)
            td.hashBefore = d1td.hashBefore
            td.hashAfter  = d2td.hashAfter
            td.qualityBefore = d1td.qualityBefore
            td.qualityAfter  = d2td.qualityAfter
            // Unavailable count guard
            let anyImpaired = (d1td.qualityBefore != SnapshotQuality.complete) ||
                              (d1td.qualityAfter  != SnapshotQuality.complete) ||
                              (d2td.qualityBefore != SnapshotQuality.complete) ||
                              (d2td.qualityAfter  != SnapshotQuality.complete)
            td.countDelta = anyImpaired ? 0 : d1td.countDelta + d2td.countDelta
        }

        // Reason: apply same priority table; use highest-priority reason from both source deltas
        td.reason = highestPriorityReason(d1td.reason, d2td.reason)

        // Timezone note: carry forward if either source had it
        if !d1td.yearlyCountNote.isEmpty || !d2td.yearlyCountNote.isEmpty {
            td.yearlyCountNote = "yearly attribution unreliable — timezone changed between snapshots"
        }

        return td
    }

    private static func deriveTransition(
        hashBefore: String, hashAfter: String,
        d1: TypeDelta, d2: TypeDelta
    ) -> TypeTransition {
        // Infer transition from the merged hash pair
        if hashBefore.isEmpty && !hashAfter.isEmpty { return .appeared }
        if !hashBefore.isEmpty && hashAfter.isEmpty { return .disappeared }
        if hashBefore == hashAfter && d1.countDelta + d2.countDelta == 0 { return .unchanged }
        return .changed
    }

    private static func highestPriorityReason(_ a: TypeDeltaReason, _ b: TypeDeltaReason) -> TypeDeltaReason {
        // Priority: authorizationChanged > unsupported > registryChanged > unknown > normal
        let priority: [TypeDeltaReason] = [.authorizationChanged, .unsupported, .registryChanged, .unknown, .normal]
        let aIdx = priority.firstIndex(of: a) ?? priority.count
        let bIdx = priority.firstIndex(of: b) ?? priority.count
        return priority[min(aIdx, bIdx)]
    }

    private static func copyTypeDelta(_ source: TypeDelta) -> TypeDelta {
        let td = TypeDelta(typeIdentifier: source.typeIdentifier, displayName: source.displayName)
        td.countDelta       = source.countDelta
        td.hashBefore       = source.hashBefore
        td.hashAfter        = source.hashAfter
        td.qualityBefore    = source.qualityBefore
        td.qualityAfter     = source.qualityAfter
        td.transition       = source.transition
        td.reason           = source.reason
        td.yearlyCountNote  = source.yearlyCountNote
        return td
    }
}