krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v4.7.0: DomainDataPortabilityService.swift · raw

   1import Foundation
   2import UniformTypeIdentifiers
   3
   4struct DomainDigBackup: Codable {
   5    static let currentSchemaVersion = 1
   6
   7    let schemaVersion: Int
   8    let exportedAt: Date
   9    let appVersion: String
  10    let trackedDomains: [TrackedDomain]
  11    let historyEntries: [HistoryEntry]
  12    let auditSessions: [AuditSession]?
  13    let workflows: [DomainWorkflow]
  14    let monitoringSettings: MonitoringSettings?
  15    let monitoringLogs: [MonitoringLog]
  16    let appSettings: AppSettingsSnapshot
  17    let featureMetadata: FeatureMetadataSnapshot?
  18}
  19
  20struct AppSettingsSnapshot: Codable {
  21    let recentSearches: [String]
  22    let savedDomains: [String]
  23    let resolverURLString: String
  24    let appDensityRawValue: String
  25}
  26
  27struct FeatureMetadataSnapshot: Codable {
  28    let cachedEntitlement: PurchaseService.CachedEntitlement?
  29    let usageCredits: UsageCreditsSnapshot?
  30}
  31
  32struct UsageCreditsSnapshot: Codable {
  33    let appVersion: String
  34    let remainingByFeature: [UsageCreditFeature: Int]
  35}
  36
  37struct TrackedDomainsExport: Codable {
  38    let schemaVersion: Int
  39    let exportedAt: Date
  40    let appVersion: String
  41    let trackedDomains: [TrackedDomain]
  42}
  43
  44struct WorkflowsExport: Codable {
  45    let schemaVersion: Int
  46    let exportedAt: Date
  47    let appVersion: String
  48    let workflows: [DomainWorkflow]
  49}
  50
  51struct HistoryExport: Codable {
  52    let schemaVersion: Int
  53    let exportedAt: Date
  54    let appVersion: String
  55    let historyEntries: [HistoryEntry]
  56}
  57
  58enum DataPortabilityImportMode: String, CaseIterable, Identifiable {
  59    case merge
  60    case replace
  61
  62    var id: String { rawValue }
  63
  64    var title: String {
  65        switch self {
  66        case .merge:
  67            return "Merge"
  68        case .replace:
  69            return "Replace"
  70        }
  71    }
  72
  73    var explanation: String {
  74        switch self {
  75        case .merge:
  76            return "Keep local data and merge imported items."
  77        case .replace:
  78            return "Replace local data with the imported file."
  79        }
  80    }
  81}
  82
  83enum DataPortabilityImportKind: String {
  84    case backup
  85    case trackedDomains
  86    case workflows
  87}
  88
  89struct DataLifecycleSummary: Equatable {
  90    let trackedDomains: Int
  91    let historySnapshots: Int
  92    let auditSessions: Int
  93    let workflows: Int
  94    let cachedItems: Int
  95    let monitoringLogs: Int
  96}
  97
  98struct DataValidationMessage: Identifiable, Equatable {
  99    let id = UUID()
 100    let text: String
 101    let isError: Bool
 102}
 103
 104struct DataValidationReport {
 105    let messages: [DataValidationMessage]
 106
 107    var warnings: [String] {
 108        messages.filter { !$0.isError }.map(\.text)
 109    }
 110
 111    var errors: [String] {
 112        messages.filter(\.isError).map(\.text)
 113    }
 114}
 115
 116struct DataImportPreview {
 117    let fileName: String
 118    let kind: DataPortabilityImportKind
 119    let mode: DataPortabilityImportMode
 120    let summaryLines: [String]
 121    let warnings: [String]
 122    let currentCounts: DataLifecycleSummary
 123    let projectedCounts: DataLifecycleSummary
 124    fileprivate let payload: ImportPayload
 125}
 126
 127struct DataImportResult {
 128    let kind: DataPortabilityImportKind
 129    let mode: DataPortabilityImportMode
 130    let summary: String
 131    let warnings: [String]
 132}
 133
 134enum DataPortabilityError: LocalizedError {
 135    case unsupportedFormat
 136    case unsupportedSchema(Int)
 137    case invalidCSV(String)
 138    case unreadableFile
 139    case validationFailed([String])
 140
 141    var errorDescription: String? {
 142        switch self {
 143        case .unsupportedFormat:
 144            return "The selected file is not a supported DomainDig import."
 145        case .unsupportedSchema(let version):
 146            return "Backup schema v\(version) is not supported by this version of DomainDig."
 147        case .invalidCSV(let message):
 148            return message
 149        case .unreadableFile:
 150            return "The selected file could not be read."
 151        case .validationFailed(let messages):
 152            return messages.joined(separator: "\n")
 153        }
 154    }
 155}
 156
 157enum DataPortabilityCSV {
 158    static func trackedDomains(_ domains: [TrackedDomain]) -> String {
 159        let header = [
 160            "id",
 161            "domain",
 162            "createdAt",
 163            "updatedAt",
 164            "note",
 165            "isPinned",
 166            "monitoringEnabled",
 167            "lastKnownAvailability",
 168            "certificateWarningLevel",
 169            "certificateDaysRemaining",
 170            "lastMonitoredAt",
 171            "lastAlertAt"
 172        ]
 173
 174        let rows = domains.map { domain in
 175            [
 176                domain.id.uuidString,
 177                domain.domain,
 178                DomainDataPortabilityService.formatISODate(domain.createdAt),
 179                DomainDataPortabilityService.formatISODate(domain.updatedAt),
 180                domain.note ?? "",
 181                String(domain.isPinned),
 182                String(domain.monitoringEnabled),
 183                domain.lastKnownAvailability?.rawValue ?? "",
 184                domain.certificateWarningLevel.rawValue,
 185                domain.certificateDaysRemaining.map(String.init) ?? "",
 186                domain.lastMonitoredAt.map(DomainDataPortabilityService.formatISODate) ?? "",
 187                domain.lastAlertAt.map(DomainDataPortabilityService.formatISODate) ?? ""
 188            ]
 189        }
 190
 191        return ([header] + rows).map(Self.csvLine).joined(separator: "\n")
 192    }
 193
 194    static func workflows(_ workflows: [DomainWorkflow]) -> String {
 195        let header = ["id", "name", "domains", "createdAt", "updatedAt", "notes"]
 196        let rows = workflows.map { workflow in
 197            [
 198                workflow.id.uuidString,
 199                workflow.name,
 200                workflow.domains.joined(separator: "|"),
 201                DomainDataPortabilityService.formatISODate(workflow.createdAt),
 202                DomainDataPortabilityService.formatISODate(workflow.updatedAt),
 203                workflow.notes ?? ""
 204            ]
 205        }
 206
 207        return ([header] + rows).map(Self.csvLine).joined(separator: "\n")
 208    }
 209
 210    static func parseTrackedDomains(from string: String) throws -> [TrackedDomain] {
 211        let rows = try parseRows(from: string)
 212        guard let header = rows.first else { return [] }
 213        let mappedRows = rows.dropFirst().map { dictionary(for: Array($0), header: header) }
 214
 215        return mappedRows.compactMap { row in
 216            let domain = Self.normalizedDomain(row["domain"])
 217            guard !domain.isEmpty else { return nil }
 218
 219            let createdAt = parseDate(row["createdAt"]) ?? Date()
 220            let updatedAt = parseDate(row["updatedAt"]) ?? createdAt
 221            let availability = row["lastKnownAvailability"].flatMap(DomainAvailabilityStatus.init(rawValue:))
 222            let certificateLevel = row["certificateWarningLevel"].flatMap(CertificateWarningLevel.init(rawValue:)) ?? .none
 223
 224            return TrackedDomain(
 225                id: UUID(uuidString: row["id"] ?? "") ?? UUID(),
 226                domain: domain,
 227                createdAt: createdAt,
 228                updatedAt: updatedAt,
 229                note: row["note"]?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
 230                isPinned: Self.parseBool(row["isPinned"]),
 231                monitoringEnabled: row["monitoringEnabled"].map(Self.parseBool) ?? true,
 232                lastKnownAvailability: availability,
 233                certificateWarningLevel: certificateLevel,
 234                certificateDaysRemaining: Int(row["certificateDaysRemaining"] ?? ""),
 235                lastMonitoredAt: parseDate(row["lastMonitoredAt"]),
 236                lastAlertAt: parseDate(row["lastAlertAt"]),
 237                monitoringState: MonitoringState(),
 238                pendingMonitoringAlerts: []
 239            )
 240        }
 241    }
 242
 243    static func parseWorkflows(from string: String) throws -> [DomainWorkflow] {
 244        let rows = try parseRows(from: string)
 245        guard let header = rows.first else { return [] }
 246        let mappedRows = rows.dropFirst().map { dictionary(for: Array($0), header: header) }
 247
 248        return mappedRows.compactMap { row in
 249            let name = row["name"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
 250            let domains = (row["domains"] ?? "")
 251                .split(separator: "|")
 252                .map { Self.normalizedDomain(String($0)) }
 253                .filter { !$0.isEmpty }
 254
 255            guard !name.isEmpty, !domains.isEmpty else { return nil }
 256
 257            let createdAt = parseDate(row["createdAt"]) ?? Date()
 258            let updatedAt = parseDate(row["updatedAt"]) ?? createdAt
 259            return DomainWorkflow(
 260                id: UUID(uuidString: row["id"] ?? "") ?? UUID(),
 261                name: name,
 262                domains: Self.deduplicated(domains),
 263                createdAt: createdAt,
 264                updatedAt: updatedAt,
 265                notes: row["notes"]?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
 266            )
 267        }
 268    }
 269
 270    private nonisolated static func parseRows(from string: String) throws -> [[String]] {
 271        var rows: [[String]] = []
 272        var currentRow: [String] = []
 273        var currentField = ""
 274        var insideQuotes = false
 275
 276        for character in string {
 277            switch character {
 278            case "\"":
 279                insideQuotes.toggle()
 280            case "," where !insideQuotes:
 281                currentRow.append(currentField)
 282                currentField = ""
 283            case "\n" where !insideQuotes:
 284                currentRow.append(currentField)
 285                rows.append(currentRow)
 286                currentRow = []
 287                currentField = ""
 288            case "\r":
 289                continue
 290            default:
 291                currentField.append(character)
 292            }
 293        }
 294
 295        if insideQuotes {
 296            throw DataPortabilityError.invalidCSV("The CSV file is malformed.")
 297        }
 298
 299        if !currentField.isEmpty || !currentRow.isEmpty {
 300            currentRow.append(currentField)
 301            rows.append(currentRow)
 302        }
 303
 304        return rows.filter { !$0.allSatisfy(\.isEmpty) }
 305    }
 306
 307    private nonisolated static func dictionary(for row: [String], header: [String]) -> [String: String] {
 308        Dictionary(uniqueKeysWithValues: zip(header, row).map { ($0.0, $0.1) })
 309    }
 310
 311    private nonisolated static func csvLine(_ values: [String]) -> String {
 312        values.map { value in
 313            let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
 314            return "\"\(escaped)\""
 315        }
 316        .joined(separator: ",")
 317    }
 318
 319    private nonisolated static func parseDate(_ value: String?) -> Date? {
 320        guard let value, !value.isEmpty else { return nil }
 321        return DomainDataPortabilityService.parseISODate(value)
 322    }
 323
 324    private nonisolated static func parseBool(_ value: String?) -> Bool {
 325        guard let value else { return false }
 326        return value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "true"
 327    }
 328
 329    private nonisolated static func normalizedDomain(_ value: String?) -> String {
 330        value?
 331            .trimmingCharacters(in: .whitespacesAndNewlines)
 332            .replacingOccurrences(of: "https://", with: "")
 333            .replacingOccurrences(of: "http://", with: "")
 334            .components(separatedBy: "/")
 335            .first?
 336            .lowercased() ?? ""
 337    }
 338
 339    private nonisolated static func deduplicated(_ values: [String]) -> [String] {
 340        var seen = Set<String>()
 341        return values.filter { seen.insert($0).inserted }
 342    }
 343}
 344
 345enum DataMigrationService {
 346    private static let migrationMarkerKey = "data.migrations.v3_4_0"
 347
 348    static func migrateIfNeeded(defaults: UserDefaults = .standard) {
 349        guard !defaults.bool(forKey: migrationMarkerKey) else { return }
 350
 351        let trackedDomains = DomainDataPortabilityService.loadTrackedDomains(defaults: defaults)
 352        DomainDataPortabilityService.saveTrackedDomains(trackedDomains, defaults: defaults)
 353
 354        let historyEntries = DomainDataPortabilityService.loadHistoryEntries(defaults: defaults)
 355        DomainDataPortabilityService.saveHistoryEntries(historyEntries, defaults: defaults)
 356
 357        let auditSessions = DomainDataPortabilityService.loadAuditSessions(defaults: defaults)
 358        DomainDataPortabilityService.saveAuditSessions(auditSessions, defaults: defaults)
 359
 360        let workflows = DomainDataPortabilityService.loadWorkflows(defaults: defaults)
 361        DomainDataPortabilityService.saveWorkflows(workflows, defaults: defaults)
 362
 363        let monitoringSettings = DomainDataPortabilityService.loadMonitoringSettings(defaults: defaults)
 364        let sanitizedMonitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
 365        DomainDataPortabilityService.saveMonitoringSettings(sanitizedMonitoringSettings, defaults: defaults)
 366
 367        let monitoringLogs = DomainDataPortabilityService.loadMonitoringLogs(defaults: defaults)
 368        DomainDataPortabilityService.saveMonitoringLogs(monitoringLogs, defaults: defaults)
 369
 370        defaults.set(true, forKey: migrationMarkerKey)
 371    }
 372}
 373
 374enum DataValidationService {
 375    static func validate(backup: DomainDigBackup) -> DataValidationReport {
 376        var messages: [DataValidationMessage] = []
 377
 378        if backup.schemaVersion > DomainDigBackup.currentSchemaVersion {
 379            messages.append(.init(text: "This backup was created by a newer DomainDig version.", isError: true))
 380        } else if backup.schemaVersion < DomainDigBackup.currentSchemaVersion {
 381            messages.append(.init(text: "Older backup schema detected. DomainDig will import using compatibility rules.", isError: false))
 382        }
 383
 384        messages.append(contentsOf: validateTrackedDomains(backup.trackedDomains))
 385        messages.append(contentsOf: validateHistoryEntries(backup.historyEntries))
 386        messages.append(contentsOf: validateAuditSessions(backup.auditSessions ?? []))
 387        messages.append(contentsOf: validateWorkflows(backup.workflows))
 388
 389        if backup.appSettings.resolverURLString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
 390            messages.append(.init(text: "Backup is missing a resolver URL. The default resolver will be used.", isError: false))
 391        }
 392
 393        return DataValidationReport(messages: messages)
 394    }
 395
 396    static func validate(trackedDomains: [TrackedDomain]) -> DataValidationReport {
 397        DataValidationReport(messages: validateTrackedDomains(trackedDomains))
 398    }
 399
 400    static func validate(workflows: [DomainWorkflow]) -> DataValidationReport {
 401        DataValidationReport(messages: validateWorkflows(workflows))
 402    }
 403
 404    private static func validateTrackedDomains(_ trackedDomains: [TrackedDomain]) -> [DataValidationMessage] {
 405        var messages: [DataValidationMessage] = []
 406        var seen = Set<String>()
 407
 408        for trackedDomain in trackedDomains {
 409            let normalized = normalizeDomain(trackedDomain.domain)
 410            if normalized.isEmpty {
 411                messages.append(.init(text: "A tracked domain entry is missing its domain name.", isError: true))
 412                continue
 413            }
 414
 415            if !seen.insert(normalized).inserted {
 416                messages.append(.init(text: "Duplicate tracked domain found for \(normalized). Merge rules will consolidate it.", isError: false))
 417            }
 418        }
 419
 420        return messages
 421    }
 422
 423    private static func validateHistoryEntries(_ historyEntries: [HistoryEntry]) -> [DataValidationMessage] {
 424        var messages: [DataValidationMessage] = []
 425        for entry in historyEntries {
 426            if normalizeDomain(entry.domain).isEmpty {
 427                messages.append(.init(text: "A history snapshot is missing its domain name.", isError: true))
 428            }
 429            if entry.timestamp == .distantPast {
 430                messages.append(.init(text: "A history snapshot is missing its timestamp.", isError: false))
 431            }
 432            for issue in entry.validationIssues {
 433                messages.append(.init(text: "History warning for \(entry.domain): \(issue)", isError: false))
 434            }
 435        }
 436        return messages
 437    }
 438
 439    private static func validateAuditSessions(_ auditSessions: [AuditSession]) -> [DataValidationMessage] {
 440        var messages: [DataValidationMessage] = []
 441        var seen = Set<UUID>()
 442
 443        for session in auditSessions {
 444            if normalizeDomain(session.domain).isEmpty {
 445                messages.append(.init(text: "An audit session is missing its domain name.", isError: true))
 446            }
 447
 448            if !seen.insert(session.id).inserted {
 449                messages.append(.init(text: "Duplicate audit session found for \(session.domain). Merge rules will consolidate it.", isError: false))
 450            }
 451        }
 452
 453        return messages
 454    }
 455
 456    private static func validateWorkflows(_ workflows: [DomainWorkflow]) -> [DataValidationMessage] {
 457        var messages: [DataValidationMessage] = []
 458        for workflow in workflows {
 459            if workflow.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
 460                messages.append(.init(text: "A workflow is missing its name.", isError: true))
 461            }
 462            if workflow.domains.isEmpty {
 463                messages.append(.init(text: "Workflow \(workflow.name) has no domains.", isError: false))
 464            }
 465            if Set(workflow.domains.map(Self.normalizeDomain)).count != workflow.domains.count {
 466                messages.append(.init(text: "Workflow \(workflow.name) contains duplicate domains. Import will deduplicate them.", isError: false))
 467            }
 468        }
 469        return messages
 470    }
 471
 472    private nonisolated static func normalizeDomain(_ value: String) -> String {
 473        value
 474            .trimmingCharacters(in: .whitespacesAndNewlines)
 475            .replacingOccurrences(of: "https://", with: "")
 476            .replacingOccurrences(of: "http://", with: "")
 477            .components(separatedBy: "/")
 478            .first?
 479            .lowercased() ?? ""
 480    }
 481}
 482
 483enum DomainDataPortabilityService {
 484    static let backupUTType = UTType.json
 485    static let csvUTType = UTType.commaSeparatedText
 486
 487    fileprivate nonisolated static func formatISODate(_ date: Date) -> String {
 488        let formatter = ISO8601DateFormatter()
 489        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
 490        return formatter.string(from: date)
 491    }
 492
 493    fileprivate nonisolated static func parseISODate(_ value: String) -> Date? {
 494        let formatter = ISO8601DateFormatter()
 495        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
 496        return formatter.date(from: value)
 497    }
 498
 499    private enum StorageKey {
 500        static let recentSearches = "recentSearches"
 501        static let savedDomains = "savedDomains"
 502        static let trackedDomains = "trackedDomains"
 503        static let legacyWatchedDomains = "watchedDomains"
 504        static let history = "lookupHistory"
 505        static let audits = "domainAudits"
 506        static let workflows = "domainWorkflows"
 507        static let monitoringSettings = "monitoring.settings"
 508        static let monitoringLogs = "monitoring.logs"
 509        static let appDensity = AppDensity.userDefaultsKey
 510        static let resolverURL = DNSResolverOption.userDefaultsKey
 511        static let purchaseCache = "purchase.cachedEntitlement"
 512        static let usageCredits = "usageCredits.ledger"
 513    }
 514
 515    static func loadRecentSearches(defaults: UserDefaults = .standard) -> [String] {
 516        defaults.stringArray(forKey: StorageKey.recentSearches) ?? []
 517    }
 518
 519    static func saveRecentSearches(_ values: [String], defaults: UserDefaults = .standard) {
 520        defaults.set(Array(values.prefix(20)), forKey: StorageKey.recentSearches)
 521    }
 522
 523    static func loadSavedDomains(defaults: UserDefaults = .standard) -> [String] {
 524        defaults.stringArray(forKey: StorageKey.savedDomains) ?? []
 525    }
 526
 527    static func saveSavedDomains(_ values: [String], defaults: UserDefaults = .standard) {
 528        defaults.set(values, forKey: StorageKey.savedDomains)
 529    }
 530
 531    static func loadTrackedDomains(defaults: UserDefaults = .standard) -> [TrackedDomain] {
 532        let decoder = JSONDecoder()
 533
 534        if let data = defaults.data(forKey: StorageKey.trackedDomains),
 535           let domains = try? decoder.decode([TrackedDomain].self, from: data) {
 536            return deduplicatedTrackedDomains(domains)
 537        }
 538
 539        if let data = defaults.data(forKey: StorageKey.legacyWatchedDomains),
 540           let legacy = try? decoder.decode([WatchedDomain].self, from: data) {
 541            return deduplicatedTrackedDomains(
 542                legacy.map {
 543                    TrackedDomain(
 544                        id: $0.id,
 545                        domain: normalizeDomain($0.domain),
 546                        createdAt: $0.createdAt,
 547                        updatedAt: $0.createdAt,
 548                        lastKnownAvailability: $0.lastKnownAvailability
 549                    )
 550                }
 551            )
 552        }
 553
 554        return []
 555    }
 556
 557    static func saveTrackedDomains(_ trackedDomains: [TrackedDomain], defaults: UserDefaults = .standard) {
 558        if let data = try? JSONEncoder().encode(deduplicatedTrackedDomains(trackedDomains)) {
 559            defaults.set(data, forKey: StorageKey.trackedDomains)
 560        }
 561        defaults.removeObject(forKey: StorageKey.legacyWatchedDomains)
 562    }
 563
 564    static func loadHistoryEntries(defaults: UserDefaults = .standard) -> [HistoryEntry] {
 565        guard let data = defaults.data(forKey: StorageKey.history) else { return [] }
 566
 567        if let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) {
 568            return deduplicatedHistoryEntries(entries)
 569        }
 570
 571        guard let rawArray = (try? JSONSerialization.jsonObject(with: data)) as? [Any] else {
 572            return []
 573        }
 574
 575        let decoder = JSONDecoder()
 576        let entries = rawArray.compactMap { item -> HistoryEntry? in
 577            guard JSONSerialization.isValidJSONObject(item),
 578                  let entryData = try? JSONSerialization.data(withJSONObject: item) else {
 579                return nil
 580            }
 581            return try? decoder.decode(HistoryEntry.self, from: entryData)
 582        }
 583        return deduplicatedHistoryEntries(entries)
 584    }
 585
 586    static func saveHistoryEntries(_ historyEntries: [HistoryEntry], defaults: UserDefaults = .standard) {
 587        if let data = try? JSONEncoder().encode(deduplicatedHistoryEntries(historyEntries)) {
 588            defaults.set(data, forKey: StorageKey.history)
 589        }
 590    }
 591
 592    static func loadAuditSessions(defaults: UserDefaults = .standard) -> [AuditSession] {
 593        guard let data = defaults.data(forKey: StorageKey.audits),
 594              let sessions = try? JSONDecoder().decode([AuditSession].self, from: data) else {
 595            return []
 596        }
 597        return sessions.sorted { $0.createdAt > $1.createdAt }
 598    }
 599
 600    static func saveAuditSessions(_ auditSessions: [AuditSession], defaults: UserDefaults = .standard) {
 601        let sessions = auditSessions.sorted { $0.createdAt > $1.createdAt }
 602        if let data = try? JSONEncoder().encode(sessions) {
 603            defaults.set(data, forKey: StorageKey.audits)
 604        }
 605    }
 606
 607    static func loadWorkflows(defaults: UserDefaults = .standard) -> [DomainWorkflow] {
 608        guard let data = defaults.data(forKey: StorageKey.workflows),
 609              let workflows = try? JSONDecoder().decode([DomainWorkflow].self, from: data) else {
 610            return []
 611        }
 612
 613        return deduplicatedWorkflows(workflows).sorted(by: workflowSort)
 614    }
 615
 616    static func saveWorkflows(_ workflows: [DomainWorkflow], defaults: UserDefaults = .standard) {
 617        if let data = try? JSONEncoder().encode(deduplicatedWorkflows(workflows)) {
 618            defaults.set(data, forKey: StorageKey.workflows)
 619        }
 620    }
 621
 622    static func loadMonitoringSettings(defaults: UserDefaults = .standard) -> MonitoringSettings {
 623        guard let data = defaults.data(forKey: StorageKey.monitoringSettings),
 624              let settings = try? JSONDecoder().decode(MonitoringSettings.self, from: data) else {
 625            return MonitoringSettings()
 626        }
 627        return settings
 628    }
 629
 630    static func saveMonitoringSettings(_ settings: MonitoringSettings, defaults: UserDefaults = .standard) {
 631        if let data = try? JSONEncoder().encode(settings) {
 632            defaults.set(data, forKey: StorageKey.monitoringSettings)
 633        }
 634    }
 635
 636    static func loadMonitoringLogs(defaults: UserDefaults = .standard) -> [MonitoringLog] {
 637        guard let data = defaults.data(forKey: StorageKey.monitoringLogs),
 638              let logs = try? JSONDecoder().decode([MonitoringLog].self, from: data) else {
 639            return []
 640        }
 641        return Array(logs.prefix(MonitoringStorage.maxLogs))
 642    }
 643
 644    static func saveMonitoringLogs(_ logs: [MonitoringLog], defaults: UserDefaults = .standard) {
 645        if let data = try? JSONEncoder().encode(Array(logs.prefix(MonitoringStorage.maxLogs))) {
 646            defaults.set(data, forKey: StorageKey.monitoringLogs)
 647        }
 648    }
 649
 650    static func loadAppSettings(defaults: UserDefaults = .standard) -> AppSettingsSnapshot {
 651        AppSettingsSnapshot(
 652            recentSearches: loadRecentSearches(defaults: defaults),
 653            savedDomains: loadSavedDomains(defaults: defaults),
 654            resolverURLString: defaults.string(forKey: StorageKey.resolverURL) ?? DNSResolverOption.defaultURLString,
 655            appDensityRawValue: defaults.string(forKey: StorageKey.appDensity) ?? AppDensity.compact.rawValue
 656        )
 657    }
 658
 659    static func saveAppSettings(_ settings: AppSettingsSnapshot, defaults: UserDefaults = .standard) {
 660        saveRecentSearches(settings.recentSearches, defaults: defaults)
 661        saveSavedDomains(settings.savedDomains, defaults: defaults)
 662        defaults.set(settings.resolverURLString, forKey: StorageKey.resolverURL)
 663        defaults.set(settings.appDensityRawValue, forKey: StorageKey.appDensity)
 664    }
 665
 666    static func loadFeatureMetadata(defaults: UserDefaults = .standard) -> FeatureMetadataSnapshot {
 667        let cachedEntitlement: PurchaseService.CachedEntitlement?
 668        if let data = defaults.data(forKey: StorageKey.purchaseCache) {
 669            cachedEntitlement = try? JSONDecoder().decode(PurchaseService.CachedEntitlement.self, from: data)
 670        } else {
 671            cachedEntitlement = nil
 672        }
 673
 674        let usageCredits: UsageCreditsSnapshot?
 675        if let data = defaults.data(forKey: StorageKey.usageCredits) {
 676            usageCredits = try? JSONDecoder().decode(UsageCreditsSnapshot.self, from: data)
 677        } else {
 678            usageCredits = nil
 679        }
 680
 681        return FeatureMetadataSnapshot(cachedEntitlement: cachedEntitlement, usageCredits: usageCredits)
 682    }
 683
 684    static func saveFeatureMetadata(_ featureMetadata: FeatureMetadataSnapshot?, defaults: UserDefaults = .standard) {
 685        if let cachedEntitlement = featureMetadata?.cachedEntitlement,
 686           let data = try? JSONEncoder().encode(cachedEntitlement) {
 687            defaults.set(data, forKey: StorageKey.purchaseCache)
 688        }
 689
 690        if let usageCredits = featureMetadata?.usageCredits,
 691           let data = try? JSONEncoder().encode(usageCredits) {
 692            defaults.set(data, forKey: StorageKey.usageCredits)
 693        }
 694    }
 695
 696    static func currentBackup(defaults: UserDefaults = .standard) -> DomainDigBackup {
 697        DataMigrationService.migrateIfNeeded(defaults: defaults)
 698        return DomainDigBackup(
 699            schemaVersion: DomainDigBackup.currentSchemaVersion,
 700            exportedAt: Date(),
 701            appVersion: AppVersion.current,
 702            trackedDomains: loadTrackedDomains(defaults: defaults),
 703            historyEntries: loadHistoryEntries(defaults: defaults),
 704            auditSessions: loadAuditSessions(defaults: defaults),
 705            workflows: loadWorkflows(defaults: defaults),
 706            monitoringSettings: loadMonitoringSettings(defaults: defaults),
 707            monitoringLogs: loadMonitoringLogs(defaults: defaults),
 708            appSettings: loadAppSettings(defaults: defaults),
 709            featureMetadata: loadFeatureMetadata(defaults: defaults)
 710        )
 711    }
 712
 713    static func backupData(defaults: UserDefaults = .standard) throws -> Data {
 714        try makeEncoder().encode(currentBackup(defaults: defaults))
 715    }
 716
 717    static func trackedDomainsExportData(defaults: UserDefaults = .standard) throws -> Data {
 718        try makeEncoder().encode(
 719            TrackedDomainsExport(
 720                schemaVersion: DomainDigBackup.currentSchemaVersion,
 721                exportedAt: Date(),
 722                appVersion: AppVersion.current,
 723                trackedDomains: loadTrackedDomains(defaults: defaults)
 724            )
 725        )
 726    }
 727
 728    static func workflowsExportData(defaults: UserDefaults = .standard) throws -> Data {
 729        try makeEncoder().encode(
 730            WorkflowsExport(
 731                schemaVersion: DomainDigBackup.currentSchemaVersion,
 732                exportedAt: Date(),
 733                appVersion: AppVersion.current,
 734                workflows: loadWorkflows(defaults: defaults)
 735            )
 736        )
 737    }
 738
 739    static func historyExportData(defaults: UserDefaults = .standard) throws -> Data {
 740        try makeEncoder().encode(
 741            HistoryExport(
 742                schemaVersion: DomainDigBackup.currentSchemaVersion,
 743                exportedAt: Date(),
 744                appVersion: AppVersion.current,
 745                historyEntries: loadHistoryEntries(defaults: defaults)
 746            )
 747        )
 748    }
 749
 750    static func trackedDomainsCSV(defaults: UserDefaults = .standard) -> String {
 751        DataPortabilityCSV.trackedDomains(loadTrackedDomains(defaults: defaults))
 752    }
 753
 754    static func workflowsCSV(defaults: UserDefaults = .standard) -> String {
 755        DataPortabilityCSV.workflows(loadWorkflows(defaults: defaults))
 756    }
 757
 758    static func prepareImport(
 759        data: Data,
 760        fileName: String,
 761        mode: DataPortabilityImportMode,
 762        defaults: UserDefaults = .standard
 763    ) throws -> DataImportPreview {
 764        DataMigrationService.migrateIfNeeded(defaults: defaults)
 765        let currentCounts = lifecycleSummary(defaults: defaults)
 766
 767        let payload = try decodeImportPayload(from: data, fileName: fileName)
 768        let projectedCounts = projectCounts(for: payload, mode: mode, defaults: defaults)
 769
 770        return DataImportPreview(
 771            fileName: fileName,
 772            kind: payload.kind,
 773            mode: mode,
 774            summaryLines: payload.summaryLines(mode: mode),
 775            warnings: payload.validationReport.warnings,
 776            currentCounts: currentCounts,
 777            projectedCounts: projectedCounts,
 778            payload: payload
 779        )
 780    }
 781
 782    static func applyImport(
 783        _ preview: DataImportPreview,
 784        mode: DataPortabilityImportMode,
 785        defaults: UserDefaults = .standard
 786    ) throws -> DataImportResult {
 787        switch preview.payload {
 788        case .backup(let backup, let report):
 789            try applyBackup(backup, mode: mode, defaults: defaults)
 790            return DataImportResult(
 791                kind: .backup,
 792                mode: mode,
 793                summary: "Imported backup with \(backup.trackedDomains.count) tracked domains, \(backup.historyEntries.count) history snapshots, \(backup.auditSessions?.count ?? 0) audit sessions, and \(backup.workflows.count) workflows.",
 794                warnings: report.warnings
 795            )
 796        case .trackedDomains(let trackedDomains, let report):
 797            applyTrackedDomainsImport(trackedDomains, mode: mode, defaults: defaults)
 798            return DataImportResult(
 799                kind: .trackedDomains,
 800                mode: mode,
 801                summary: "Imported \(trackedDomains.count) tracked domains.",
 802                warnings: report.warnings
 803            )
 804        case .workflows(let workflows, let report):
 805            applyWorkflowsImport(workflows, mode: mode, defaults: defaults)
 806            return DataImportResult(
 807                kind: .workflows,
 808                mode: mode,
 809                summary: "Imported \(workflows.count) workflows.",
 810                warnings: report.warnings
 811            )
 812        }
 813    }
 814
 815    static func validateBackup(data: Data, fileName: String = "backup.json") throws -> DataValidationReport {
 816        switch try decodeImportPayload(from: data, fileName: fileName) {
 817        case .backup(_, let report):
 818            return report
 819        case .trackedDomains(_, let report):
 820            return report
 821        case .workflows(_, let report):
 822            return report
 823        }
 824    }
 825
 826    static func lifecycleSummary(defaults: UserDefaults = .standard) -> DataLifecycleSummary {
 827        let appSettings = loadAppSettings(defaults: defaults)
 828        let featureMetadata = loadFeatureMetadata(defaults: defaults)
 829        let cachedItems = appSettings.recentSearches.count
 830            + appSettings.savedDomains.count
 831            + (featureMetadata.cachedEntitlement == nil ? 0 : 1)
 832            + (featureMetadata.usageCredits == nil ? 0 : 1)
 833
 834        return DataLifecycleSummary(
 835            trackedDomains: loadTrackedDomains(defaults: defaults).count,
 836            historySnapshots: loadHistoryEntries(defaults: defaults).count,
 837            auditSessions: loadAuditSessions(defaults: defaults).count,
 838            workflows: loadWorkflows(defaults: defaults).count,
 839            cachedItems: cachedItems,
 840            monitoringLogs: loadMonitoringLogs(defaults: defaults).count
 841        )
 842    }
 843
 844    private static func decodeImportPayload(from data: Data, fileName: String) throws -> ImportPayload {
 845        let decoder = JSONDecoder()
 846
 847        if let backup = try? decoder.decode(DomainDigBackup.self, from: data) {
 848            if backup.schemaVersion > DomainDigBackup.currentSchemaVersion {
 849                throw DataPortabilityError.unsupportedSchema(backup.schemaVersion)
 850            }
 851            let report = DataValidationService.validate(backup: backup)
 852            if !report.errors.isEmpty {
 853                throw DataPortabilityError.validationFailed(report.errors)
 854            }
 855            return .backup(backup, report)
 856        }
 857
 858        if let backup = try? decodeLegacyBackup(from: data) {
 859            let report = DataValidationService.validate(backup: backup)
 860            if !report.errors.isEmpty {
 861                throw DataPortabilityError.validationFailed(report.errors)
 862            }
 863            return .backup(backup, report)
 864        }
 865
 866        if let export = try? decoder.decode(TrackedDomainsExport.self, from: data) {
 867            let report = DataValidationService.validate(trackedDomains: export.trackedDomains)
 868            if !report.errors.isEmpty {
 869                throw DataPortabilityError.validationFailed(report.errors)
 870            }
 871            return .trackedDomains(export.trackedDomains, report)
 872        }
 873
 874        if let export = try? decoder.decode(WorkflowsExport.self, from: data) {
 875            let report = DataValidationService.validate(workflows: export.workflows)
 876            if !report.errors.isEmpty {
 877                throw DataPortabilityError.validationFailed(report.errors)
 878            }
 879            return .workflows(export.workflows, report)
 880        }
 881
 882        if let trackedDomains = try? decoder.decode([TrackedDomain].self, from: data) {
 883            let report = DataValidationService.validate(trackedDomains: trackedDomains)
 884            if !report.errors.isEmpty {
 885                throw DataPortabilityError.validationFailed(report.errors)
 886            }
 887            return .trackedDomains(trackedDomains, report)
 888        }
 889
 890        if let workflows = try? decoder.decode([DomainWorkflow].self, from: data) {
 891            let report = DataValidationService.validate(workflows: workflows)
 892            if !report.errors.isEmpty {
 893                throw DataPortabilityError.validationFailed(report.errors)
 894            }
 895            return .workflows(workflows, report)
 896        }
 897
 898        let lowercasedFileName = fileName.lowercased()
 899        if lowercasedFileName.hasSuffix(".csv") {
 900            guard let string = String(data: data, encoding: .utf8) else {
 901                throw DataPortabilityError.unreadableFile
 902            }
 903            if lowercasedFileName.contains("workflow") {
 904                let workflows = try DataPortabilityCSV.parseWorkflows(from: string)
 905                let report = DataValidationService.validate(workflows: workflows)
 906                if !report.errors.isEmpty {
 907                    throw DataPortabilityError.validationFailed(report.errors)
 908                }
 909                return .workflows(workflows, report)
 910            }
 911
 912            let trackedDomains = try DataPortabilityCSV.parseTrackedDomains(from: string)
 913            let report = DataValidationService.validate(trackedDomains: trackedDomains)
 914            if !report.errors.isEmpty {
 915                throw DataPortabilityError.validationFailed(report.errors)
 916            }
 917            return .trackedDomains(trackedDomains, report)
 918        }
 919
 920        throw DataPortabilityError.unsupportedFormat
 921    }
 922
 923    private static func decodeLegacyBackup(from data: Data) throws -> DomainDigBackup {
 924        struct LegacyBackup: Decodable {
 925            let trackedDomains: [TrackedDomain]?
 926            let historyEntries: [HistoryEntry]?
 927            let workflows: [DomainWorkflow]?
 928            let monitoringSettings: MonitoringSettings?
 929            let monitoringLogs: [MonitoringLog]?
 930            let recentSearches: [String]?
 931            let savedDomains: [String]?
 932        }
 933
 934        let legacy = try JSONDecoder().decode(LegacyBackup.self, from: data)
 935        return DomainDigBackup(
 936            schemaVersion: 0,
 937            exportedAt: Date(),
 938            appVersion: AppVersion.current,
 939            trackedDomains: legacy.trackedDomains ?? [],
 940            historyEntries: legacy.historyEntries ?? [],
 941            auditSessions: nil,
 942            workflows: legacy.workflows ?? [],
 943            monitoringSettings: legacy.monitoringSettings,
 944            monitoringLogs: legacy.monitoringLogs ?? [],
 945            appSettings: AppSettingsSnapshot(
 946                recentSearches: legacy.recentSearches ?? [],
 947                savedDomains: legacy.savedDomains ?? [],
 948                resolverURLString: DNSResolverOption.defaultURLString,
 949                appDensityRawValue: AppDensity.compact.rawValue
 950            ),
 951            featureMetadata: nil
 952        )
 953    }
 954
 955    private static func applyBackup(
 956        _ backup: DomainDigBackup,
 957        mode: DataPortabilityImportMode,
 958        defaults: UserDefaults
 959    ) throws {
 960        if mode == .replace {
 961            saveTrackedDomains(deduplicatedTrackedDomains(backup.trackedDomains), defaults: defaults)
 962            saveHistoryEntries(deduplicatedHistoryEntries(backup.historyEntries), defaults: defaults)
 963            saveAuditSessions(backup.auditSessions ?? [], defaults: defaults)
 964            saveWorkflows(deduplicatedWorkflows(backup.workflows), defaults: defaults)
 965            saveMonitoringSettings(
 966                MonitoringStorage.sanitizeSettings(
 967                    backup.monitoringSettings ?? MonitoringSettings(),
 968                    trackedDomains: deduplicatedTrackedDomains(backup.trackedDomains)
 969                ),
 970                defaults: defaults
 971            )
 972            saveMonitoringLogs(backup.monitoringLogs, defaults: defaults)
 973            saveAppSettings(backup.appSettings, defaults: defaults)
 974            saveFeatureMetadata(backup.featureMetadata, defaults: defaults)
 975            return
 976        }
 977
 978        let existingTrackedDomains = loadTrackedDomains(defaults: defaults)
 979        let mergedTrackedDomains = mergeTrackedDomains(existing: existingTrackedDomains, incoming: backup.trackedDomains)
 980        saveTrackedDomains(mergedTrackedDomains.domains, defaults: defaults)
 981
 982        let existingHistoryEntries = loadHistoryEntries(defaults: defaults)
 983        let remappedIncomingHistory = backup.historyEntries.map { remapHistoryEntry($0, using: mergedTrackedDomains.idMap) }
 984        saveHistoryEntries(
 985            mergeHistoryEntries(existing: existingHistoryEntries, incoming: remappedIncomingHistory),
 986            defaults: defaults
 987        )
 988
 989        let existingAuditSessions = loadAuditSessions(defaults: defaults)
 990        let mergedAuditSessions = mergeAuditSessions(existing: existingAuditSessions, incoming: backup.auditSessions ?? [])
 991        saveAuditSessions(mergedAuditSessions, defaults: defaults)
 992
 993        let existingWorkflows = loadWorkflows(defaults: defaults)
 994        saveWorkflows(mergeWorkflows(existing: existingWorkflows, incoming: backup.workflows), defaults: defaults)
 995
 996        let existingMonitoringLogs = loadMonitoringLogs(defaults: defaults)
 997        saveMonitoringLogs(mergeMonitoringLogs(existing: existingMonitoringLogs, incoming: backup.monitoringLogs), defaults: defaults)
 998
 999        let remappedMonitoringSettings = remapMonitoringSettings(
1000            backup.monitoringSettings ?? MonitoringSettings(),
1001            using: mergedTrackedDomains.idMap
1002        )
1003        saveMonitoringSettings(
1004            MonitoringStorage.sanitizeSettings(remappedMonitoringSettings, trackedDomains: mergedTrackedDomains.domains),
1005            defaults: defaults
1006        )
1007
1008        let mergedSettings = mergeAppSettings(existing: loadAppSettings(defaults: defaults), incoming: backup.appSettings)
1009        saveAppSettings(mergedSettings, defaults: defaults)
1010
1011        let mergedFeatureMetadata = mergeFeatureMetadata(existing: loadFeatureMetadata(defaults: defaults), incoming: backup.featureMetadata)
1012        saveFeatureMetadata(mergedFeatureMetadata, defaults: defaults)
1013    }
1014
1015    private static func applyTrackedDomainsImport(
1016        _ trackedDomains: [TrackedDomain],
1017        mode: DataPortabilityImportMode,
1018        defaults: UserDefaults
1019    ) {
1020        let importedDomains = deduplicatedTrackedDomains(trackedDomains)
1021        let finalDomains: [TrackedDomain]
1022        switch mode {
1023        case .merge:
1024            finalDomains = mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: importedDomains).domains
1025        case .replace:
1026            finalDomains = importedDomains
1027        }
1028
1029        saveTrackedDomains(finalDomains, defaults: defaults)
1030        let sanitizedSettings = MonitoringStorage.sanitizeSettings(loadMonitoringSettings(defaults: defaults), trackedDomains: finalDomains)
1031        saveMonitoringSettings(sanitizedSettings, defaults: defaults)
1032    }
1033
1034    private static func applyWorkflowsImport(
1035        _ workflows: [DomainWorkflow],
1036        mode: DataPortabilityImportMode,
1037        defaults: UserDefaults
1038    ) {
1039        let importedWorkflows = deduplicatedWorkflows(workflows)
1040        let finalWorkflows: [DomainWorkflow]
1041        switch mode {
1042        case .merge:
1043            finalWorkflows = mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: importedWorkflows)
1044        case .replace:
1045            finalWorkflows = importedWorkflows
1046        }
1047        saveWorkflows(finalWorkflows, defaults: defaults)
1048    }
1049
1050    private static func projectCounts(
1051        for payload: ImportPayload,
1052        mode: DataPortabilityImportMode,
1053        defaults: UserDefaults
1054    ) -> DataLifecycleSummary {
1055        let current = lifecycleSummary(defaults: defaults)
1056        switch payload {
1057        case .backup(let backup, _):
1058            if mode == .replace {
1059                return DataLifecycleSummary(
1060                    trackedDomains: backup.trackedDomains.count,
1061                    historySnapshots: backup.historyEntries.count,
1062                    auditSessions: backup.auditSessions?.count ?? 0,
1063                    workflows: backup.workflows.count,
1064                    cachedItems: backup.appSettings.recentSearches.count + backup.appSettings.savedDomains.count + ((backup.featureMetadata?.cachedEntitlement == nil ? 0 : 1) + (backup.featureMetadata?.usageCredits == nil ? 0 : 1)),
1065                    monitoringLogs: backup.monitoringLogs.count
1066                )
1067            }
1068
1069            return DataLifecycleSummary(
1070                trackedDomains: mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: backup.trackedDomains).domains.count,
1071                historySnapshots: mergeHistoryEntries(existing: loadHistoryEntries(defaults: defaults), incoming: backup.historyEntries).count,
1072                auditSessions: mergeAuditSessions(existing: loadAuditSessions(defaults: defaults), incoming: backup.auditSessions ?? []).count,
1073                workflows: mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: backup.workflows).count,
1074                cachedItems: max(current.cachedItems, backup.appSettings.recentSearches.count + backup.appSettings.savedDomains.count),
1075                monitoringLogs: mergeMonitoringLogs(existing: loadMonitoringLogs(defaults: defaults), incoming: backup.monitoringLogs).count
1076            )
1077        case .trackedDomains(let trackedDomains, _):
1078            let total = mode == .replace
1079                ? trackedDomains.count
1080                : mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: trackedDomains).domains.count
1081            return DataLifecycleSummary(
1082                trackedDomains: total,
1083                historySnapshots: current.historySnapshots,
1084                auditSessions: current.auditSessions,
1085                workflows: current.workflows,
1086                cachedItems: current.cachedItems,
1087                monitoringLogs: current.monitoringLogs
1088            )
1089        case .workflows(let workflows, _):
1090            let total = mode == .replace
1091                ? workflows.count
1092                : mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: workflows).count
1093            return DataLifecycleSummary(
1094                trackedDomains: current.trackedDomains,
1095                historySnapshots: current.historySnapshots,
1096                auditSessions: current.auditSessions,
1097                workflows: total,
1098                cachedItems: current.cachedItems,
1099                monitoringLogs: current.monitoringLogs
1100            )
1101        }
1102    }
1103
1104    private static func mergeTrackedDomains(existing: [TrackedDomain], incoming: [TrackedDomain]) -> MergedTrackedDomains {
1105        var mergedByDomain = Dictionary(uniqueKeysWithValues: existing.map { (normalizeDomain($0.domain), $0) })
1106        var idMap: [UUID: UUID] = [:]
1107
1108        for trackedDomain in incoming {
1109            let key = normalizeDomain(trackedDomain.domain)
1110            guard !key.isEmpty else { continue }
1111
1112            if let existingDomain = mergedByDomain[key] {
1113                let chosen = trackedDomain.updatedAt >= existingDomain.updatedAt
1114                    ? mergeTrackedDomain(existing: existingDomain, incoming: trackedDomain)
1115                    : mergeTrackedDomain(existing: trackedDomain, incoming: existingDomain)
1116                mergedByDomain[key] = chosen
1117                idMap[trackedDomain.id] = chosen.id
1118            } else {
1119                let normalized = normalizedTrackedDomain(trackedDomain)
1120                mergedByDomain[key] = normalized
1121                idMap[trackedDomain.id] = normalized.id
1122            }
1123        }
1124
1125        for trackedDomain in existing {
1126            idMap[trackedDomain.id] = mergedByDomain[normalizeDomain(trackedDomain.domain)]?.id ?? trackedDomain.id
1127        }
1128
1129        return MergedTrackedDomains(
1130            domains: mergedByDomain.values.sorted {
1131                if $0.updatedAt != $1.updatedAt {
1132                    return $0.updatedAt > $1.updatedAt
1133                }
1134                return $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending
1135            },
1136            idMap: idMap
1137        )
1138    }
1139
1140    private static func mergeTrackedDomain(existing: TrackedDomain, incoming: TrackedDomain) -> TrackedDomain {
1141        let normalizedDomain = normalizeDomain(existing.domain.isEmpty ? incoming.domain : existing.domain)
1142        let winner = existing.updatedAt >= incoming.updatedAt ? existing : incoming
1143        let note = winner.note ?? existing.note ?? incoming.note
1144
1145        return TrackedDomain(
1146            id: existing.id,
1147            domain: normalizedDomain,
1148            createdAt: min(existing.createdAt, incoming.createdAt),
1149            updatedAt: max(existing.updatedAt, incoming.updatedAt),
1150            note: note,
1151            isPinned: existing.isPinned || incoming.isPinned,
1152            monitoringEnabled: existing.monitoringEnabled || incoming.monitoringEnabled,
1153            lastKnownAvailability: winner.lastKnownAvailability ?? existing.lastKnownAvailability ?? incoming.lastKnownAvailability,
1154            lastSnapshotID: winner.lastSnapshotID ?? existing.lastSnapshotID ?? incoming.lastSnapshotID,
1155            lastChangeSummary: winner.lastChangeSummary ?? existing.lastChangeSummary ?? incoming.lastChangeSummary,
1156            lastChangeSeverity: winner.lastChangeSeverity ?? existing.lastChangeSeverity ?? incoming.lastChangeSeverity,
1157            certificateWarningLevel: higherCertificateWarningLevel(existing.certificateWarningLevel, incoming.certificateWarningLevel),
1158            certificateDaysRemaining: winner.certificateDaysRemaining ?? existing.certificateDaysRemaining ?? incoming.certificateDaysRemaining,
1159            lastMonitoredAt: [existing.lastMonitoredAt, incoming.lastMonitoredAt].compactMap { $0 }.max(),
1160            lastAlertAt: [existing.lastAlertAt, incoming.lastAlertAt].compactMap { $0 }.max(),
1161            monitoringState: preferredMonitoringState(existing.monitoringState, incoming.monitoringState),
1162            pendingMonitoringAlerts: deduplicatedPendingAlerts(existing.pendingMonitoringAlerts + incoming.pendingMonitoringAlerts)
1163        )
1164    }
1165
1166    private static func mergeHistoryEntries(existing: [HistoryEntry], incoming: [HistoryEntry]) -> [HistoryEntry] {
1167        var mergedByKey: [String: HistoryEntry] = [:]
1168
1169        for entry in existing + incoming {
1170            let key = historyKey(for: entry)
1171            if let existingEntry = mergedByKey[key] {
1172                mergedByKey[key] = chooseBetterHistoryEntry(existingEntry, entry)
1173            } else {
1174                mergedByKey[key] = entry
1175            }
1176        }
1177
1178        return mergedByKey.values.sorted { lhs, rhs in
1179            if lhs.timestamp != rhs.timestamp {
1180                return lhs.timestamp > rhs.timestamp
1181            }
1182            return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending
1183        }
1184    }
1185
1186    private static func mergeAuditSessions(existing: [AuditSession], incoming: [AuditSession]) -> [AuditSession] {
1187        var merged = Dictionary(uniqueKeysWithValues: existing.map { ($0.id, $0) })
1188
1189        for session in incoming {
1190            if let existingSession = merged[session.id] {
1191                let winner = existingSession.findings.count >= session.findings.count ? existingSession : session
1192                let candidateNotes = [existingSession.notes, session.notes]
1193                    .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
1194                    .filter { !$0.isEmpty }
1195                let notes = candidateNotes.reduce(into: [String]()) { result, note in
1196                    if !result.contains(note) {
1197                        result.append(note)
1198                    }
1199                }.joined(separator: "\n\n")
1200                merged[session.id] = AuditSession(
1201                    id: existingSession.id,
1202                    domain: existingSession.domain,
1203                    createdAt: min(existingSession.createdAt, session.createdAt),
1204                    reviewer: winner.reviewer,
1205                    status: winner.status,
1206                    evidence: winner.evidence,
1207                    findings: winner.findings,
1208                    notes: notes,
1209                    checklist: winner.checklist
1210                )
1211            } else {
1212                merged[session.id] = session
1213            }
1214        }
1215
1216        return merged.values.sorted { $0.createdAt > $1.createdAt }
1217    }
1218
1219    private static func mergeWorkflows(existing: [DomainWorkflow], incoming: [DomainWorkflow]) -> [DomainWorkflow] {
1220        var merged = Dictionary(uniqueKeysWithValues: existing.map { ($0.id, normalizedWorkflow($0)) })
1221
1222        for workflow in incoming {
1223            let normalized = normalizedWorkflow(workflow)
1224            if let existingWorkflow = merged[workflow.id] {
1225                let winner = existingWorkflow.updatedAt >= normalized.updatedAt ? existingWorkflow : normalized
1226                merged[workflow.id] = DomainWorkflow(
1227                    id: existingWorkflow.id,
1228                    name: winner.name,
1229                    domains: deduplicated(existingWorkflow.domains + normalized.domains),
1230                    createdAt: min(existingWorkflow.createdAt, normalized.createdAt),
1231                    updatedAt: max(existingWorkflow.updatedAt, normalized.updatedAt),
1232                    notes: winner.notes ?? existingWorkflow.notes ?? normalized.notes
1233                )
1234            } else {
1235                merged[workflow.id] = normalized
1236            }
1237        }
1238
1239        return merged.values.sorted(by: workflowSort)
1240    }
1241
1242    private static func mergeMonitoringLogs(existing: [MonitoringLog], incoming: [MonitoringLog]) -> [MonitoringLog] {
1243        var merged: [UUID: MonitoringLog] = [:]
1244        for log in existing + incoming {
1245            if let current = merged[log.id] {
1246                merged[log.id] = log.timestamp >= current.timestamp ? log : current
1247            } else {
1248                merged[log.id] = log
1249            }
1250        }
1251        return merged.values.sorted { $0.timestamp > $1.timestamp }.prefix(MonitoringStorage.maxLogs).map { $0 }
1252    }
1253
1254    private static func mergeAppSettings(existing: AppSettingsSnapshot, incoming: AppSettingsSnapshot) -> AppSettingsSnapshot {
1255        AppSettingsSnapshot(
1256            recentSearches: deduplicated(incoming.recentSearches + existing.recentSearches).prefix(20).map { $0 },
1257            savedDomains: deduplicated(incoming.savedDomains + existing.savedDomains),
1258            resolverURLString: incoming.resolverURLString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? existing.resolverURLString : incoming.resolverURLString,
1259            appDensityRawValue: incoming.appDensityRawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? existing.appDensityRawValue : incoming.appDensityRawValue
1260        )
1261    }
1262
1263    private static func mergeFeatureMetadata(existing: FeatureMetadataSnapshot, incoming: FeatureMetadataSnapshot?) -> FeatureMetadataSnapshot {
1264        guard let incoming else { return existing }
1265
1266        let mergedUsageCredits: UsageCreditsSnapshot?
1267        switch (existing.usageCredits, incoming.usageCredits) {
1268        case let (.some(lhs), .some(rhs)):
1269            let mergedRemaining = Dictionary(uniqueKeysWithValues: UsageCreditFeature.allCases.map { feature in
1270                (feature, max(lhs.remainingByFeature[feature] ?? 0, rhs.remainingByFeature[feature] ?? 0))
1271            })
1272            mergedUsageCredits = UsageCreditsSnapshot(
1273                appVersion: rhs.appVersion,
1274                remainingByFeature: mergedRemaining
1275            )
1276        case (.some(let lhs), nil):
1277            mergedUsageCredits = lhs
1278        case (nil, .some(let rhs)):
1279            mergedUsageCredits = rhs
1280        case (nil, nil):
1281            mergedUsageCredits = nil
1282        }
1283
1284        return FeatureMetadataSnapshot(
1285            cachedEntitlement: incoming.cachedEntitlement ?? existing.cachedEntitlement,
1286            usageCredits: mergedUsageCredits
1287        )
1288    }
1289
1290    private static func remapMonitoringSettings(
1291        _ settings: MonitoringSettings,
1292        using idMap: [UUID: UUID]
1293    ) -> MonitoringSettings {
1294        var remapped = settings
1295        remapped.selectedDomainIDs = deduplicatedUUIDs(settings.selectedDomainIDs.compactMap { idMap[$0] ?? $0 })
1296        return remapped
1297    }
1298
1299    private static func remapHistoryEntry(_ entry: HistoryEntry, using idMap: [UUID: UUID]) -> HistoryEntry {
1300        var remapped = entry
1301        if let trackedDomainID = entry.trackedDomainID {
1302            remapped.trackedDomainID = idMap[trackedDomainID] ?? trackedDomainID
1303        }
1304        return remapped
1305    }
1306
1307    private static func chooseBetterHistoryEntry(_ lhs: HistoryEntry, _ rhs: HistoryEntry) -> HistoryEntry {
1308        if historyQualityScore(lhs) == historyQualityScore(rhs) {
1309            return lhs.timestamp >= rhs.timestamp ? lhs : rhs
1310        }
1311        return historyQualityScore(lhs) >= historyQualityScore(rhs) ? lhs : rhs
1312    }
1313
1314    private static func historyQualityScore(_ entry: HistoryEntry) -> Int {
1315        var score = 0
1316        score += entry.dnsSections.count
1317        score += entry.httpHeaders.count
1318        score += entry.reachabilityResults.count
1319        score += entry.redirectChain.count
1320        score += entry.subdomains.count
1321        score += entry.portScanResults.count
1322        score += entry.validationIssues.isEmpty ? 2 : 0
1323        score += entry.sslInfo == nil ? 0 : 4
1324        score += entry.ownership == nil ? 0 : 4
1325        score += entry.emailSecurity == nil ? 0 : 3
1326        score += entry.ipGeolocation == nil ? 0 : 2
1327        return score
1328    }
1329
1330    private static func historyKey(for entry: HistoryEntry) -> String {
1331        if entry.id != UUID() {
1332            return "id:\(entry.id.uuidString)"
1333        }
1334        return "domain:\(normalizeDomain(entry.domain))|timestamp:\(entry.timestamp.timeIntervalSince1970)"
1335    }
1336
1337    private nonisolated static func normalizeDomain(_ value: String) -> String {
1338        value
1339            .trimmingCharacters(in: .whitespacesAndNewlines)
1340            .replacingOccurrences(of: "https://", with: "")
1341            .replacingOccurrences(of: "http://", with: "")
1342            .components(separatedBy: "/")
1343            .first?
1344            .lowercased() ?? ""
1345    }
1346
1347    private static func normalizedTrackedDomain(_ trackedDomain: TrackedDomain) -> TrackedDomain {
1348        TrackedDomain(
1349            id: trackedDomain.id,
1350            domain: normalizeDomain(trackedDomain.domain),
1351            createdAt: trackedDomain.createdAt,
1352            updatedAt: trackedDomain.updatedAt,
1353            note: trackedDomain.note,
1354            isPinned: trackedDomain.isPinned,
1355            monitoringEnabled: trackedDomain.monitoringEnabled,
1356            lastKnownAvailability: trackedDomain.lastKnownAvailability,
1357            lastSnapshotID: trackedDomain.lastSnapshotID,
1358            lastChangeSummary: trackedDomain.lastChangeSummary,
1359            lastChangeSeverity: trackedDomain.lastChangeSeverity,
1360            certificateWarningLevel: trackedDomain.certificateWarningLevel,
1361            certificateDaysRemaining: trackedDomain.certificateDaysRemaining,
1362            lastMonitoredAt: trackedDomain.lastMonitoredAt,
1363            lastAlertAt: trackedDomain.lastAlertAt,
1364            monitoringState: trackedDomain.monitoringState,
1365            pendingMonitoringAlerts: trackedDomain.pendingMonitoringAlerts
1366        )
1367    }
1368
1369    private static func preferredMonitoringState(_ lhs: MonitoringState, _ rhs: MonitoringState) -> MonitoringState {
1370        let lhsDate = [lhs.lastCheck, lhs.lastAlertDate, lhs.lastChangeDate].compactMap { $0 }.max() ?? .distantPast
1371        let rhsDate = [rhs.lastCheck, rhs.lastAlertDate, rhs.lastChangeDate].compactMap { $0 }.max() ?? .distantPast
1372        return lhsDate >= rhsDate ? lhs : rhs
1373    }
1374
1375    private static func deduplicatedPendingAlerts(_ alerts: [MonitoringPendingAlert]) -> [MonitoringPendingAlert] {
1376        var uniqueByHash: [String: MonitoringPendingAlert] = [:]
1377        for alert in alerts {
1378            if let existing = uniqueByHash[alert.changeHash] {
1379                uniqueByHash[alert.changeHash] = existing.detectedAt >= alert.detectedAt ? existing : alert
1380            } else {
1381                uniqueByHash[alert.changeHash] = alert
1382            }
1383        }
1384        return uniqueByHash.values.sorted { $0.detectedAt < $1.detectedAt }
1385    }
1386
1387    private static func normalizedWorkflow(_ workflow: DomainWorkflow) -> DomainWorkflow {
1388        DomainWorkflow(
1389            id: workflow.id,
1390            name: workflow.name.trimmingCharacters(in: .whitespacesAndNewlines),
1391            domains: deduplicated(workflow.domains.map(Self.normalizeDomain).filter { !$0.isEmpty }),
1392            createdAt: workflow.createdAt,
1393            updatedAt: workflow.updatedAt,
1394            notes: workflow.notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
1395        )
1396    }
1397
1398    private static func deduplicatedTrackedDomains(_ trackedDomains: [TrackedDomain]) -> [TrackedDomain] {
1399        mergeTrackedDomains(existing: [], incoming: trackedDomains).domains
1400    }
1401
1402    private static func deduplicatedHistoryEntries(_ historyEntries: [HistoryEntry]) -> [HistoryEntry] {
1403        mergeHistoryEntries(existing: [], incoming: historyEntries)
1404    }
1405
1406    private static func deduplicatedWorkflows(_ workflows: [DomainWorkflow]) -> [DomainWorkflow] {
1407        mergeWorkflows(existing: [], incoming: workflows)
1408    }
1409
1410    private nonisolated static func deduplicated(_ values: [String]) -> [String] {
1411        var seen = Set<String>()
1412        return values.filter { seen.insert($0).inserted }
1413    }
1414
1415    private nonisolated static func deduplicatedUUIDs(_ values: [UUID]) -> [UUID] {
1416        var seen = Set<UUID>()
1417        return values.filter { seen.insert($0).inserted }
1418    }
1419
1420    private nonisolated static func workflowSort(lhs: DomainWorkflow, rhs: DomainWorkflow) -> Bool {
1421        if lhs.updatedAt != rhs.updatedAt {
1422            return lhs.updatedAt > rhs.updatedAt
1423        }
1424        return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
1425    }
1426
1427    private nonisolated static func higherCertificateWarningLevel(
1428        _ lhs: CertificateWarningLevel,
1429        _ rhs: CertificateWarningLevel
1430    ) -> CertificateWarningLevel {
1431        let rank: [CertificateWarningLevel: Int] = [
1432            .none: 0,
1433            .warning: 1,
1434            .critical: 2
1435        ]
1436        return (rank[lhs] ?? 0) >= (rank[rhs] ?? 0) ? lhs : rhs
1437    }
1438
1439    private static func makeEncoder() -> JSONEncoder {
1440        let encoder = JSONEncoder()
1441        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
1442        return encoder
1443    }
1444}
1445
1446private struct MergedTrackedDomains {
1447    let domains: [TrackedDomain]
1448    let idMap: [UUID: UUID]
1449}
1450
1451private enum ImportPayload {
1452    case backup(DomainDigBackup, DataValidationReport)
1453    case trackedDomains([TrackedDomain], DataValidationReport)
1454    case workflows([DomainWorkflow], DataValidationReport)
1455
1456    var kind: DataPortabilityImportKind {
1457        switch self {
1458        case .backup:
1459            return .backup
1460        case .trackedDomains:
1461            return .trackedDomains
1462        case .workflows:
1463            return .workflows
1464        }
1465    }
1466
1467    var validationReport: DataValidationReport {
1468        switch self {
1469        case .backup(_, let report), .trackedDomains(_, let report), .workflows(_, let report):
1470            return report
1471        }
1472    }
1473
1474    func summaryLines(mode: DataPortabilityImportMode) -> [String] {
1475        switch self {
1476        case .backup(let backup, _):
1477            return [
1478                "Full backup import",
1479                "\(backup.trackedDomains.count) tracked domains",
1480                "\(backup.historyEntries.count) history snapshots",
1481                "\(backup.auditSessions?.count ?? 0) audit sessions",
1482                "\(backup.workflows.count) workflows",
1483                "\(backup.monitoringLogs.count) monitoring logs",
1484                mode == .replace ? "Replace mode will overwrite local backupable data." : "Merge mode will keep local data and consolidate duplicates."
1485            ]
1486        case .trackedDomains(let trackedDomains, _):
1487            return [
1488                "Tracked domains import",
1489                "\(trackedDomains.count) tracked domains",
1490                mode == .replace ? "Replace mode will overwrite the watchlist." : "Merge mode will consolidate duplicates by normalized domain."
1491            ]
1492        case .workflows(let workflows, _):
1493            return [
1494                "Workflow import",
1495                "\(workflows.count) workflows",
1496                mode == .replace ? "Replace mode will overwrite saved workflows." : "Merge mode will merge matching workflow IDs and deduplicate domains."
1497            ]
1498        }
1499    }
1500}
1501
1502private extension String {
1503    var nilIfEmpty: String? {
1504        isEmpty ? nil : self
1505    }
1506}