import Foundation

// Interface updated 2026-05-17 — see AGENTS.md
struct TypeCountDailyChangeBin: Codable, Equatable, Sendable {
    let dayStart: Date
    let added: Int
    let disappeared: Int
    let unchanged: Int
}

// Interface updated 2026-05-17 — see AGENTS.md
struct TypeCountDetailCache: Codable, Equatable, Sendable {
    static let previewLimit = 1_000

    let baselineSnapshotID: UUID?
    let addedCount: Int
    let disappearedCount: Int
    let addedPreviewRecords: [HealthRecordValue]
    let disappearedPreviewRecords: [HealthRecordValue]
    let dailyChangeBins: [TypeCountDailyChangeBin]
    let earliestRecordDate: Date?
    let latestRecordDate: Date?

    var isPreviewLimited: Bool {
        addedCount > Self.previewLimit || disappearedCount > Self.previewLimit
    }

    func matchesBaseline(_ snapshotID: UUID?) -> Bool {
        baselineSnapshotID == snapshotID
    }
}

enum TypeCountDetailCacheArchive {
    static func encode(_ cache: TypeCountDetailCache) -> Data? {
        try? PropertyListEncoder().encode(cache)
    }

    static func decode(_ data: Data) -> TypeCountDetailCache? {
        try? PropertyListDecoder().decode(TypeCountDetailCache.self, from: data)
    }
}

enum TypeCountDetailCacheBuilder {
    private static let bucketTargetArchiveBytes = 8 * 1_024 * 1_024
    private static let minBucketCount = 4
    private static let maxBucketCount = 32

    static func build(
        current: TypeCount,
        previous: TypeCount?,
        baselineSnapshotID: UUID?
    ) -> TypeCountDetailCache? {
        let metadata = buildMetadata(current: current, previous: previous)
        MemoryLog.log("typeCountDetailCache.build.begin", metadata: metadata)

        if current.count > 0, current.recordArchiveData == nil { return nil }
        if let previous, previous.count > 0, previous.recordArchiveData == nil { return nil }

        var accumulator = DetailCacheAccumulator()

        if MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) {
            MemoryLog.log("typeCountDetailCache.build.fastPathSkippedMemoryPressure", metadata: metadata.merging([
                "limit": MemoryLog.format(MemoryLog.detailCacheBuildFootprintLimit)
            ]) { _, new in new })
            guard buildBucketedDiffData(
                currentArchive: current.recordArchiveData,
                previousArchive: previous?.recordArchiveData,
                metadata: metadata,
                accumulator: &accumulator
            ) else {
                return nil
            }
        } else if !buildFastDiffData(
            currentArchive: current.recordArchiveData,
            previousArchive: previous?.recordArchiveData,
            metadata: metadata,
            accumulator: &accumulator
        ) {
            MemoryLog.log("typeCountDetailCache.build.fastPathFailedFallingBack", metadata: metadata)
            accumulator = DetailCacheAccumulator()
            guard buildBucketedDiffData(
                currentArchive: current.recordArchiveData,
                previousArchive: previous?.recordArchiveData,
                metadata: metadata,
                accumulator: &accumulator
            ) else {
                return nil
            }
        }

        let cache = TypeCountDetailCache(
            baselineSnapshotID: baselineSnapshotID,
            addedCount: accumulator.addedCount,
            disappearedCount: accumulator.disappearedCount,
            addedPreviewRecords: accumulator.addedPreview.records,
            disappearedPreviewRecords: accumulator.disappearedPreview.records,
            dailyChangeBins: accumulator.dailyBins,
            earliestRecordDate: accumulator.earliestRecordDate,
            latestRecordDate: accumulator.latestRecordDate
        )
        MemoryLog.log("typeCountDetailCache.build.finished", metadata: metadata.merging([
            "added": "\(cache.addedCount)",
            "disappeared": "\(cache.disappearedCount)",
            "dailyBins": "\(cache.dailyChangeBins.count)"
        ]) { _, new in new })
        return cache
    }

    private static func buildFastDiffData(
        currentArchive: Data?,
        previousArchive: Data?,
        metadata: [String: String],
        accumulator: inout DetailCacheAccumulator
    ) -> Bool {
        switch (currentArchive, previousArchive) {
        case let (currentArchive?, previousArchive?):
            if currentArchive.count <= previousArchive.count {
                return scanUsingCurrentFingerprintSet(
                    currentArchive: currentArchive,
                    previousArchive: previousArchive,
                    metadata: metadata,
                    accumulator: &accumulator
                )
            }

            return scanUsingPreviousFingerprintSet(
                currentArchive: currentArchive,
                previousArchive: previousArchive,
                metadata: metadata,
                accumulator: &accumulator
            )

        case let (currentArchive?, nil):
            guard HealthRecordArchive.forEachRecord(in: currentArchive, { record in
                accumulator.add(record, as: .added)
            }) else {
                MemoryLog.log("typeCountDetailCache.build.currentScanFailed", metadata: metadata)
                return false
            }
            MemoryLog.log("typeCountDetailCache.build.currentScanFinished", metadata: metadata)
            return true

        case let (nil, previousArchive?):
            guard HealthRecordArchive.forEachRecord(in: previousArchive, { record in
                accumulator.add(record, as: .disappeared)
            }) else {
                MemoryLog.log("typeCountDetailCache.build.previousScanFailed", metadata: metadata)
                return false
            }
            MemoryLog.log("typeCountDetailCache.build.previousScanFinished", metadata: metadata)
            return true

        case (nil, nil):
            return true
        }
    }

    private static func buildBucketedDiffData(
        currentArchive: Data?,
        previousArchive: Data?,
        metadata: [String: String],
        accumulator: inout DetailCacheAccumulator
    ) -> Bool {
        switch (currentArchive, previousArchive) {
        case let (currentArchive?, previousArchive?):
            let bucketCount = bucketCount(for: max(currentArchive.count, previousArchive.count))
            MemoryLog.log("typeCountDetailCache.build.bucketedBegin", metadata: metadata.merging([
                "bucketCount": "\(bucketCount)"
            ]) { _, new in new })

            let didBuild: Bool
            if currentArchive.count <= previousArchive.count {
                didBuild = scanBucketsUsingCurrentFingerprintSet(
                    currentArchive: currentArchive,
                    previousArchive: previousArchive,
                    bucketCount: bucketCount,
                    metadata: metadata,
                    accumulator: &accumulator
                )
            } else {
                didBuild = scanBucketsUsingPreviousFingerprintSet(
                    currentArchive: currentArchive,
                    previousArchive: previousArchive,
                    bucketCount: bucketCount,
                    metadata: metadata,
                    accumulator: &accumulator
                )
            }

            if didBuild {
                MemoryLog.log("typeCountDetailCache.build.bucketedEnd", metadata: metadata.merging([
                    "bucketCount": "\(bucketCount)"
                ]) { _, new in new })
            }
            return didBuild

        case let (currentArchive?, nil):
            guard HealthRecordArchive.forEachRecord(in: currentArchive, { record in
                accumulator.add(record, as: .added)
            }) else {
                MemoryLog.log("typeCountDetailCache.build.currentScanFailed", metadata: metadata)
                return false
            }
            MemoryLog.log("typeCountDetailCache.build.currentScanFinished", metadata: metadata)
            return true

        case let (nil, previousArchive?):
            guard HealthRecordArchive.forEachRecord(in: previousArchive, { record in
                accumulator.add(record, as: .disappeared)
            }) else {
                MemoryLog.log("typeCountDetailCache.build.previousScanFailed", metadata: metadata)
                return false
            }
            MemoryLog.log("typeCountDetailCache.build.previousScanFinished", metadata: metadata)
            return true

        case (nil, nil):
            return true
        }
    }

    private static func scanUsingPreviousFingerprintSet(
        currentArchive: Data,
        previousArchive: Data,
        metadata: [String: String],
        accumulator: inout DetailCacheAccumulator
    ) -> Bool {
        guard var unmatchedPreviousFingerprints = HealthRecordArchive.fingerprintSet(from: previousArchive) else {
            MemoryLog.log("typeCountDetailCache.build.previousFingerprintFailed", metadata: metadata)
            return false
        }
        MemoryLog.log("typeCountDetailCache.build.previousFingerprintsReady", metadata: metadata.merging([
            "previousFingerprintCount": "\(unmatchedPreviousFingerprints.count)",
            "fingerprintStrategy": "previousOnly"
        ]) { _, new in new })
        guard !MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) else {
            MemoryLog.log("typeCountDetailCache.build.skippedAfterPreviousFingerprints", metadata: metadata.merging([
                "limit": MemoryLog.format(MemoryLog.detailCacheBuildFootprintLimit)
            ]) { _, new in new })
            return false
        }

        guard HealthRecordArchive.forEachRecord(in: currentArchive, { record in
            if unmatchedPreviousFingerprints.remove(record.recordFingerprint) != nil {
                accumulator.add(record, as: .unchanged)
            } else {
                accumulator.add(record, as: .added)
            }
        }) else {
            MemoryLog.log("typeCountDetailCache.build.currentScanFailed", metadata: metadata)
            return false
        }
        MemoryLog.log("typeCountDetailCache.build.currentScanFinished", metadata: metadata)

        guard HealthRecordArchive.forEachRecord(in: previousArchive, { record in
            if unmatchedPreviousFingerprints.contains(record.recordFingerprint) {
                accumulator.add(record, as: .disappeared)
            }
        }) else {
            MemoryLog.log("typeCountDetailCache.build.previousScanFailed", metadata: metadata)
            return false
        }
        MemoryLog.log("typeCountDetailCache.build.previousScanFinished", metadata: metadata)
        return true
    }

    private static func scanUsingCurrentFingerprintSet(
        currentArchive: Data,
        previousArchive: Data,
        metadata: [String: String],
        accumulator: inout DetailCacheAccumulator
    ) -> Bool {
        guard var unmatchedCurrentFingerprints = HealthRecordArchive.fingerprintSet(from: currentArchive) else {
            MemoryLog.log("typeCountDetailCache.build.currentFingerprintFailed", metadata: metadata)
            return false
        }
        MemoryLog.log("typeCountDetailCache.build.currentFingerprintsReady", metadata: metadata.merging([
            "currentFingerprintCount": "\(unmatchedCurrentFingerprints.count)",
            "fingerprintStrategy": "currentOnly"
        ]) { _, new in new })
        guard !MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) else {
            MemoryLog.log("typeCountDetailCache.build.skippedAfterCurrentFingerprints", metadata: metadata.merging([
                "limit": MemoryLog.format(MemoryLog.detailCacheBuildFootprintLimit)
            ]) { _, new in new })
            return false
        }

        guard HealthRecordArchive.forEachRecord(in: previousArchive, { record in
            if unmatchedCurrentFingerprints.remove(record.recordFingerprint) == nil {
                accumulator.add(record, as: .disappeared)
            }
        }) else {
            MemoryLog.log("typeCountDetailCache.build.previousScanFailed", metadata: metadata)
            return false
        }
        MemoryLog.log("typeCountDetailCache.build.previousScanFinished", metadata: metadata)

        guard HealthRecordArchive.forEachRecord(in: currentArchive, { record in
            if unmatchedCurrentFingerprints.contains(record.recordFingerprint) {
                accumulator.add(record, as: .added)
            } else {
                accumulator.add(record, as: .unchanged)
            }
        }) else {
            MemoryLog.log("typeCountDetailCache.build.currentScanFailed", metadata: metadata)
            return false
        }
        MemoryLog.log("typeCountDetailCache.build.currentScanFinished", metadata: metadata)
        return true
    }

    private static func scanBucketsUsingPreviousFingerprintSet(
        currentArchive: Data,
        previousArchive: Data,
        bucketCount: Int,
        metadata: [String: String],
        accumulator: inout DetailCacheAccumulator
    ) -> Bool {
        for bucketNumber in 0..<bucketCount {
            var unmatchedPreviousFingerprints: Set<String> = []
            guard HealthRecordArchive.forEachRecord(in: previousArchive, { record in
                guard fingerprintBucketIndex(for: record.recordFingerprint, bucketCount: bucketCount) == bucketNumber else {
                    return
                }
                unmatchedPreviousFingerprints.insert(record.recordFingerprint)
            }) else {
                MemoryLog.log("typeCountDetailCache.build.previousFingerprintFailed", metadata: bucketMetadata(
                    metadata: metadata,
                    bucketIndex: bucketNumber,
                    bucketCount: bucketCount,
                    strategy: "previousBucketed"
                ))
                return false
            }

            guard HealthRecordArchive.forEachRecord(in: currentArchive, { record in
                guard fingerprintBucketIndex(for: record.recordFingerprint, bucketCount: bucketCount) == bucketNumber else {
                    return
                }
                if unmatchedPreviousFingerprints.remove(record.recordFingerprint) != nil {
                    accumulator.add(record, as: .unchanged)
                } else {
                    accumulator.add(record, as: .added)
                }
            }) else {
                MemoryLog.log("typeCountDetailCache.build.currentScanFailed", metadata: bucketMetadata(
                    metadata: metadata,
                    bucketIndex: bucketNumber,
                    bucketCount: bucketCount,
                    strategy: "previousBucketed"
                ))
                return false
            }

            guard HealthRecordArchive.forEachRecord(in: previousArchive, { record in
                guard fingerprintBucketIndex(for: record.recordFingerprint, bucketCount: bucketCount) == bucketNumber else {
                    return
                }
                if unmatchedPreviousFingerprints.contains(record.recordFingerprint) {
                    accumulator.add(record, as: .disappeared)
                }
            }) else {
                MemoryLog.log("typeCountDetailCache.build.previousScanFailed", metadata: bucketMetadata(
                    metadata: metadata,
                    bucketIndex: bucketNumber,
                    bucketCount: bucketCount,
                    strategy: "previousBucketed"
                ))
                return false
            }
        }

        return true
    }

    private static func scanBucketsUsingCurrentFingerprintSet(
        currentArchive: Data,
        previousArchive: Data,
        bucketCount: Int,
        metadata: [String: String],
        accumulator: inout DetailCacheAccumulator
    ) -> Bool {
        for bucketNumber in 0..<bucketCount {
            var unmatchedCurrentFingerprints: Set<String> = []
            guard HealthRecordArchive.forEachRecord(in: currentArchive, { record in
                guard fingerprintBucketIndex(for: record.recordFingerprint, bucketCount: bucketCount) == bucketNumber else {
                    return
                }
                unmatchedCurrentFingerprints.insert(record.recordFingerprint)
            }) else {
                MemoryLog.log("typeCountDetailCache.build.currentFingerprintFailed", metadata: bucketMetadata(
                    metadata: metadata,
                    bucketIndex: bucketNumber,
                    bucketCount: bucketCount,
                    strategy: "currentBucketed"
                ))
                return false
            }

            guard HealthRecordArchive.forEachRecord(in: previousArchive, { record in
                guard fingerprintBucketIndex(for: record.recordFingerprint, bucketCount: bucketCount) == bucketNumber else {
                    return
                }
                if unmatchedCurrentFingerprints.remove(record.recordFingerprint) == nil {
                    accumulator.add(record, as: .disappeared)
                }
            }) else {
                MemoryLog.log("typeCountDetailCache.build.previousScanFailed", metadata: bucketMetadata(
                    metadata: metadata,
                    bucketIndex: bucketNumber,
                    bucketCount: bucketCount,
                    strategy: "currentBucketed"
                ))
                return false
            }

            guard HealthRecordArchive.forEachRecord(in: currentArchive, { record in
                guard fingerprintBucketIndex(for: record.recordFingerprint, bucketCount: bucketCount) == bucketNumber else {
                    return
                }
                if unmatchedCurrentFingerprints.contains(record.recordFingerprint) {
                    accumulator.add(record, as: .added)
                } else {
                    accumulator.add(record, as: .unchanged)
                }
            }) else {
                MemoryLog.log("typeCountDetailCache.build.currentScanFailed", metadata: bucketMetadata(
                    metadata: metadata,
                    bucketIndex: bucketNumber,
                    bucketCount: bucketCount,
                    strategy: "currentBucketed"
                ))
                return false
            }
        }

        return true
    }

    private static func bucketCount(for maxArchiveBytes: Int) -> Int {
        let rawBucketCount = max(1, Int(ceil(Double(maxArchiveBytes) / Double(bucketTargetArchiveBytes))))
        var bucketCount = 1
        while bucketCount < rawBucketCount {
            bucketCount <<= 1
        }
        return min(max(bucketCount, minBucketCount), maxBucketCount)
    }

    private static func fingerprintBucketIndex(for fingerprint: String, bucketCount: Int) -> Int {
        var hash: UInt64 = 1_469_598_103_934_665_603
        for byte in fingerprint.utf8.prefix(12) {
            hash ^= UInt64(byte)
            hash &*= 1_099_511_628_211
        }
        return Int(hash % UInt64(bucketCount))
    }

    private static func bucketMetadata(
        metadata: [String: String],
        bucketIndex: Int,
        bucketCount: Int,
        strategy: String
    ) -> [String: String] {
        metadata.merging([
            "bucket": "\(bucketIndex + 1)/\(bucketCount)",
            "fingerprintStrategy": strategy
        ]) { _, new in new }
    }

    private static func buildMetadata(current: TypeCount, previous: TypeCount?) -> [String: String] {
        [
            "type": current.typeIdentifier,
            "currentCount": "\(current.count)",
            "previousCount": "\(previous?.count ?? 0)",
            "currentArchive": current.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
            "previousArchive": previous?.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil"
        ]
    }
}

private enum TypeCountDetailCacheRecordKind {
    case added
    case disappeared
    case unchanged
}

private struct DetailCacheAccumulator {
    private let calendar = Calendar.current
    private var bins: [Date: (added: Int, disappeared: Int, unchanged: Int)] = [:]

    var addedCount = 0
    var disappearedCount = 0
    var addedPreview = NewestRecordBuffer(limit: TypeCountDetailCache.previewLimit)
    var disappearedPreview = NewestRecordBuffer(limit: TypeCountDetailCache.previewLimit)
    var earliestRecordDate: Date?
    var latestRecordDate: Date?

    var dailyBins: [TypeCountDailyChangeBin] {
        bins
            .map { dayStart, counts in
                TypeCountDailyChangeBin(
                    dayStart: dayStart,
                    added: counts.added,
                    disappeared: counts.disappeared,
                    unchanged: counts.unchanged
                )
            }
            .sorted { $0.dayStart < $1.dayStart }
    }

    mutating func add(_ record: HealthRecordValue, as kind: TypeCountDetailCacheRecordKind) {
        updateDateRange(with: record.startDate)

        let dayStart = calendar.startOfDay(for: record.startDate)
        var bin = bins[dayStart] ?? (added: 0, disappeared: 0, unchanged: 0)

        switch kind {
        case .added:
            addedCount += 1
            addedPreview.append(record)
            bin.added += 1
        case .disappeared:
            disappearedCount += 1
            disappearedPreview.append(record)
            bin.disappeared += 1
        case .unchanged:
            bin.unchanged += 1
        }

        bins[dayStart] = bin
    }

    private mutating func updateDateRange(with date: Date) {
        if earliestRecordDate.map({ date < $0 }) ?? true {
            earliestRecordDate = date
        }
        if latestRecordDate.map({ date > $0 }) ?? true {
            latestRecordDate = date
        }
    }
}

private struct NewestRecordBuffer {
    let limit: Int
    private var storage: [HealthRecordValue] = []
    private var oldestPreviewIndex: Int?

    init(limit: Int) {
        self.limit = limit
    }

    var records: [HealthRecordValue] {
        storage.sorted { $0.startDate > $1.startDate }
    }

    mutating func append(_ record: HealthRecordValue) {
        guard limit > 0 else { return }

        if storage.count < limit {
            storage.append(record)
            refreshOldestPreviewIndex()
            return
        }

        guard let oldestPreviewIndex,
              record.startDate > storage[oldestPreviewIndex].startDate else {
            return
        }

        storage[oldestPreviewIndex] = record
        refreshOldestPreviewIndex()
    }

    private mutating func refreshOldestPreviewIndex() {
        oldestPreviewIndex = storage.indices.min { lhs, rhs in
            storage[lhs].startDate < storage[rhs].startDate
        }
    }
}
