HealthProbe / HealthProbe / Utilities / SnapshotPDFExporter.swift
1 contributor
368 lines | 13.868kb
import CoreGraphics
import CoreText
import Foundation

// MARK: - Report data (value type, Sendable — passed to background task)

struct SnapshotReportData: Sendable {
    let timestamp: Date
    let osVersion: String
    let deviceName: String
    let deviceID: String
    let typeCounts: [TypeCountData]
    let baseline: BaselineData?

    struct TypeCountData: Sendable {
        let identifier: String
        let displayName: String
        let count: Int
    }

    struct BaselineData: Sendable {
        let timestamp: Date
        let totalChange: Int
        let changedCount: Int
        let countByIdentifier: [String: Int]
    }
}

// MARK: - Exporter

enum SnapshotPDFExporter {

    /// Reads SwiftData models. Must be called on the main actor.
    @MainActor
    static func extractReportData(
        snapshot: HealthSnapshot,
        baseline: HealthSnapshot?,
        profile: DeviceProfile?
    ) -> SnapshotReportData {
        let profileName: String? = {
            guard let n = profile?.name, !n.isEmpty else { return nil }
            return n
        }()

        let typeCounts = (snapshot.typeCounts ?? [])
            .sorted { $0.displayName.localizedCompare($1.displayName) == .orderedAscending }
            .map {
                SnapshotReportData.TypeCountData(
                    identifier: $0.typeIdentifier,
                    displayName: $0.displayName,
                    count: $0.count
                )
            }

        let baselineData: SnapshotReportData.BaselineData?
        if let baseline {
            let svc = SnapshotDiffService.shared
            baselineData = SnapshotReportData.BaselineData(
                timestamp: baseline.timestamp,
                totalChange: svc.totalAbsoluteChange(current: snapshot, baseline: baseline),
                changedCount: svc.diff(current: snapshot, baseline: baseline)
                    .filter { $0.previousTracked && $0.delta != 0 }.count,
                countByIdentifier: Dictionary(
                    uniqueKeysWithValues: (baseline.typeCounts ?? []).map { ($0.typeIdentifier, $0.count) }
                )
            )
        } else {
            baselineData = nil
        }

        // Device ID is truncated to first 8 chars — enough for local correlation,
        // not enough to uniquely identify the device in a shared report.
        let rawID = snapshot.deviceID
        let displayID = rawID.isEmpty ? "—" : String(rawID.prefix(8)) + "…"

        return SnapshotReportData(
            timestamp: snapshot.timestamp,
            osVersion: snapshot.osVersion.isEmpty ? "—" : snapshot.osVersion,
            deviceName: profileName ?? "This Device",
            deviceID: displayID,
            typeCounts: typeCounts,
            baseline: baselineData
        )
    }

    /// Generates PDF using only CoreGraphics + CoreText. Safe to call off the main thread.
    nonisolated static func generatePDF(from data: SnapshotReportData) -> Data {
        let pageSize = CGSize(width: 595.2, height: 841.8)
        let pdfData = NSMutableData()
        guard let consumer = CGDataConsumer(data: pdfData as CFMutableData) else { return Data() }
        var mediaBox = CGRect(origin: .zero, size: pageSize)
        guard let ctx = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else { return Data() }

        let pen = Pen(ctx: ctx, pageSize: pageSize, margin: 48)
        pen.beginPage()

        drawPageHeader(pen, timestamp: data.timestamp)
        drawSummarySection(pen, data: data)
        drawDeviceSection(pen, data: data)
        if let baseline = data.baseline {
            drawComparisonSection(pen, baseline: baseline)
        }
        drawDataTypesSection(pen, data: data)
        pen.endPage()

        ctx.closePDF()
        return pdfData as Data
    }

    // MARK: - Sections

    private static func drawPageHeader(_ pen: Pen, timestamp: Date) {
        text("HealthProbe", x: pen.margin, y: pen.y, font: pf(10), color: .secondary, pen: pen)
        pen.advance(16)
        text("Snapshot Report", x: pen.margin, y: pen.y, font: pf(22, .bold), color: .primary, pen: pen)
        pen.advance(30)
        text("Generated \(formatted(Date()))", x: pen.margin, y: pen.y, font: pf(9), color: .secondary, pen: pen)
        pen.advance(14)
        rule(pen)
        pen.advance(16)
    }

    private static func drawSummarySection(_ pen: Pen, data: SnapshotReportData) {
        let total = data.typeCounts.filter { $0.count > 0 }.reduce(0) { $0 + $1.count }
        sectionTitle(pen, "Summary")
        keyValue(pen, "Captured",      value: formatted(data.timestamp))
        keyValue(pen, "Tracked Types", value: "\(data.typeCounts.count)")
        keyValue(pen, "Total Records", value: "\(total)")
        pen.advance(12)
    }

    private static func drawDeviceSection(_ pen: Pen, data: SnapshotReportData) {
        sectionTitle(pen, "Device")
        keyValue(pen, "Name",      value: data.deviceName)
        keyValue(pen, "OS",        value: data.osVersion)
        keyValue(pen, "Device ID", value: data.deviceID)
        pen.advance(12)
    }

    private static func drawComparisonSection(_ pen: Pen, baseline: SnapshotReportData.BaselineData) {
        sectionTitle(pen, "Comparison vs. Baseline")
        keyValue(pen, "Baseline Date",  value: formatted(baseline.timestamp))
        keyValue(pen, "Total Changes",  value: baseline.totalChange == 0 ? "None" : "\(baseline.totalChange) records")
        keyValue(pen, "Changed Types",  value: "\(baseline.changedCount)")
        pen.advance(12)
    }

    private static func drawDataTypesSection(_ pen: Pen, data: SnapshotReportData) {
        guard !data.typeCounts.isEmpty else { return }
        let hasBaseline = data.baseline != nil
        sectionTitle(pen, "Data Types (\(data.typeCounts.count))")
        tableHeader(pen, hasBaseline: hasBaseline)
        for tc in data.typeCounts {
            pen.checkBreak(height: 16)
            let delta = data.baseline?.countByIdentifier[tc.identifier].map { tc.count - $0 }
            tableRow(pen, tc: tc, delta: delta, hasBaseline: hasBaseline)
        }
    }

    // MARK: - Drawing primitives

    private static func sectionTitle(_ pen: Pen, _ title: String) {
        pen.checkBreak(height: 40)
        text(title, x: pen.margin, y: pen.y, font: pf(12, .semibold), color: .primary, pen: pen)
        pen.advance(18)
    }

    private static func keyValue(_ pen: Pen, _ label: String, value: String) {
        pen.checkBreak(height: 18)
        let f = pf(10)
        text(label, x: pen.margin, y: pen.y, font: f, color: .secondary, pen: pen)
        text(value, x: pen.margin + pen.contentWidth - tw(value, font: f), y: pen.y, font: f, color: .primary, pen: pen)
        pen.advance(16)
    }

    private static func tableHeader(_ pen: Pen, hasBaseline: Bool) {
        let f = pf(8, .semibold)
        let countRight = pen.margin + pen.contentWidth - (hasBaseline ? 100 : 0)
        let deltaRight = pen.margin + pen.contentWidth

        text("TYPE",  x: pen.margin,                  y: pen.y, font: f, color: .tertiary, pen: pen)
        text("COUNT", x: countRight - tw("COUNT", font: f), y: pen.y, font: f, color: .tertiary, pen: pen)
        if hasBaseline {
            text("DELTA", x: deltaRight - tw("DELTA", font: f), y: pen.y, font: f, color: .tertiary, pen: pen)
        }
        pen.advance(12)
        rule(pen, alpha: 0.35, width: 0.25)
        pen.advance(4)
    }

    private static func tableRow(
        _ pen: Pen,
        tc: SnapshotReportData.TypeCountData,
        delta: Int?,
        hasBaseline: Bool
    ) {
        let nf = pf(9)
        let mf = mf(9)
        let countRight = pen.margin + pen.contentWidth - (hasBaseline ? 100 : 0)
        let deltaRight = pen.margin + pen.contentWidth
        let maxNameW   = countRight - pen.margin - 12

        var name = tc.displayName
        if tw(name, font: nf) > maxNameW { name = String(name.prefix(45)) + "…" }
        text(name, x: pen.margin, y: pen.y, font: nf, color: .primary, pen: pen)

        let cs = tc.count < 0 ? "err" : "\(tc.count)"
        text(cs, x: countRight - tw(cs, font: mf), y: pen.y, font: mf, color: .primary, pen: pen)

        if hasBaseline, let delta {
            let ds  = delta == 0 ? "—" : (delta > 0 ? "+\(delta)" : "\(delta)")
            let col: CGColor = delta > 0 ? .orange : delta < 0 ? .red : .tertiary
            text(ds, x: deltaRight - tw(ds, font: mf), y: pen.y, font: mf, color: col, pen: pen)
        }
        pen.advance(15)
    }

    private static func rule(_ pen: Pen, alpha: CGFloat = 1, width: CGFloat = 0.5) {
        let cgY = pen.pageSize.height - pen.y
        pen.ctx.saveGState()
        pen.ctx.setStrokeColor(CGColor(gray: 0.75, alpha: alpha))
        pen.ctx.setLineWidth(width)
        pen.ctx.move(to: CGPoint(x: pen.margin, y: cgY))
        pen.ctx.addLine(to: CGPoint(x: pen.margin + pen.contentWidth, y: cgY))
        pen.ctx.strokePath()
        pen.ctx.restoreGState()
        pen.advance(6)
    }

    // MARK: - CoreText

    private static func text(
        _ string: String,
        x: CGFloat,
        y: CGFloat,
        font: CTFont,
        color: CGColor,
        pen: Pen
    ) {
        let attrStr = NSAttributedString(string: string, attributes: [
            kCTFontAttributeName as NSAttributedString.Key: font,
            kCTForegroundColorAttributeName as NSAttributedString.Key: color
        ])
        let line = CTLineCreateWithAttributedString(attrStr)
        let cgY = pen.pageSize.height - y - CTFontGetAscent(font)
        pen.ctx.textMatrix = .identity
        pen.ctx.textPosition = CGPoint(x: x, y: cgY)
        CTLineDraw(line, pen.ctx)
    }

    private static func tw(_ string: String, font: CTFont) -> CGFloat {
        let attrStr = NSAttributedString(string: string, attributes: [
            kCTFontAttributeName as NSAttributedString.Key: font
        ])
        return CGFloat(CTLineGetTypographicBounds(CTLineCreateWithAttributedString(attrStr), nil, nil, nil))
    }

    // MARK: - Font helpers (CTFont, no UIKit)

    private enum Weight { case regular, semibold, bold }

    private static func pf(_ size: CGFloat, _ weight: Weight = .regular) -> CTFont {
        let name: CFString
        switch weight {
        case .regular:  name = "HelveticaNeue" as CFString
        case .semibold: name = "HelveticaNeue-Medium" as CFString
        case .bold:     name = "HelveticaNeue-Bold" as CFString
        }
        return CTFontCreateWithName(name, size, nil)
    }

    private static func mf(_ size: CGFloat) -> CTFont {
        CTFontCreateWithName("Menlo-Regular" as CFString, size, nil)
    }

    private static func formatted(_ date: Date) -> String {
        let f = DateFormatter()
        f.dateStyle = .medium
        f.timeStyle = .short
        return f.string(from: date)
    }
}

// MARK: - CGColor shortcuts

private extension CGColor {
    static let primary   = CGColor(gray: 0.05, alpha: 1)
    static let secondary = CGColor(gray: 0.40, alpha: 1)
    static let tertiary  = CGColor(gray: 0.60, alpha: 1)
    static let orange    = CGColor(srgbRed: 1.00, green: 0.58, blue: 0.00, alpha: 1)
    static let red       = CGColor(srgbRed: 1.00, green: 0.23, blue: 0.19, alpha: 1)
}

// MARK: - Pen (page state, CoreGraphics only)

private final class Pen {
    let ctx: CGContext
    let pageSize: CGSize
    let margin: CGFloat
    private(set) var y: CGFloat
    private(set) var pageNumber: Int = 0

    var contentWidth: CGFloat  { pageSize.width  - margin * 2 }
    var bottomBoundary: CGFloat { pageSize.height - margin - 28 }

    init(ctx: CGContext, pageSize: CGSize, margin: CGFloat) {
        self.ctx      = ctx
        self.pageSize = pageSize
        self.margin   = margin
        self.y        = margin
    }

    func beginPage() {
        ctx.beginPDFPage(nil)
        y = margin
        pageNumber += 1
    }

    func endPage() {
        drawFooter()
        ctx.endPDFPage()
    }

    func advance(_ delta: CGFloat) { y += delta }

    func checkBreak(height: CGFloat) {
        guard y + height > bottomBoundary else { return }
        endPage()
        beginPage()
    }

    private func drawFooter() {
        let footerFont  = CTFontCreateWithName("HelveticaNeue" as CFString, 8, nil)
        let footerColor = CGColor(gray: 0.6, alpha: 1)
        let ascent      = CTFontGetAscent(footerFont)
        let sepCGY      = margin                    // separator y in CGContext (from bottom)
        let textCGY     = margin - 10 - ascent      // text y in CGContext

        // Separator
        ctx.saveGState()
        ctx.setStrokeColor(CGColor(gray: 0.75, alpha: 0.4))
        ctx.setLineWidth(0.5)
        ctx.move(to: CGPoint(x: margin, y: sepCGY))
        ctx.addLine(to: CGPoint(x: pageSize.width - margin, y: sepCGY))
        ctx.strokePath()
        ctx.restoreGState()

        func footerLine(_ str: String, x: CGFloat) {
            let a = NSAttributedString(string: str, attributes: [
                kCTFontAttributeName as NSAttributedString.Key: footerFont,
                kCTForegroundColorAttributeName as NSAttributedString.Key: footerColor
            ])
            let line = CTLineCreateWithAttributedString(a)
            ctx.textMatrix = .identity
            ctx.textPosition = CGPoint(x: x, y: textCGY)
            CTLineDraw(line, ctx)
        }

        footerLine("HealthProbe — Snapshot Report", x: margin)

        let pageStr   = "Page \(pageNumber)"
        let pageAttr  = NSAttributedString(string: pageStr, attributes: [
            kCTFontAttributeName as NSAttributedString.Key: footerFont
        ])
        let pageWidth = CGFloat(CTLineGetTypographicBounds(CTLineCreateWithAttributedString(pageAttr), nil, nil, nil))
        footerLine(pageStr, x: pageSize.width - margin - pageWidth)
    }
}