HealthProbe / HealthProbe / Views / Snapshots / SnapshotDetailView.swift
1 contributor
1159 lines | 41.78kb
import Charts
import SwiftUI
import SwiftData
import UIKit

struct SnapshotDetailView: View {
    let snapshot: HealthSnapshot
    let baseline: HealthSnapshot?
    let profile: LocalDeviceProfile?

    @Query(sort: \HealthSnapshot.timestamp) private var allSnapshots: [HealthSnapshot]
    @Query private var allDeltas: [SnapshotDelta]
    @State private var displayedSnapshot: HealthSnapshot?
    @State private var archiveTypeRows: [SnapshotArchiveTypeRow]?
    @State private var archiveTypeError: String?

    private var currentSnapshot: HealthSnapshot {
        displayedSnapshot ?? snapshot
    }

    private var currentDelta: SnapshotDelta? {
        allDeltas.first { $0.toSnapshotID == currentSnapshot.id }
    }

    private var currentDeltaSummary: SnapshotDeltaListSummary? {
        currentDelta?.listSummary
    }

    private var allTypeDeltas: [TypeDelta] {
        (currentDelta?.typeDeltas ?? [])
            .sorted { $0.displayName.localizedCompare($1.displayName) == .orderedAscending }
    }

    private var archiveReloadID: String {
        [
            currentSnapshot.id.uuidString,
            String(currentSnapshot.archiveObservationID ?? -1),
            String(baseline?.archiveObservationID ?? -1)
        ].joined(separator: "|")
    }

    private var summaryTypeCount: Int? {
        if let archiveTypeRows {
            return archiveTypeRows.count
        }
        guard currentSnapshot.hasCurrentCachedSummary else { return nil }
        return currentSnapshot.cachedTypeCount
    }

    private var summaryRecordCount: Int? {
        if let archiveTypeRows {
            return archiveTypeRows.reduce(0) { $0 + $1.currentCount }
        }
        guard currentSnapshot.hasCurrentCachedSummary else { return nil }
        return currentSnapshot.cachedRecordCount
    }

    private var summaryEarliestRecordDate: Date? {
        archiveTypeRows?.compactMap(\.earliestStartDate).min() ?? currentSnapshot.cachedEarliestRecordDate
    }

    private var summaryLatestRecordDate: Date? {
        archiveTypeRows?.compactMap(\.latestEndDate).max() ?? currentSnapshot.cachedLatestRecordDate
    }

    private var archiveRecordChangeCount: Int? {
        archiveTypeRows?.reduce(0) { $0 + $1.recordChangeCount }
    }

    private var archiveAffectedMetricCount: Int? {
        archiveTypeRows?.filter(\.hasChanges).count
    }

    private var deviceDisplayName: String {
        if let name = profile?.name, !name.isEmpty { return name }
        return currentSnapshot.deviceName.isEmpty ? "Unknown device" : currentSnapshot.deviceName
    }

    private var timelineSnapshots: [HealthSnapshot] {
        allSnapshots.filter { candidate in
            if currentSnapshot.deviceID.isEmpty {
                return candidate.deviceID.isEmpty
            }
            return candidate.deviceID == currentSnapshot.deviceID
        }
    }

    @State private var showShareSheet = false
    @State private var pdfExportURL: URL?
    @State private var isExporting = false
    @State private var showMetadataSheet = false

    var body: some View {
        List {
            evolutionSection
        }
        .navigationTitle("Snapshot")
        .navigationBarTitleDisplayMode(.inline)
        .safeAreaInset(edge: .top, spacing: 0) {
            SnapshotNavigationHeader(
                snapshots: timelineSnapshots,
                currentSnapshot: currentSnapshot,
                onSnapshotSelected: { displayedSnapshot = $0 }
            )
                .frame(height: 64)
        }
        .toolbar {
            ToolbarItem(placement: .principal) {
                snapshotToolbarTitle
            }
            ToolbarItem(placement: .navigationBarTrailing) {
                HStack(spacing: 12) {
                    Button {
                        showMetadataSheet = true
                    } label: {
                        Image(systemName: "info.circle")
                    }
                    .accessibilityLabel("View snapshot details")

                    if isExporting {
                        ProgressView()
                            .accessibilityLabel("Generating PDF")
                    } else {
                        Button {
                            exportAsPDF()
                        } label: {
                            Image(systemName: "square.and.arrow.up")
                        }
                        .accessibilityLabel("Export snapshot as PDF")
                    }
                }
            }
        }
        .sheet(isPresented: $showMetadataSheet) {
            metadataSheetContent
        }
        .sheet(isPresented: $showShareSheet) {
            if let url = pdfExportURL {
                ShareSheet(items: [url])
                    .ignoresSafeArea()
            }
        }
        .task(id: archiveReloadID) {
            await loadArchiveTypeRows()
        }
    }

    private func exportAsPDF() {
        isExporting = true
        let reportData = SnapshotPDFExporter.extractReportData(
            snapshot: currentSnapshot,
            baseline: baseline,
            profile: profile
        )
        let timestamp = currentSnapshot.timestamp
        Task(priority: .userInitiated) {
            let pdfData = SnapshotPDFExporter.generatePDF(from: reportData)
            let formatter = DateFormatter()
            formatter.dateFormat = "yyyy-MM-dd-HH-mm"
            let name = "HealthProbe-Snapshot-\(formatter.string(from: timestamp)).pdf"
            let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)
            try? pdfData.write(to: url)
            isExporting = false
            pdfExportURL = url
            showShareSheet = true
        }
    }

    @MainActor
    private func loadArchiveTypeRows() async {
        guard let currentObservationID = currentSnapshot.archiveObservationID else {
            archiveTypeRows = nil
            archiveTypeError = nil
            return
        }

        do {
            let cache = try CoreDataArchiveCacheStore()
            let currentSummaries = try cache.typeSummaries(observationID: currentObservationID)
            let baselineObservationID = baseline?.archiveObservationID
            let baselineSummaries = try baselineObservationID.map {
                try cache.typeSummaries(observationID: $0)
            } ?? []

            let currentByType = Dictionary(uniqueKeysWithValues: currentSummaries.map { ($0.sampleTypeIdentifier, $0) })
            let baselineByType = Dictionary(uniqueKeysWithValues: baselineSummaries.map { ($0.sampleTypeIdentifier, $0) })
            let allTypeIdentifiers = Set(currentByType.keys).union(baselineByType.keys)

            var rows: [SnapshotArchiveTypeRow] = []
            rows.reserveCapacity(allTypeIdentifiers.count)

            for typeIdentifier in allTypeIdentifiers {
                let current = currentByType[typeIdentifier]
                let baselineSummary = baselineByType[typeIdentifier]
                let diff: HealthArchiveDiffSummary
                if let baselineObservationID {
                    diff = try await SQLiteHealthArchiveStore.shared.diffSummary(HealthArchiveDiffRequest(
                        fromObservationID: baselineObservationID,
                        toObservationID: currentObservationID,
                        sampleTypeIdentifier: typeIdentifier
                    ))
                } else {
                    diff = HealthArchiveDiffSummary(
                        fromObservationID: currentObservationID,
                        toObservationID: currentObservationID,
                        sampleTypeIdentifier: typeIdentifier,
                        appearedCount: 0,
                        disappearedCount: 0,
                        representationChangedCount: 0
                    )
                }

                rows.append(SnapshotArchiveTypeRow(
                    typeIdentifier: typeIdentifier,
                    displayName: current?.displayName ?? baselineSummary?.displayName ?? typeIdentifier,
                    currentCount: current?.visibleRecordCount ?? 0,
                    previousCount: baselineSummary?.visibleRecordCount,
                    appearedCount: diff.appearedCount,
                    disappearedCount: diff.disappearedCount,
                    representationChangedCount: diff.representationChangedCount,
                    earliestStartDate: current?.earliestStartDate,
                    latestEndDate: current?.latestEndDate
                ))
            }

            archiveTypeRows = rows.sorted {
                $0.displayName.localizedCompare($1.displayName) == .orderedAscending
            }
            archiveTypeError = nil
        } catch {
            archiveTypeRows = nil
            archiveTypeError = error.localizedDescription
        }
    }

    @ViewBuilder
    private var snapshotToolbarTitle: some View {
        if #available(iOS 26.0, *) {
            Text("Snapshot")
                .font(.headline.weight(.semibold))
                .padding(.horizontal, 18)
                .frame(height: 36)
                .background(Color(.systemBackground).opacity(0.08), in: Capsule())
                .glassEffect(
                    .regular.tint(Color(.systemBackground).opacity(0.12)),
                    in: Capsule()
                )
        } else {
            Text(currentSnapshot.timestamp, format: .dateTime.month().day().hour().minute())
                .font(.headline.weight(.semibold))
                .padding(.horizontal, 18)
                .frame(height: 36)
                .background(.ultraThinMaterial, in: Capsule())
        }
    }

    @ViewBuilder
    private var metadataSheetContent: some View {
        NavigationStack {
            ScrollView {
                VStack(alignment: .leading, spacing: 12) {
                    // Title with Date
                    VStack(spacing: 4) {
                        Text("Snapshot")
                            .font(.headline.weight(.semibold))
                        Text(currentSnapshot.timestamp, format: .dateTime.month().day().hour().minute())
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                    }
                    .frame(maxWidth: .infinity, alignment: .center)
                    .padding(12)
                    .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12))

                    // Data Range
                    SnapshotDataRangeIndicator(
                        oldestRecordDate: summaryEarliestRecordDate,
                        newestRecordDate: summaryLatestRecordDate,
                        quality: currentSnapshot.snapshotQuality
                    )

                    // Summary Stats (compact)
                    VStack(spacing: 12) {
                        if let summaryTypeCount,
                           let summaryRecordCount {
                            HStack(spacing: 16) {
                                statCompact(label: "Types", value: "\(summaryTypeCount)")
                                Divider()
                                statCompact(label: "Records", value: "\(summaryRecordCount)")
                            }
                            .font(.caption)
                            .foregroundStyle(.secondary)
                        } else {
                            Text("Snapshot summary unavailable")
                                .font(.caption)
                                .foregroundStyle(.secondary)
                                .frame(maxWidth: .infinity, alignment: .center)
                        }
                    }
                    .padding(12)
                    .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12))

                    // Device (collapsible)
                    DisclosureGroup {
                        VStack(alignment: .leading, spacing: 12) {
                            DetailRow(label: "Version") {
                                Text(extractOSVersion(currentSnapshot.osVersion))
                                    .foregroundStyle(.secondary)
                                    .font(.caption.monospacedDigit())
                            }
                            Divider()
                            DetailRow(label: "Build") {
                                Text(extractBuildNumber(currentSnapshot.osVersion))
                                    .foregroundStyle(.secondary)
                                    .font(.caption.monospacedDigit())
                            }
                        }
                        .padding(.top, 8)
                    } label: {
                        HStack(spacing: 8) {
                            Image(systemName: "iphone")
                                .font(.system(size: 16, weight: .semibold))
                            Text(deviceDisplayName)
                                .font(.subheadline.weight(.semibold))
                        }
                    }
                    .padding(12)
                    .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12))

                    // Comparison (if baseline exists)
                    if let baseline {
                        comparisonSection(baseline: baseline)
                    }

                    Spacer()
                }
                .padding(16)
            }
            .navigationTitle("Snapshot")
            .navigationBarTitleDisplayMode(.inline)
        }
    }

    @ViewBuilder
    private func comparisonSection(baseline: HealthSnapshot) -> some View {
        let delta = archiveRecordChangeCount ?? currentDeltaSummary?.absoluteRecordChangeCount ?? 0
        let deltaPercent = computeDeltaPercent(delta: delta, baseline: baseline)
        let affectedMetricCount = archiveAffectedMetricCount ?? currentDeltaSummary?.affectedMetricCount ?? 0
        let isSignificant = delta > 0 || affectedMetricCount > 0 || (deltaPercent > 10)

        DisclosureGroup {
            VStack(alignment: .leading, spacing: 12) {
                DetailRow(label: "Baseline") {
                    Text(baseline.timestamp, format: .dateTime.month().day().hour().minute())
                        .foregroundStyle(.secondary)
                }
                Divider()
                DetailRow(label: "Time Span") {
                    let days = Calendar.current.dateComponents([.day], from: baseline.timestamp, to: currentSnapshot.timestamp).day ?? 0
                    Text(days == 0 ? "Same day" : "\(days) days")
                        .foregroundStyle(.secondary)
                }
                if archiveTypeRows != nil || currentDeltaSummary != nil {
                    Divider()
                    DetailRow(label: "Changed Metrics") {
                        Text("\(affectedMetricCount)")
                            .foregroundStyle(.secondary)
                    }
                    Divider()
                    DetailRow(label: "Record Changes") {
                        Text("\(delta)")
                            .foregroundStyle(.secondary)
                    }
                }
            }
            .padding(.top, 8)
        } label: {
            HStack(spacing: 8) {
                Image(systemName: "arrow.left.and.right.square")
                    .font(.system(size: 16, weight: .semibold))
                Text("Comparison")
                    .font(.subheadline.weight(.semibold))
                Spacer()
                if isSignificant {
                    SeverityBadge(delta: delta)
                        .frame(height: 24)
                } else {
                    Text("–")
                        .font(.caption2.weight(.semibold))
                        .foregroundStyle(.secondary)
                }
            }
        }
        .padding(12)
        .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12))
    }

    private func shortOSVersion(_ full: String) -> Text {
        if full.hasPrefix("iOS ") {
            let version = full.dropFirst(4).prefix(while: { $0 != " " })
            return Text("iOS \(version)")
        }
        return Text(full)
    }

    private func extractOSVersion(_ full: String) -> String {
        if full.hasPrefix("iOS ") {
            let versionPart = full.dropFirst(4).prefix(while: { $0 != " " && $0 != "(" })
            return String(versionPart)
        }
        return full
    }
    
    private func extractBuildNumber(_ full: String) -> String {
        if let start = full.firstIndex(of: "("), let end = full.firstIndex(of: ")") {
            let buildPart = String(full[full.index(after: start)..<end])
            return buildPart.hasPrefix("Build ") ? String(buildPart.dropFirst(6)) : buildPart
        }
        return full
    }

    private func computeDeltaPercent(delta: Int, baseline: HealthSnapshot) -> Double {
        if let archiveTypeRows {
            let baselineTotal = archiveTypeRows.reduce(0) { $0 + ($1.previousCount ?? 0) }
            return baselineTotal > 0 ? Double(delta) / Double(baselineTotal) * 100 : 0
        }

        let baselineTotal = baseline.hasCurrentCachedSummary ? baseline.cachedRecordCount : 0
        return baselineTotal > 0 ? Double(delta) / Double(baselineTotal) * 100 : 0
    }

    private func statCompact(label: String, value: String) -> some View {
        VStack(alignment: .center, spacing: 2) {
            Text(label)
                .font(.caption2.weight(.medium))
            Text(value)
                .font(.subheadline.weight(.semibold).monospacedDigit())
                .foregroundStyle(.primary)
        }
        .frame(maxWidth: .infinity)
    }

    private var evolutionSection: some View {
        Section("Data Types") {
            if let archiveTypeRows {
                if archiveTypeRows.isEmpty {
                    Text("No data types are available for this snapshot.")
                        .foregroundStyle(.secondary)
                } else {
                    ForEach(archiveTypeRows) { row in
                        NavigationLink {
                            DataTypeSnapshotDetailView(
                                snapshot: currentSnapshot,
                                typeIdentifier: row.typeIdentifier,
                                displayName: row.displayName
                            )
                        } label: {
                            SnapshotArchiveTypeRowView(row: row, hasBaseline: baseline != nil)
                        }
                    }
                }
            } else if baseline == nil {
                Text("This snapshot starts the chain, so no baseline comparison is available.")
                    .foregroundStyle(.secondary)
            } else if currentDelta == nil {
                Text("Cached metric summary unavailable for this snapshot.")
                    .foregroundStyle(.secondary)
            } else if allTypeDeltas.isEmpty {
                Text("No data types are available for this snapshot.")
                    .foregroundStyle(.secondary)
            } else {
                ForEach(allTypeDeltas) { typeDelta in
                    NavigationLink {
                        DataTypeSnapshotDetailView(
                            snapshot: currentSnapshot,
                            typeIdentifier: typeDelta.typeIdentifier,
                            displayName: typeDelta.displayName
                        )
                    } label: {
                        SnapshotTypeDeltaRow(typeDelta: typeDelta)
                    }
                }
            }
        }
    }
}

private struct SnapshotArchiveTypeRow: Identifiable {
    let typeIdentifier: String
    let displayName: String
    let currentCount: Int
    let previousCount: Int?
    let appearedCount: Int
    let disappearedCount: Int
    let representationChangedCount: Int
    let earliestStartDate: Date?
    let latestEndDate: Date?

    var id: String { typeIdentifier }

    var recordChangeCount: Int {
        appearedCount + disappearedCount + representationChangedCount
    }

    var hasChanges: Bool {
        currentDelta != 0 || recordChangeCount > 0
    }

    var currentDelta: Int {
        guard let previousCount else { return currentCount }
        return currentCount - previousCount
    }
}

private struct SnapshotArchiveTypeRowView: View {
    let row: SnapshotArchiveTypeRow
    let hasBaseline: Bool

    private var countText: String {
        "\(row.currentCount)"
    }

    private var changeLabel: String {
        guard hasBaseline else { return "Stored" }
        if row.disappearedCount > 0 {
            return "\(row.disappearedCount) missing"
        }
        if row.appearedCount > 0 {
            return "\(row.appearedCount) new"
        }
        if row.representationChangedCount > 0 {
            return "\(row.representationChangedCount) changed"
        }
        if row.currentDelta != 0 {
            let prefix = row.currentDelta > 0 ? "+" : ""
            return "\(prefix)\(row.currentDelta) records"
        }
        return "No changes"
    }

    private var changeColor: Color {
        guard hasBaseline else { return .secondary }
        if row.disappearedCount > 0 { return .criticalRed }
        if row.hasChanges { return .warningAmber }
        return .secondary
    }

    var body: some View {
        HStack(spacing: 12) {
            VStack(alignment: .leading, spacing: 3) {
                Text(row.displayName)
                    .font(.subheadline)
                Text(row.typeIdentifier)
                    .font(.caption2)
                    .foregroundStyle(.secondary)
                    .lineLimit(1)
                    .truncationMode(.middle)
            }

            Spacer()

            VStack(alignment: .trailing, spacing: 4) {
                Text(countText)
                    .font(.subheadline.monospacedDigit())
                    .foregroundStyle(.primary)
                Text(changeLabel)
                    .font(.caption.weight(.semibold))
                    .foregroundStyle(changeColor)
            }
        }
        .accessibilityElement(children: .combine)
    }
}

private struct SnapshotTypeDeltaRow: View {
    let typeDelta: TypeDelta

    private var deltaLabel: String {
        switch typeDelta.transition {
        case .changed:
            if typeDelta.countDelta == 0 {
                return "Content changed"
            }
            let prefix = typeDelta.countDelta > 0 ? "+" : ""
            return "\(prefix)\(typeDelta.countDelta) records"
        case .appeared:
            return "New"
        case .disappeared:
            return "Missing"
        case .unchanged:
            return "No changes"
        }
    }

    private var deltaColor: Color {
        switch typeDelta.transition {
        case .disappeared:
            return .criticalRed
        case .changed, .appeared:
            return .warningAmber
        case .unchanged:
            return .secondary
        }
    }

    var body: some View {
        HStack(spacing: 12) {
            VStack(alignment: .leading, spacing: 3) {
                Text(typeDelta.displayName)
                    .font(.subheadline)
                Text(typeDelta.typeIdentifier)
                    .font(.caption2)
                    .foregroundStyle(.secondary)
                    .lineLimit(1)
                    .truncationMode(.middle)
            }

            Spacer()

            Text(deltaLabel)
                .font(.caption.weight(.semibold))
                .foregroundStyle(deltaColor)
        }
        .accessibilityElement(children: .combine)
    }
}

private enum EvolutionXAxisMode: String, CaseIterable, Identifiable {
    case time
    case snapshots

    var id: String { rawValue }

    var title: String {
        switch self {
        case .time:
            return "Time"
        case .snapshots:
            return "Snapshots"
        }
    }
}

private struct TypeEvolutionSeries: Identifiable {
    let typeIdentifier: String
    let displayName: String
    let points: [TypeEvolutionPoint]

    var id: String { typeIdentifier }

    var latestPoint: TypeEvolutionPoint? {
        points.max { $0.timestamp < $1.timestamp }
    }

    var selectedOrLatestPoint: TypeEvolutionPoint? {
        points.last
    }

    var yDomain: ClosedRange<Double> {
        let counts = points.map(\.count)
        guard let minCount = counts.min(), let maxCount = counts.max() else {
            return 0...1
        }

        if minCount == maxCount {
            let lower = max(0, minCount - 1)
            return Double(lower)...Double(maxCount + 1)
        }

        return Double(max(0, minCount))...Double(maxCount)
    }
}

private struct TypeEvolutionPoint: Identifiable {
    let snapshotID: UUID
    let timestamp: Date
    let count: Int

    var id: UUID { snapshotID }
}

private struct TypeEvolutionChart: View {
    let series: TypeEvolutionSeries
    let contextSnapshots: [HealthSnapshot]
    let xAxisMode: EvolutionXAxisMode
    let selectedSnapshotID: UUID
    let selectedTimestamp: Date
    let snapshotNumbers: [UUID: Int]
    let baselineTypeCount: TypeCount?

    private struct SnapshotAxisPoint: Identifiable {
        let snapshotID: UUID
        let contextIndex: Int
        let timestamp: Date
        let count: Int

        var id: UUID { snapshotID }
    }

    private var selectedPoint: TypeEvolutionPoint? {
        series.points.first { $0.snapshotID == selectedSnapshotID }
    }

    private var isMissingInSelectedSnapshot: Bool {
        selectedPoint == nil
    }

    private var previousPoint: TypeEvolutionPoint? {
        guard let selectedIndex = series.points.firstIndex(where: { $0.snapshotID == selectedSnapshotID }),
              selectedIndex > 0 else { return nil }
        return series.points[selectedIndex - 1]
    }

    private var delta: Int? {
        guard let selected = selectedPoint,
              let previous = previousPoint,
              selected.count >= 0,
              previous.count >= 0 else { return nil }
        return selected.count - previous.count
    }

    private var isSignificantChange: Bool {
        guard let d = delta, let prev = previousPoint?.count, prev > 0 else { return false }
        let percentChange = abs(Double(d)) / Double(prev) * 100
        return percentChange > 10 || d > 0
    }

    private var contextPointCountLabel: String {
        "\(series.points.count)/\(contextSnapshots.count) snapshots with data"
    }

    private var contextAxisPoints: [SnapshotAxisPoint] {
        contextSnapshots.enumerated().compactMap { index, snapshot in
            guard let candidateTypeCount = snapshot.typeCounts?.first(where: {
                $0.typeIdentifier == series.typeIdentifier
            }), candidateTypeCount.count >= 0 else {
                return nil
            }

            return SnapshotAxisPoint(
                snapshotID: snapshot.id,
                contextIndex: index,
                timestamp: snapshot.timestamp,
                count: candidateTypeCount.count
            )
        }
    }

    private var contextAxisGroups: [[SnapshotAxisPoint]] {
        guard !contextAxisPoints.isEmpty else { return [] }

        var groups: [[SnapshotAxisPoint]] = []
        var currentGroup: [SnapshotAxisPoint] = [contextAxisPoints[0]]

        for point in contextAxisPoints.dropFirst() {
            if let previous = currentGroup.last, point.contextIndex == previous.contextIndex + 1 {
                currentGroup.append(point)
            } else {
                groups.append(currentGroup)
                currentGroup = [point]
            }
        }

        groups.append(currentGroup)
        return groups
    }

    private var selectedContextIndex: Int? {
        contextSnapshots.firstIndex { $0.id == selectedSnapshotID }
    }

    private var snapshotAxisValues: [Int] {
        Array(contextSnapshots.indices)
    }

    private func snapshotAxisLabel(for index: Int) -> String {
        guard contextSnapshots.indices.contains(index) else { return "\(index + 1)" }
        let snapshotID = contextSnapshots[index].id
        return "\(snapshotNumbers[snapshotID] ?? index + 1)"
    }

    private var snapshotAxisDomain: ClosedRange<Int> {
        guard let first = snapshotAxisValues.first, let last = snapshotAxisValues.last else {
            return 0...0
        }
        return first...last
    }

    @ViewBuilder
    private var chartContent: some View {
        switch xAxisMode {
        case .time:
            timeChart
        case .snapshots:
            snapshotChart
        }
    }

    private var timeChart: some View {
        Chart {
            ForEach(contextSnapshots, id: \.id) { item in
                RuleMark(x: .value("Timeline", item.timestamp))
                    .foregroundStyle(Color.secondary.opacity(0.10))
            }

            RuleMark(x: .value("Selected Snapshot", selectedTimestamp))
                .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3]))
                .foregroundStyle(Color.secondary.opacity(0.55))

            ForEach(series.points) { point in
                LineMark(
                    x: .value("Date", point.timestamp),
                    y: .value("Records", point.count)
                )
                .interpolationMethod(.linear)

                PointMark(
                    x: .value("Date", point.timestamp),
                    y: .value("Records", point.count)
                )
                .symbolSize(24)

                if point.snapshotID == selectedSnapshotID {
                    PointMark(
                        x: .value("Selected Date", point.timestamp),
                        y: .value("Selected Records", point.count)
                    )
                    .symbolSize(64)
                }
            }
        }
    }

    private var snapshotChart: some View {
        Chart {
            ForEach(contextSnapshots.indices, id: \.self) { index in
                RuleMark(x: .value("Snapshot", index))
                    .foregroundStyle(Color.secondary.opacity(0.10))
            }

            if let selectedContextIndex {
                RuleMark(x: .value("Selected Snapshot", selectedContextIndex))
                    .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3]))
                    .foregroundStyle(Color.secondary.opacity(0.55))
            }

            ForEach(contextAxisGroups.indices, id: \.self) { groupIndex in
                let group = contextAxisGroups[groupIndex]

                ForEach(group) { point in
                    LineMark(
                        x: .value("Snapshot", point.contextIndex),
                        y: .value("Records", point.count)
                    )
                    .interpolationMethod(.linear)

                    PointMark(
                        x: .value("Snapshot", point.contextIndex),
                        y: .value("Records", point.count)
                    )
                    .symbolSize(24)

                    if point.snapshotID == selectedSnapshotID {
                        PointMark(
                            x: .value("Selected Snapshot", point.contextIndex),
                            y: .value("Selected Records", point.count)
                        )
                        .symbolSize(64)
                    }
                }
            }
        }
        .chartXAxis {
            AxisMarks(values: snapshotAxisValues) { value in
                AxisGridLine()
                AxisTick()
                if let rawIndex = value.as(Int.self) {
                    AxisValueLabel(snapshotAxisLabel(for: rawIndex))
                }
            }
        }
        .chartXScale(domain: snapshotAxisDomain)
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            HStack(alignment: .firstTextBaseline) {
                Text(series.displayName)
                    .font(.subheadline.weight(.semibold))
                Spacer()
                VStack(alignment: .trailing, spacing: 4) {
                    if let selectedPoint {
                        Text("\(selectedPoint.count)")
                            .font(.subheadline.monospacedDigit())
                            .foregroundStyle(.secondary)
                    }
                    if isSignificantChange, let delta {
                        SeverityBadge(delta: delta)
                    }
                }
            }

            chartContent
            .chartYScale(domain: series.yDomain)
            .chartXAxis {
                switch xAxisMode {
                case .time:
                    AxisMarks(values: .automatic(desiredCount: 3))
                case .snapshots:
                    AxisMarks(values: snapshotAxisValues) { value in
                        AxisGridLine()
                        AxisTick()
                        if let rawIndex = value.as(Int.self) {
                            AxisValueLabel(snapshotAxisLabel(for: rawIndex))
                        }
                    }
                }
            }
            .chartYAxis {
                AxisMarks(position: .leading, values: .automatic(desiredCount: 3))
            }
            .frame(height: 120)
            .foregroundStyle(Color.accentColor)

            if isMissingInSelectedSnapshot {
                Text("Datatype missing in this snapshot")
                    .font(.caption2)
                    .foregroundStyle(Color.warningAmber)
            } else if series.points.count == 1 {
                Text("Only one measurement")
                    .font(.caption2)
                    .foregroundStyle(.secondary)
            }

            Text(contextPointCountLabel)
                .font(.caption2)
                .foregroundStyle(.secondary)
        }
        .padding(.vertical, 4)
        .accessibilityElement(children: .combine)
    }
}

private struct SnapshotTypeCountRow: View {
    let typeCount: TypeCount
    let baselineTypeCount: TypeCount?

    private var countText: String {
        if typeCount.isUnsupported { return "Unsupported" }
        if typeCount.count == -1   { return "Unavailable" }
        return "\(typeCount.count)"
    }

    private var countColor: Color {
        if typeCount.isUnsupported { return .secondary }
        if typeCount.count == -1   { return Color.criticalRed }
        if typeCount.quality != SnapshotQuality.complete { return Color.warningAmber }
        return Color.primary
    }

    private var delta: Int? {
        guard let b = baselineTypeCount,
              typeCount.count >= 0,
              b.count >= 0 else { return nil }
        return typeCount.count - b.count
    }

    private var isSignificantChange: Bool {
        guard let d = delta, let b = baselineTypeCount?.count, b > 0 else { return false }
        let percentChange = abs(Double(d)) / Double(b) * 100
        return percentChange > 10 || d > 0
    }

    var body: some View {
        HStack(spacing: 12) {
            VStack(alignment: .leading, spacing: 3) {
                Text(typeCount.displayName)
                    .font(.subheadline)
                Text(typeCount.typeIdentifier)
                    .font(.caption2)
                    .foregroundStyle(.secondary)
                    .lineLimit(1)
                    .truncationMode(.middle)
            }
            Spacer()
            VStack(alignment: .trailing, spacing: 4) {
                Text(countText)
                    .font(.subheadline.monospacedDigit())
                    .foregroundStyle(countColor)
                if isSignificantChange, let delta {
                    SeverityBadge(delta: delta)
                }
            }
        }
        .accessibilityElement(children: .combine)
    }
}

private struct SnapshotDataRangeIndicator: View {
    let oldestRecordDate: Date?
    let newestRecordDate: Date?
    let quality: SnapshotQuality

    private var hasDateRange: Bool {
        oldestRecordDate != nil && newestRecordDate != nil
    }

    private var daySpan: Int? {
        guard let oldest = oldestRecordDate, let newest = newestRecordDate else { return nil }
        return Calendar.current.dateComponents([.day], from: oldest, to: newest).day ?? 0
    }

    var body: some View {
        VStack(spacing: 12) {
            HStack(spacing: 8) {
                Text("Data Range")
                    .font(.headline.weight(.semibold))

                Spacer()

                qualityBadge
            }

            if hasDateRange {
                dateRangeVisualization
            } else {
                Text("No dated records available")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
                    .frame(maxWidth: .infinity, alignment: .center)
                    .padding(.vertical, 16)
            }
        }
        .padding(16)
        .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12))
    }

    @ViewBuilder
    private var qualityBadge: some View {
        if quality != .complete {
            Label("Incomplete", systemImage: "exclamationmark.triangle.fill")
                .font(.caption.weight(.medium))
                .foregroundStyle(Color.warningAmber)
                .padding(.horizontal, 8)
                .padding(.vertical, 4)
                .background(Color.warningAmber.opacity(0.12), in: Capsule())
        }
    }

    @ViewBuilder
    private var dateRangeVisualization: some View {
        if let oldest = oldestRecordDate, let newest = newestRecordDate, let span = daySpan {
            VStack(spacing: 12) {
                HStack(alignment: .top, spacing: 12) {
                    VStack(alignment: .center, spacing: 4) {
                        Image(systemName: "calendar.badge.clock")
                            .font(.system(size: 16, weight: .semibold))
                            .foregroundStyle(Color.healthyGreen)

                        VStack(alignment: .center, spacing: 2) {
                            Text("Oldest record")
                                .font(.caption2.weight(.medium))
                                .foregroundStyle(.secondary)
                            Text(oldest, format: .dateTime.month().day().year())
                                .font(.caption.weight(.semibold))
                        }
                    }
                    .frame(maxWidth: .infinity)

                    VStack(alignment: .center, spacing: 4) {
                        Text("\(span)")
                            .font(.system(size: 18, weight: .semibold).monospacedDigit())
                            .foregroundStyle(.primary)

                        Text("days")
                            .font(.caption2.weight(.medium))
                            .foregroundStyle(.secondary)
                    }

                    VStack(alignment: .center, spacing: 4) {
                        Image(systemName: "calendar.badge.clock")
                            .font(.system(size: 16, weight: .semibold))
                            .foregroundStyle(Color.accentColor)

                        VStack(alignment: .center, spacing: 2) {
                            Text("Newest record")
                                .font(.caption2.weight(.medium))
                                .foregroundStyle(.secondary)
                            Text(newest, format: .dateTime.month().day().year())
                                .font(.caption.weight(.semibold))
                        }
                    }
                    .frame(maxWidth: .infinity)
                }

                timelineBar
            }
        }
    }

    @ViewBuilder
    private var timelineBar: some View {
        if oldestRecordDate != nil, newestRecordDate != nil {
            ZStack(alignment: .leading) {
                RoundedRectangle(cornerRadius: 3)
                    .fill(Color(.systemGray5))

                RoundedRectangle(cornerRadius: 3)
                    .fill(
                        LinearGradient(
                            gradient: Gradient(colors: [Color.healthyGreen, Color.accentColor]),
                            startPoint: .leading,
                            endPoint: .trailing
                        )
                    )
                    .opacity(0.7)
            }
            .frame(height: 4)
        }
    }
}

private struct DetailRow<Content: View>: View {
    let label: String
    @ViewBuilder let content: () -> Content

    var body: some View {
        HStack {
            Text(label)
            Spacer()
            content()
        }
    }
}

private struct ShareSheet: UIViewControllerRepresentable {
    let items: [Any]

    func makeUIViewController(context: Context) -> UIActivityViewController {
        UIActivityViewController(activityItems: items, applicationActivities: nil)
    }

    func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}

#Preview {
    NavigationStack {
        SnapshotDetailView(
            snapshot: HealthSnapshot(
                timestamp: .now,
                osVersion: "iOS 26.4",
                deviceName: "Preview iPhone",
                deviceID: "preview-device"
            ),
            baseline: nil,
            profile: LocalDeviceProfile(deviceID: "preview-device")
        )
    }
    .modelContainer(for: [HealthSnapshot.self, SnapshotDelta.self, TypeDelta.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self], inMemory: true)
}