krz/domain-dig

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

main: 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
 345/// Versioned migration runner for the on-device persisted store.
 346///
 347/// The store is a set of independent JSON blobs in `UserDefaults` (tracked
 348/// domains, history, audits, workflows, monitoring settings/logs, app settings).
 349/// Most model evolution is handled additively by the models' own lenient
 350/// decoders (`decodeIfPresent` with defaults), which need no migration at all.
 351/// This runner exists only for changes lenient decoding can't express: dropping
 352/// a renamed storage key, re-normalizing existing rows, or reshaping a blob.
 353///
 354/// `currentStoreSchemaVersion` is bumped whenever such a step is added. Each step
 355/// runs exactly once, in ascending order, and must be safe to run on any prior
 356/// state  including an empty store. A store written by a newer build (a higher
 357/// version than this build knows) is left untouched; migrations never downgrade.
 358/// The policy is documented in `Docs/data-migration.md`.
 359enum DataMigrationService {
 360    /// The schema version this build expects the on-device store to be at.
 361    static let currentStoreSchemaVersion = 1
 362
 363    /// UserDefaults key holding the store's current schema version.
 364    static let storeSchemaVersionKey = "data.storeSchemaVersion"
 365
 366    /// Pre-versioning installs recorded that the one-shot v1 normalization had
 367    /// run using this boolean marker; `true` means the store is already at v1.
 368    private static let legacyNormalizationMarkerKey = "data.migrations.v3_4_0"
 369
 370    /// The store's current schema version. Absent on pre-versioning installs: a
 371    /// set legacy marker means v1 already ran, otherwise the store is fresh or
 372    /// never-migrated at v0.
 373    static func storeSchemaVersion(defaults: UserDefaults = .standard) -> Int {
 374        if let version = defaults.object(forKey: storeSchemaVersionKey) as? Int {
 375            return version
 376        }
 377        return defaults.bool(forKey: legacyNormalizationMarkerKey) ? 1 : 0
 378    }
 379
 380    static func migrateIfNeeded(defaults: UserDefaults = .standard) {
 381        // At or ahead of this build's version: nothing to do, and never rewrite
 382        // a store a newer build may have reshaped (forward compatibility).
 383        guard storeSchemaVersion(defaults: defaults) < currentStoreSchemaVersion else { return }
 384
 385        var version = storeSchemaVersion(defaults: defaults)
 386        while version < currentStoreSchemaVersion {
 387            let target = version + 1
 388            runMigration(to: target, defaults: defaults)
 389            defaults.set(target, forKey: storeSchemaVersionKey)
 390            version = target
 391        }
 392    }
 393
 394    private static func runMigration(to version: Int, defaults: UserDefaults) {
 395        switch version {
 396        case 1:
 397            normalizeAllStores(defaults: defaults)
 398        default:
 399            break
 400        }
 401    }
 402
 403    /// v1: load every store through its deduplicating loader and write it back.
 404    /// This consolidates duplicate rows, drops the legacy `watchedDomains` key
 405    /// (via `saveTrackedDomains`), and sanitizes monitoring settings against the
 406    /// surviving tracked domains. Safe on an empty store (every step is a no-op).
 407    private static func normalizeAllStores(defaults: UserDefaults) {
 408        let trackedDomains = DomainDataPortabilityService.loadTrackedDomains(defaults: defaults)
 409        DomainDataPortabilityService.saveTrackedDomains(trackedDomains, defaults: defaults)
 410
 411        let historyEntries = DomainDataPortabilityService.loadHistoryEntries(defaults: defaults)
 412        DomainDataPortabilityService.saveHistoryEntries(historyEntries, defaults: defaults)
 413
 414        let auditSessions = DomainDataPortabilityService.loadAuditSessions(defaults: defaults)
 415        DomainDataPortabilityService.saveAuditSessions(auditSessions, defaults: defaults)
 416
 417        let workflows = DomainDataPortabilityService.loadWorkflows(defaults: defaults)
 418        DomainDataPortabilityService.saveWorkflows(workflows, defaults: defaults)
 419
 420        let monitoringSettings = DomainDataPortabilityService.loadMonitoringSettings(defaults: defaults)
 421        let sanitizedMonitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
 422        DomainDataPortabilityService.saveMonitoringSettings(sanitizedMonitoringSettings, defaults: defaults)
 423
 424        let monitoringLogs = DomainDataPortabilityService.loadMonitoringLogs(defaults: defaults)
 425        DomainDataPortabilityService.saveMonitoringLogs(monitoringLogs, defaults: defaults)
 426    }
 427}
 428
 429enum DataValidationService {
 430    static func validate(backup: DomainDigBackup) -> DataValidationReport {
 431        var messages: [DataValidationMessage] = []
 432
 433        if backup.schemaVersion > DomainDigBackup.currentSchemaVersion {
 434            messages.append(.init(text: "This backup was created by a newer DomainDig version.", isError: true))
 435        } else if backup.schemaVersion < DomainDigBackup.currentSchemaVersion {
 436            messages.append(.init(text: "Older backup schema detected. DomainDig will import using compatibility rules.", isError: false))
 437        }
 438
 439        messages.append(contentsOf: validateTrackedDomains(backup.trackedDomains))
 440        messages.append(contentsOf: validateHistoryEntries(backup.historyEntries))
 441        messages.append(contentsOf: validateAuditSessions(backup.auditSessions ?? []))
 442        messages.append(contentsOf: validateWorkflows(backup.workflows))
 443
 444        if backup.appSettings.resolverURLString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
 445            messages.append(.init(text: "Backup is missing a resolver URL. The default resolver will be used.", isError: false))
 446        }
 447
 448        return DataValidationReport(messages: messages)
 449    }
 450
 451    static func validate(trackedDomains: [TrackedDomain]) -> DataValidationReport {
 452        DataValidationReport(messages: validateTrackedDomains(trackedDomains))
 453    }
 454
 455    static func validate(workflows: [DomainWorkflow]) -> DataValidationReport {
 456        DataValidationReport(messages: validateWorkflows(workflows))
 457    }
 458
 459    private static func validateTrackedDomains(_ trackedDomains: [TrackedDomain]) -> [DataValidationMessage] {
 460        var messages: [DataValidationMessage] = []
 461        var seen = Set<String>()
 462
 463        for trackedDomain in trackedDomains {
 464            let normalized = normalizeDomain(trackedDomain.domain)
 465            if normalized.isEmpty {
 466                messages.append(.init(text: "A tracked domain entry is missing its domain name.", isError: true))
 467                continue
 468            }
 469
 470            if !seen.insert(normalized).inserted {
 471                messages.append(.init(text: "Duplicate tracked domain found for \(normalized). Merge rules will consolidate it.", isError: false))
 472            }
 473        }
 474
 475        return messages
 476    }
 477
 478    private static func validateHistoryEntries(_ historyEntries: [HistoryEntry]) -> [DataValidationMessage] {
 479        var messages: [DataValidationMessage] = []
 480        for entry in historyEntries {
 481            if normalizeDomain(entry.domain).isEmpty {
 482                messages.append(.init(text: "A history snapshot is missing its domain name.", isError: true))
 483            }
 484            if entry.timestamp == .distantPast {
 485                messages.append(.init(text: "A history snapshot is missing its timestamp.", isError: false))
 486            }
 487            for issue in entry.validationIssues {
 488                messages.append(.init(text: "History warning for \(entry.domain): \(issue)", isError: false))
 489            }
 490        }
 491        return messages
 492    }
 493
 494    private static func validateAuditSessions(_ auditSessions: [AuditSession]) -> [DataValidationMessage] {
 495        var messages: [DataValidationMessage] = []
 496        var seen = Set<UUID>()
 497
 498        for session in auditSessions {
 499            if normalizeDomain(session.domain).isEmpty {
 500                messages.append(.init(text: "An audit session is missing its domain name.", isError: true))
 501            }
 502
 503            if !seen.insert(session.id).inserted {
 504                messages.append(.init(text: "Duplicate audit session found for \(session.domain). Merge rules will consolidate it.", isError: false))
 505            }
 506        }
 507
 508        return messages
 509    }
 510
 511    private static func validateWorkflows(_ workflows: [DomainWorkflow]) -> [DataValidationMessage] {
 512        var messages: [DataValidationMessage] = []
 513        for workflow in workflows {
 514            if workflow.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
 515                messages.append(.init(text: "A workflow is missing its name.", isError: true))
 516            }
 517            if workflow.domains.isEmpty {
 518                messages.append(.init(text: "Workflow \(workflow.name) has no domains.", isError: false))
 519            }
 520            if Set(workflow.domains.map(Self.normalizeDomain)).count != workflow.domains.count {
 521                messages.append(.init(text: "Workflow \(workflow.name) contains duplicate domains. Import will deduplicate them.", isError: false))
 522            }
 523        }
 524        return messages
 525    }
 526
 527    private nonisolated static func normalizeDomain(_ value: String) -> String {
 528        value
 529            .trimmingCharacters(in: .whitespacesAndNewlines)
 530            .replacingOccurrences(of: "https://", with: "")
 531            .replacingOccurrences(of: "http://", with: "")
 532            .components(separatedBy: "/")
 533            .first?
 534            .lowercased() ?? ""
 535    }
 536}
 537
 538enum DomainDataPortabilityService {
 539    static let backupUTType = UTType.json
 540    static let csvUTType = UTType.commaSeparatedText
 541
 542    fileprivate nonisolated static func formatISODate(_ date: Date) -> String {
 543        let formatter = ISO8601DateFormatter()
 544        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
 545        return formatter.string(from: date)
 546    }
 547
 548    fileprivate nonisolated static func parseISODate(_ value: String) -> Date? {
 549        let formatter = ISO8601DateFormatter()
 550        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
 551        return formatter.date(from: value)
 552    }
 553
 554    private enum StorageKey {
 555        static let recentSearches = "recentSearches"
 556        static let savedDomains = "savedDomains"
 557        static let trackedDomains = "trackedDomains"
 558        static let legacyWatchedDomains = "watchedDomains"
 559        static let history = "lookupHistory"
 560        static let audits = "domainAudits"
 561        static let workflows = "domainWorkflows"
 562        static let monitoringSettings = "monitoring.settings"
 563        static let monitoringLogs = "monitoring.logs"
 564        static let appDensity = AppDensity.userDefaultsKey
 565        static let resolverURL = DNSResolverOption.userDefaultsKey
 566        static let purchaseCache = "purchase.cachedEntitlement"
 567        static let usageCredits = "usageCredits.ledger"
 568    }
 569
 570    static func loadRecentSearches(defaults: UserDefaults = .standard) -> [String] {
 571        defaults.stringArray(forKey: StorageKey.recentSearches) ?? []
 572    }
 573
 574    static func saveRecentSearches(_ values: [String], defaults: UserDefaults = .standard) {
 575        defaults.set(Array(values.prefix(20)), forKey: StorageKey.recentSearches)
 576    }
 577
 578    static func loadSavedDomains(defaults: UserDefaults = .standard) -> [String] {
 579        defaults.stringArray(forKey: StorageKey.savedDomains) ?? []
 580    }
 581
 582    static func saveSavedDomains(_ values: [String], defaults: UserDefaults = .standard) {
 583        defaults.set(values, forKey: StorageKey.savedDomains)
 584    }
 585
 586    static func loadTrackedDomains(defaults: UserDefaults = .standard) -> [TrackedDomain] {
 587        let decoder = JSONDecoder()
 588
 589        if let data = defaults.data(forKey: StorageKey.trackedDomains),
 590           let domains = try? decoder.decode([TrackedDomain].self, from: data) {
 591            return deduplicatedTrackedDomains(domains)
 592        }
 593
 594        if let data = defaults.data(forKey: StorageKey.legacyWatchedDomains),
 595           let legacy = try? decoder.decode([WatchedDomain].self, from: data) {
 596            return deduplicatedTrackedDomains(
 597                legacy.map {
 598                    TrackedDomain(
 599                        id: $0.id,
 600                        domain: normalizeDomain($0.domain),
 601                        createdAt: $0.createdAt,
 602                        updatedAt: $0.createdAt,
 603                        lastKnownAvailability: $0.lastKnownAvailability
 604                    )
 605                }
 606            )
 607        }
 608
 609        return []
 610    }
 611
 612    static func saveTrackedDomains(_ trackedDomains: [TrackedDomain], defaults: UserDefaults = .standard) {
 613        if let data = try? JSONEncoder().encode(deduplicatedTrackedDomains(trackedDomains)) {
 614            defaults.set(data, forKey: StorageKey.trackedDomains)
 615        }
 616        defaults.removeObject(forKey: StorageKey.legacyWatchedDomains)
 617    }
 618
 619    static func loadHistoryEntries(defaults: UserDefaults = .standard) -> [HistoryEntry] {
 620        guard let data = defaults.data(forKey: StorageKey.history) else { return [] }
 621
 622        if let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) {
 623            return deduplicatedHistoryEntries(entries)
 624        }
 625
 626        guard let rawArray = (try? JSONSerialization.jsonObject(with: data)) as? [Any] else {
 627            return []
 628        }
 629
 630        let decoder = JSONDecoder()
 631        let entries = rawArray.compactMap { item -> HistoryEntry? in
 632            guard JSONSerialization.isValidJSONObject(item),
 633                  let entryData = try? JSONSerialization.data(withJSONObject: item) else {
 634                return nil
 635            }
 636            return try? decoder.decode(HistoryEntry.self, from: entryData)
 637        }
 638        return deduplicatedHistoryEntries(entries)
 639    }
 640
 641    static func saveHistoryEntries(_ historyEntries: [HistoryEntry], defaults: UserDefaults = .standard) {
 642        if let data = try? JSONEncoder().encode(deduplicatedHistoryEntries(historyEntries)) {
 643            defaults.set(data, forKey: StorageKey.history)
 644        }
 645    }
 646
 647    static func loadAuditSessions(defaults: UserDefaults = .standard) -> [AuditSession] {
 648        guard let data = defaults.data(forKey: StorageKey.audits),
 649              let sessions = try? JSONDecoder().decode([AuditSession].self, from: data) else {
 650            return []
 651        }
 652        return sessions.sorted { $0.createdAt > $1.createdAt }
 653    }
 654
 655    static func saveAuditSessions(_ auditSessions: [AuditSession], defaults: UserDefaults = .standard) {
 656        let sessions = auditSessions.sorted { $0.createdAt > $1.createdAt }
 657        if let data = try? JSONEncoder().encode(sessions) {
 658            defaults.set(data, forKey: StorageKey.audits)
 659        }
 660    }
 661
 662    static func loadWorkflows(defaults: UserDefaults = .standard) -> [DomainWorkflow] {
 663        guard let data = defaults.data(forKey: StorageKey.workflows),
 664              let workflows = try? JSONDecoder().decode([DomainWorkflow].self, from: data) else {
 665            return []
 666        }
 667
 668        return deduplicatedWorkflows(workflows).sorted(by: workflowSort)
 669    }
 670
 671    static func saveWorkflows(_ workflows: [DomainWorkflow], defaults: UserDefaults = .standard) {
 672        if let data = try? JSONEncoder().encode(deduplicatedWorkflows(workflows)) {
 673            defaults.set(data, forKey: StorageKey.workflows)
 674        }
 675    }
 676
 677    static func loadMonitoringSettings(defaults: UserDefaults = .standard) -> MonitoringSettings {
 678        guard let data = defaults.data(forKey: StorageKey.monitoringSettings),
 679              let settings = try? JSONDecoder().decode(MonitoringSettings.self, from: data) else {
 680            return MonitoringSettings()
 681        }
 682        return settings
 683    }
 684
 685    static func saveMonitoringSettings(_ settings: MonitoringSettings, defaults: UserDefaults = .standard) {
 686        if let data = try? JSONEncoder().encode(settings) {
 687            defaults.set(data, forKey: StorageKey.monitoringSettings)
 688        }
 689    }
 690
 691    static func loadMonitoringLogs(defaults: UserDefaults = .standard) -> [MonitoringLog] {
 692        guard let data = defaults.data(forKey: StorageKey.monitoringLogs),
 693              let logs = try? JSONDecoder().decode([MonitoringLog].self, from: data) else {
 694            return []
 695        }
 696        return Array(logs.prefix(MonitoringStorage.maxLogs))
 697    }
 698
 699    static func saveMonitoringLogs(_ logs: [MonitoringLog], defaults: UserDefaults = .standard) {
 700        if let data = try? JSONEncoder().encode(Array(logs.prefix(MonitoringStorage.maxLogs))) {
 701            defaults.set(data, forKey: StorageKey.monitoringLogs)
 702        }
 703    }
 704
 705    static func loadAppSettings(defaults: UserDefaults = .standard) -> AppSettingsSnapshot {
 706        AppSettingsSnapshot(
 707            recentSearches: loadRecentSearches(defaults: defaults),
 708            savedDomains: loadSavedDomains(defaults: defaults),
 709            resolverURLString: defaults.string(forKey: StorageKey.resolverURL) ?? DNSResolverOption.defaultURLString,
 710            appDensityRawValue: defaults.string(forKey: StorageKey.appDensity) ?? AppDensity.compact.rawValue
 711        )
 712    }
 713
 714    static func saveAppSettings(_ settings: AppSettingsSnapshot, defaults: UserDefaults = .standard) {
 715        saveRecentSearches(settings.recentSearches, defaults: defaults)
 716        saveSavedDomains(settings.savedDomains, defaults: defaults)
 717        defaults.set(settings.resolverURLString, forKey: StorageKey.resolverURL)
 718        defaults.set(settings.appDensityRawValue, forKey: StorageKey.appDensity)
 719    }
 720
 721    static func loadFeatureMetadata(defaults: UserDefaults = .standard) -> FeatureMetadataSnapshot {
 722        let cachedEntitlement: PurchaseService.CachedEntitlement?
 723        if let data = defaults.data(forKey: StorageKey.purchaseCache) {
 724            cachedEntitlement = try? JSONDecoder().decode(PurchaseService.CachedEntitlement.self, from: data)
 725        } else {
 726            cachedEntitlement = nil
 727        }
 728
 729        let usageCredits: UsageCreditsSnapshot?
 730        if let data = defaults.data(forKey: StorageKey.usageCredits) {
 731            usageCredits = try? JSONDecoder().decode(UsageCreditsSnapshot.self, from: data)
 732        } else {
 733            usageCredits = nil
 734        }
 735
 736        return FeatureMetadataSnapshot(cachedEntitlement: cachedEntitlement, usageCredits: usageCredits)
 737    }
 738
 739    static func saveFeatureMetadata(_ featureMetadata: FeatureMetadataSnapshot?, defaults: UserDefaults = .standard) {
 740        if let cachedEntitlement = featureMetadata?.cachedEntitlement,
 741           let data = try? JSONEncoder().encode(cachedEntitlement) {
 742            defaults.set(data, forKey: StorageKey.purchaseCache)
 743        }
 744
 745        if let usageCredits = featureMetadata?.usageCredits,
 746           let data = try? JSONEncoder().encode(usageCredits) {
 747            defaults.set(data, forKey: StorageKey.usageCredits)
 748        }
 749    }
 750
 751    static func currentBackup(defaults: UserDefaults = .standard) -> DomainDigBackup {
 752        DataMigrationService.migrateIfNeeded(defaults: defaults)
 753        return DomainDigBackup(
 754            schemaVersion: DomainDigBackup.currentSchemaVersion,
 755            exportedAt: Date(),
 756            appVersion: AppVersion.current,
 757            trackedDomains: loadTrackedDomains(defaults: defaults),
 758            historyEntries: loadHistoryEntries(defaults: defaults),
 759            auditSessions: loadAuditSessions(defaults: defaults),
 760            workflows: loadWorkflows(defaults: defaults),
 761            monitoringSettings: loadMonitoringSettings(defaults: defaults),
 762            monitoringLogs: loadMonitoringLogs(defaults: defaults),
 763            appSettings: loadAppSettings(defaults: defaults),
 764            featureMetadata: loadFeatureMetadata(defaults: defaults)
 765        )
 766    }
 767
 768    static func backupData(defaults: UserDefaults = .standard) throws -> Data {
 769        try makeEncoder().encode(currentBackup(defaults: defaults))
 770    }
 771
 772    static func trackedDomainsExportData(defaults: UserDefaults = .standard) throws -> Data {
 773        try makeEncoder().encode(
 774            TrackedDomainsExport(
 775                schemaVersion: DomainDigBackup.currentSchemaVersion,
 776                exportedAt: Date(),
 777                appVersion: AppVersion.current,
 778                trackedDomains: loadTrackedDomains(defaults: defaults)
 779            )
 780        )
 781    }
 782
 783    static func workflowsExportData(defaults: UserDefaults = .standard) throws -> Data {
 784        try makeEncoder().encode(
 785            WorkflowsExport(
 786                schemaVersion: DomainDigBackup.currentSchemaVersion,
 787                exportedAt: Date(),
 788                appVersion: AppVersion.current,
 789                workflows: loadWorkflows(defaults: defaults)
 790            )
 791        )
 792    }
 793
 794    static func historyExportData(defaults: UserDefaults = .standard) throws -> Data {
 795        try makeEncoder().encode(
 796            HistoryExport(
 797                schemaVersion: DomainDigBackup.currentSchemaVersion,
 798                exportedAt: Date(),
 799                appVersion: AppVersion.current,
 800                historyEntries: loadHistoryEntries(defaults: defaults)
 801            )
 802        )
 803    }
 804
 805    static func trackedDomainsCSV(defaults: UserDefaults = .standard) -> String {
 806        DataPortabilityCSV.trackedDomains(loadTrackedDomains(defaults: defaults))
 807    }
 808
 809    static func workflowsCSV(defaults: UserDefaults = .standard) -> String {
 810        DataPortabilityCSV.workflows(loadWorkflows(defaults: defaults))
 811    }
 812
 813    static func prepareImport(
 814        data: Data,
 815        fileName: String,
 816        mode: DataPortabilityImportMode,
 817        defaults: UserDefaults = .standard
 818    ) throws -> DataImportPreview {
 819        DataMigrationService.migrateIfNeeded(defaults: defaults)
 820        let currentCounts = lifecycleSummary(defaults: defaults)
 821
 822        let payload = try decodeImportPayload(from: data, fileName: fileName)
 823        let projectedCounts = projectCounts(for: payload, mode: mode, defaults: defaults)
 824
 825        return DataImportPreview(
 826            fileName: fileName,
 827            kind: payload.kind,
 828            mode: mode,
 829            summaryLines: payload.summaryLines(mode: mode),
 830            warnings: payload.validationReport.warnings,
 831            currentCounts: currentCounts,
 832            projectedCounts: projectedCounts,
 833            payload: payload
 834        )
 835    }
 836
 837    static func applyImport(
 838        _ preview: DataImportPreview,
 839        mode: DataPortabilityImportMode,
 840        defaults: UserDefaults = .standard
 841    ) throws -> DataImportResult {
 842        switch preview.payload {
 843        case .backup(let backup, let report):
 844            try applyBackup(backup, mode: mode, defaults: defaults)
 845            return DataImportResult(
 846                kind: .backup,
 847                mode: mode,
 848                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.",
 849                warnings: report.warnings
 850            )
 851        case .trackedDomains(let trackedDomains, let report):
 852            applyTrackedDomainsImport(trackedDomains, mode: mode, defaults: defaults)
 853            return DataImportResult(
 854                kind: .trackedDomains,
 855                mode: mode,
 856                summary: "Imported \(trackedDomains.count) tracked domains.",
 857                warnings: report.warnings
 858            )
 859        case .workflows(let workflows, let report):
 860            applyWorkflowsImport(workflows, mode: mode, defaults: defaults)
 861            return DataImportResult(
 862                kind: .workflows,
 863                mode: mode,
 864                summary: "Imported \(workflows.count) workflows.",
 865                warnings: report.warnings
 866            )
 867        }
 868    }
 869
 870    static func validateBackup(data: Data, fileName: String = "backup.json") throws -> DataValidationReport {
 871        switch try decodeImportPayload(from: data, fileName: fileName) {
 872        case .backup(_, let report):
 873            return report
 874        case .trackedDomains(_, let report):
 875            return report
 876        case .workflows(_, let report):
 877            return report
 878        }
 879    }
 880
 881    static func lifecycleSummary(defaults: UserDefaults = .standard) -> DataLifecycleSummary {
 882        let appSettings = loadAppSettings(defaults: defaults)
 883        let featureMetadata = loadFeatureMetadata(defaults: defaults)
 884        let cachedItems = appSettings.recentSearches.count
 885            + appSettings.savedDomains.count
 886            + (featureMetadata.cachedEntitlement == nil ? 0 : 1)
 887            + (featureMetadata.usageCredits == nil ? 0 : 1)
 888
 889        return DataLifecycleSummary(
 890            trackedDomains: loadTrackedDomains(defaults: defaults).count,
 891            historySnapshots: loadHistoryEntries(defaults: defaults).count,
 892            auditSessions: loadAuditSessions(defaults: defaults).count,
 893            workflows: loadWorkflows(defaults: defaults).count,
 894            cachedItems: cachedItems,
 895            monitoringLogs: loadMonitoringLogs(defaults: defaults).count
 896        )
 897    }
 898
 899    private static func decodeImportPayload(from data: Data, fileName: String) throws -> ImportPayload {
 900        let decoder = JSONDecoder()
 901
 902        if let backup = try? decoder.decode(DomainDigBackup.self, from: data) {
 903            if backup.schemaVersion > DomainDigBackup.currentSchemaVersion {
 904                throw DataPortabilityError.unsupportedSchema(backup.schemaVersion)
 905            }
 906            let report = DataValidationService.validate(backup: backup)
 907            if !report.errors.isEmpty {
 908                throw DataPortabilityError.validationFailed(report.errors)
 909            }
 910            return .backup(backup, report)
 911        }
 912
 913        if let backup = try? decodeLegacyBackup(from: data) {
 914            let report = DataValidationService.validate(backup: backup)
 915            if !report.errors.isEmpty {
 916                throw DataPortabilityError.validationFailed(report.errors)
 917            }
 918            return .backup(backup, report)
 919        }
 920
 921        if let export = try? decoder.decode(TrackedDomainsExport.self, from: data) {
 922            let report = DataValidationService.validate(trackedDomains: export.trackedDomains)
 923            if !report.errors.isEmpty {
 924                throw DataPortabilityError.validationFailed(report.errors)
 925            }
 926            return .trackedDomains(export.trackedDomains, report)
 927        }
 928
 929        if let export = try? decoder.decode(WorkflowsExport.self, from: data) {
 930            let report = DataValidationService.validate(workflows: export.workflows)
 931            if !report.errors.isEmpty {
 932                throw DataPortabilityError.validationFailed(report.errors)
 933            }
 934            return .workflows(export.workflows, report)
 935        }
 936
 937        if let trackedDomains = try? decoder.decode([TrackedDomain].self, from: data) {
 938            let report = DataValidationService.validate(trackedDomains: trackedDomains)
 939            if !report.errors.isEmpty {
 940                throw DataPortabilityError.validationFailed(report.errors)
 941            }
 942            return .trackedDomains(trackedDomains, report)
 943        }
 944
 945        if let workflows = try? decoder.decode([DomainWorkflow].self, from: data) {
 946            let report = DataValidationService.validate(workflows: workflows)
 947            if !report.errors.isEmpty {
 948                throw DataPortabilityError.validationFailed(report.errors)
 949            }
 950            return .workflows(workflows, report)
 951        }
 952
 953        let lowercasedFileName = fileName.lowercased()
 954        if lowercasedFileName.hasSuffix(".csv") {
 955            guard let string = String(data: data, encoding: .utf8) else {
 956                throw DataPortabilityError.unreadableFile
 957            }
 958            if lowercasedFileName.contains("workflow") {
 959                let workflows = try DataPortabilityCSV.parseWorkflows(from: string)
 960                let report = DataValidationService.validate(workflows: workflows)
 961                if !report.errors.isEmpty {
 962                    throw DataPortabilityError.validationFailed(report.errors)
 963                }
 964                return .workflows(workflows, report)
 965            }
 966
 967            let trackedDomains = try DataPortabilityCSV.parseTrackedDomains(from: string)
 968            let report = DataValidationService.validate(trackedDomains: trackedDomains)
 969            if !report.errors.isEmpty {
 970                throw DataPortabilityError.validationFailed(report.errors)
 971            }
 972            return .trackedDomains(trackedDomains, report)
 973        }
 974
 975        throw DataPortabilityError.unsupportedFormat
 976    }
 977
 978    private static func decodeLegacyBackup(from data: Data) throws -> DomainDigBackup {
 979        struct LegacyBackup: Decodable {
 980            let trackedDomains: [TrackedDomain]?
 981            let historyEntries: [HistoryEntry]?
 982            let workflows: [DomainWorkflow]?
 983            let monitoringSettings: MonitoringSettings?
 984            let monitoringLogs: [MonitoringLog]?
 985            let recentSearches: [String]?
 986            let savedDomains: [String]?
 987        }
 988
 989        let legacy = try JSONDecoder().decode(LegacyBackup.self, from: data)
 990        return DomainDigBackup(
 991            schemaVersion: 0,
 992            exportedAt: Date(),
 993            appVersion: AppVersion.current,
 994            trackedDomains: legacy.trackedDomains ?? [],
 995            historyEntries: legacy.historyEntries ?? [],
 996            auditSessions: nil,
 997            workflows: legacy.workflows ?? [],
 998            monitoringSettings: legacy.monitoringSettings,
 999            monitoringLogs: legacy.monitoringLogs ?? [],
1000            appSettings: AppSettingsSnapshot(
1001                recentSearches: legacy.recentSearches ?? [],
1002                savedDomains: legacy.savedDomains ?? [],
1003                resolverURLString: DNSResolverOption.defaultURLString,
1004                appDensityRawValue: AppDensity.compact.rawValue
1005            ),
1006            featureMetadata: nil
1007        )
1008    }
1009
1010    private static func applyBackup(
1011        _ backup: DomainDigBackup,
1012        mode: DataPortabilityImportMode,
1013        defaults: UserDefaults
1014    ) throws {
1015        if mode == .replace {
1016            saveTrackedDomains(deduplicatedTrackedDomains(backup.trackedDomains), defaults: defaults)
1017            saveHistoryEntries(deduplicatedHistoryEntries(backup.historyEntries), defaults: defaults)
1018            saveAuditSessions(backup.auditSessions ?? [], defaults: defaults)
1019            saveWorkflows(deduplicatedWorkflows(backup.workflows), defaults: defaults)
1020            saveMonitoringSettings(
1021                MonitoringStorage.sanitizeSettings(
1022                    backup.monitoringSettings ?? MonitoringSettings(),
1023                    trackedDomains: deduplicatedTrackedDomains(backup.trackedDomains)
1024                ),
1025                defaults: defaults
1026            )
1027            saveMonitoringLogs(backup.monitoringLogs, defaults: defaults)
1028            saveAppSettings(backup.appSettings, defaults: defaults)
1029            saveFeatureMetadata(backup.featureMetadata, defaults: defaults)
1030            return
1031        }
1032
1033        let existingTrackedDomains = loadTrackedDomains(defaults: defaults)
1034        let mergedTrackedDomains = mergeTrackedDomains(existing: existingTrackedDomains, incoming: backup.trackedDomains)
1035        saveTrackedDomains(mergedTrackedDomains.domains, defaults: defaults)
1036
1037        let existingHistoryEntries = loadHistoryEntries(defaults: defaults)
1038        let remappedIncomingHistory = backup.historyEntries.map { remapHistoryEntry($0, using: mergedTrackedDomains.idMap) }
1039        saveHistoryEntries(
1040            mergeHistoryEntries(existing: existingHistoryEntries, incoming: remappedIncomingHistory),
1041            defaults: defaults
1042        )
1043
1044        let existingAuditSessions = loadAuditSessions(defaults: defaults)
1045        let mergedAuditSessions = mergeAuditSessions(existing: existingAuditSessions, incoming: backup.auditSessions ?? [])
1046        saveAuditSessions(mergedAuditSessions, defaults: defaults)
1047
1048        let existingWorkflows = loadWorkflows(defaults: defaults)
1049        saveWorkflows(mergeWorkflows(existing: existingWorkflows, incoming: backup.workflows), defaults: defaults)
1050
1051        let existingMonitoringLogs = loadMonitoringLogs(defaults: defaults)
1052        saveMonitoringLogs(mergeMonitoringLogs(existing: existingMonitoringLogs, incoming: backup.monitoringLogs), defaults: defaults)
1053
1054        let remappedMonitoringSettings = remapMonitoringSettings(
1055            backup.monitoringSettings ?? MonitoringSettings(),
1056            using: mergedTrackedDomains.idMap
1057        )
1058        saveMonitoringSettings(
1059            MonitoringStorage.sanitizeSettings(remappedMonitoringSettings, trackedDomains: mergedTrackedDomains.domains),
1060            defaults: defaults
1061        )
1062
1063        let mergedSettings = mergeAppSettings(existing: loadAppSettings(defaults: defaults), incoming: backup.appSettings)
1064        saveAppSettings(mergedSettings, defaults: defaults)
1065
1066        let mergedFeatureMetadata = mergeFeatureMetadata(existing: loadFeatureMetadata(defaults: defaults), incoming: backup.featureMetadata)
1067        saveFeatureMetadata(mergedFeatureMetadata, defaults: defaults)
1068    }
1069
1070    private static func applyTrackedDomainsImport(
1071        _ trackedDomains: [TrackedDomain],
1072        mode: DataPortabilityImportMode,
1073        defaults: UserDefaults
1074    ) {
1075        let importedDomains = deduplicatedTrackedDomains(trackedDomains)
1076        let finalDomains: [TrackedDomain]
1077        switch mode {
1078        case .merge:
1079            finalDomains = mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: importedDomains).domains
1080        case .replace:
1081            finalDomains = importedDomains
1082        }
1083
1084        saveTrackedDomains(finalDomains, defaults: defaults)
1085        let sanitizedSettings = MonitoringStorage.sanitizeSettings(loadMonitoringSettings(defaults: defaults), trackedDomains: finalDomains)
1086        saveMonitoringSettings(sanitizedSettings, defaults: defaults)
1087    }
1088
1089    private static func applyWorkflowsImport(
1090        _ workflows: [DomainWorkflow],
1091        mode: DataPortabilityImportMode,
1092        defaults: UserDefaults
1093    ) {
1094        let importedWorkflows = deduplicatedWorkflows(workflows)
1095        let finalWorkflows: [DomainWorkflow]
1096        switch mode {
1097        case .merge:
1098            finalWorkflows = mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: importedWorkflows)
1099        case .replace:
1100            finalWorkflows = importedWorkflows
1101        }
1102        saveWorkflows(finalWorkflows, defaults: defaults)
1103    }
1104
1105    private static func projectCounts(
1106        for payload: ImportPayload,
1107        mode: DataPortabilityImportMode,
1108        defaults: UserDefaults
1109    ) -> DataLifecycleSummary {
1110        let current = lifecycleSummary(defaults: defaults)
1111        switch payload {
1112        case .backup(let backup, _):
1113            if mode == .replace {
1114                return DataLifecycleSummary(
1115                    trackedDomains: backup.trackedDomains.count,
1116                    historySnapshots: backup.historyEntries.count,
1117                    auditSessions: backup.auditSessions?.count ?? 0,
1118                    workflows: backup.workflows.count,
1119                    cachedItems: backup.appSettings.recentSearches.count + backup.appSettings.savedDomains.count + ((backup.featureMetadata?.cachedEntitlement == nil ? 0 : 1) + (backup.featureMetadata?.usageCredits == nil ? 0 : 1)),
1120                    monitoringLogs: backup.monitoringLogs.count
1121                )
1122            }
1123
1124            return DataLifecycleSummary(
1125                trackedDomains: mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: backup.trackedDomains).domains.count,
1126                historySnapshots: mergeHistoryEntries(existing: loadHistoryEntries(defaults: defaults), incoming: backup.historyEntries).count,
1127                auditSessions: mergeAuditSessions(existing: loadAuditSessions(defaults: defaults), incoming: backup.auditSessions ?? []).count,
1128                workflows: mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: backup.workflows).count,
1129                cachedItems: max(current.cachedItems, backup.appSettings.recentSearches.count + backup.appSettings.savedDomains.count),
1130                monitoringLogs: mergeMonitoringLogs(existing: loadMonitoringLogs(defaults: defaults), incoming: backup.monitoringLogs).count
1131            )
1132        case .trackedDomains(let trackedDomains, _):
1133            let total = mode == .replace
1134                ? trackedDomains.count
1135                : mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: trackedDomains).domains.count
1136            return DataLifecycleSummary(
1137                trackedDomains: total,
1138                historySnapshots: current.historySnapshots,
1139                auditSessions: current.auditSessions,
1140                workflows: current.workflows,
1141                cachedItems: current.cachedItems,
1142                monitoringLogs: current.monitoringLogs
1143            )
1144        case .workflows(let workflows, _):
1145            let total = mode == .replace
1146                ? workflows.count
1147                : mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: workflows).count
1148            return DataLifecycleSummary(
1149                trackedDomains: current.trackedDomains,
1150                historySnapshots: current.historySnapshots,
1151                auditSessions: current.auditSessions,
1152                workflows: total,
1153                cachedItems: current.cachedItems,
1154                monitoringLogs: current.monitoringLogs
1155            )
1156        }
1157    }
1158
1159    private static func mergeTrackedDomains(existing: [TrackedDomain], incoming: [TrackedDomain]) -> MergedTrackedDomains {
1160        var mergedByDomain = Dictionary(uniqueKeysWithValues: existing.map { (normalizeDomain($0.domain), $0) })
1161        var idMap: [UUID: UUID] = [:]
1162
1163        for trackedDomain in incoming {
1164            let key = normalizeDomain(trackedDomain.domain)
1165            guard !key.isEmpty else { continue }
1166
1167            if let existingDomain = mergedByDomain[key] {
1168                let chosen = trackedDomain.updatedAt >= existingDomain.updatedAt
1169                    ? mergeTrackedDomain(existing: existingDomain, incoming: trackedDomain)
1170                    : mergeTrackedDomain(existing: trackedDomain, incoming: existingDomain)
1171                mergedByDomain[key] = chosen
1172                idMap[trackedDomain.id] = chosen.id
1173            } else {
1174                let normalized = normalizedTrackedDomain(trackedDomain)
1175                mergedByDomain[key] = normalized
1176                idMap[trackedDomain.id] = normalized.id
1177            }
1178        }
1179
1180        for trackedDomain in existing {
1181            idMap[trackedDomain.id] = mergedByDomain[normalizeDomain(trackedDomain.domain)]?.id ?? trackedDomain.id
1182        }
1183
1184        return MergedTrackedDomains(
1185            domains: mergedByDomain.values.sorted {
1186                if $0.updatedAt != $1.updatedAt {
1187                    return $0.updatedAt > $1.updatedAt
1188                }
1189                return $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending
1190            },
1191            idMap: idMap
1192        )
1193    }
1194
1195    private static func mergeTrackedDomain(existing: TrackedDomain, incoming: TrackedDomain) -> TrackedDomain {
1196        let normalizedDomain = normalizeDomain(existing.domain.isEmpty ? incoming.domain : existing.domain)
1197        let winner = existing.updatedAt >= incoming.updatedAt ? existing : incoming
1198        let note = winner.note ?? existing.note ?? incoming.note
1199
1200        return TrackedDomain(
1201            id: existing.id,
1202            domain: normalizedDomain,
1203            createdAt: min(existing.createdAt, incoming.createdAt),
1204            updatedAt: max(existing.updatedAt, incoming.updatedAt),
1205            note: note,
1206            isPinned: existing.isPinned || incoming.isPinned,
1207            monitoringEnabled: existing.monitoringEnabled || incoming.monitoringEnabled,
1208            lastKnownAvailability: winner.lastKnownAvailability ?? existing.lastKnownAvailability ?? incoming.lastKnownAvailability,
1209            lastSnapshotID: winner.lastSnapshotID ?? existing.lastSnapshotID ?? incoming.lastSnapshotID,
1210            lastChangeSummary: winner.lastChangeSummary ?? existing.lastChangeSummary ?? incoming.lastChangeSummary,
1211            lastChangeSeverity: winner.lastChangeSeverity ?? existing.lastChangeSeverity ?? incoming.lastChangeSeverity,
1212            certificateWarningLevel: higherCertificateWarningLevel(existing.certificateWarningLevel, incoming.certificateWarningLevel),
1213            certificateDaysRemaining: winner.certificateDaysRemaining ?? existing.certificateDaysRemaining ?? incoming.certificateDaysRemaining,
1214            lastMonitoredAt: [existing.lastMonitoredAt, incoming.lastMonitoredAt].compactMap { $0 }.max(),
1215            lastAlertAt: [existing.lastAlertAt, incoming.lastAlertAt].compactMap { $0 }.max(),
1216            monitoringState: preferredMonitoringState(existing.monitoringState, incoming.monitoringState),
1217            pendingMonitoringAlerts: deduplicatedPendingAlerts(existing.pendingMonitoringAlerts + incoming.pendingMonitoringAlerts)
1218        )
1219    }
1220
1221    private static func mergeHistoryEntries(existing: [HistoryEntry], incoming: [HistoryEntry]) -> [HistoryEntry] {
1222        var mergedByKey: [String: HistoryEntry] = [:]
1223
1224        for entry in existing + incoming {
1225            let key = historyKey(for: entry)
1226            if let existingEntry = mergedByKey[key] {
1227                mergedByKey[key] = chooseBetterHistoryEntry(existingEntry, entry)
1228            } else {
1229                mergedByKey[key] = entry
1230            }
1231        }
1232
1233        return mergedByKey.values.sorted { lhs, rhs in
1234            if lhs.timestamp != rhs.timestamp {
1235                return lhs.timestamp > rhs.timestamp
1236            }
1237            return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending
1238        }
1239    }
1240
1241    private static func mergeAuditSessions(existing: [AuditSession], incoming: [AuditSession]) -> [AuditSession] {
1242        var merged = Dictionary(uniqueKeysWithValues: existing.map { ($0.id, $0) })
1243
1244        for session in incoming {
1245            if let existingSession = merged[session.id] {
1246                let winner = existingSession.findings.count >= session.findings.count ? existingSession : session
1247                let candidateNotes = [existingSession.notes, session.notes]
1248                    .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
1249                    .filter { !$0.isEmpty }
1250                let notes = candidateNotes.reduce(into: [String]()) { result, note in
1251                    if !result.contains(note) {
1252                        result.append(note)
1253                    }
1254                }.joined(separator: "\n\n")
1255                merged[session.id] = AuditSession(
1256                    id: existingSession.id,
1257                    domain: existingSession.domain,
1258                    createdAt: min(existingSession.createdAt, session.createdAt),
1259                    reviewer: winner.reviewer,
1260                    status: winner.status,
1261                    evidence: winner.evidence,
1262                    findings: winner.findings,
1263                    notes: notes,
1264                    checklist: winner.checklist
1265                )
1266            } else {
1267                merged[session.id] = session
1268            }
1269        }
1270
1271        return merged.values.sorted { $0.createdAt > $1.createdAt }
1272    }
1273
1274    private static func mergeWorkflows(existing: [DomainWorkflow], incoming: [DomainWorkflow]) -> [DomainWorkflow] {
1275        var merged = Dictionary(uniqueKeysWithValues: existing.map { ($0.id, normalizedWorkflow($0)) })
1276
1277        for workflow in incoming {
1278            let normalized = normalizedWorkflow(workflow)
1279            if let existingWorkflow = merged[workflow.id] {
1280                let winner = existingWorkflow.updatedAt >= normalized.updatedAt ? existingWorkflow : normalized
1281                merged[workflow.id] = DomainWorkflow(
1282                    id: existingWorkflow.id,
1283                    name: winner.name,
1284                    domains: deduplicated(existingWorkflow.domains + normalized.domains),
1285                    createdAt: min(existingWorkflow.createdAt, normalized.createdAt),
1286                    updatedAt: max(existingWorkflow.updatedAt, normalized.updatedAt),
1287                    notes: winner.notes ?? existingWorkflow.notes ?? normalized.notes
1288                )
1289            } else {
1290                merged[workflow.id] = normalized
1291            }
1292        }
1293
1294        return merged.values.sorted(by: workflowSort)
1295    }
1296
1297    private static func mergeMonitoringLogs(existing: [MonitoringLog], incoming: [MonitoringLog]) -> [MonitoringLog] {
1298        var merged: [UUID: MonitoringLog] = [:]
1299        for log in existing + incoming {
1300            if let current = merged[log.id] {
1301                merged[log.id] = log.timestamp >= current.timestamp ? log : current
1302            } else {
1303                merged[log.id] = log
1304            }
1305        }
1306        return merged.values.sorted { $0.timestamp > $1.timestamp }.prefix(MonitoringStorage.maxLogs).map { $0 }
1307    }
1308
1309    private static func mergeAppSettings(existing: AppSettingsSnapshot, incoming: AppSettingsSnapshot) -> AppSettingsSnapshot {
1310        AppSettingsSnapshot(
1311            recentSearches: deduplicated(incoming.recentSearches + existing.recentSearches).prefix(20).map { $0 },
1312            savedDomains: deduplicated(incoming.savedDomains + existing.savedDomains),
1313            resolverURLString: incoming.resolverURLString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? existing.resolverURLString : incoming.resolverURLString,
1314            appDensityRawValue: incoming.appDensityRawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? existing.appDensityRawValue : incoming.appDensityRawValue
1315        )
1316    }
1317
1318    private static func mergeFeatureMetadata(existing: FeatureMetadataSnapshot, incoming: FeatureMetadataSnapshot?) -> FeatureMetadataSnapshot {
1319        guard let incoming else { return existing }
1320
1321        let mergedUsageCredits: UsageCreditsSnapshot?
1322        switch (existing.usageCredits, incoming.usageCredits) {
1323        case let (.some(lhs), .some(rhs)):
1324            let mergedRemaining = Dictionary(uniqueKeysWithValues: UsageCreditFeature.allCases.map { feature in
1325                (feature, max(lhs.remainingByFeature[feature] ?? 0, rhs.remainingByFeature[feature] ?? 0))
1326            })
1327            mergedUsageCredits = UsageCreditsSnapshot(
1328                appVersion: rhs.appVersion,
1329                remainingByFeature: mergedRemaining
1330            )
1331        case (.some(let lhs), nil):
1332            mergedUsageCredits = lhs
1333        case (nil, .some(let rhs)):
1334            mergedUsageCredits = rhs
1335        case (nil, nil):
1336            mergedUsageCredits = nil
1337        }
1338
1339        return FeatureMetadataSnapshot(
1340            cachedEntitlement: incoming.cachedEntitlement ?? existing.cachedEntitlement,
1341            usageCredits: mergedUsageCredits
1342        )
1343    }
1344
1345    private static func remapMonitoringSettings(
1346        _ settings: MonitoringSettings,
1347        using idMap: [UUID: UUID]
1348    ) -> MonitoringSettings {
1349        var remapped = settings
1350        remapped.selectedDomainIDs = deduplicatedUUIDs(settings.selectedDomainIDs.compactMap { idMap[$0] ?? $0 })
1351        return remapped
1352    }
1353
1354    private static func remapHistoryEntry(_ entry: HistoryEntry, using idMap: [UUID: UUID]) -> HistoryEntry {
1355        var remapped = entry
1356        if let trackedDomainID = entry.trackedDomainID {
1357            remapped.trackedDomainID = idMap[trackedDomainID] ?? trackedDomainID
1358        }
1359        return remapped
1360    }
1361
1362    private static func chooseBetterHistoryEntry(_ lhs: HistoryEntry, _ rhs: HistoryEntry) -> HistoryEntry {
1363        if historyQualityScore(lhs) == historyQualityScore(rhs) {
1364            return lhs.timestamp >= rhs.timestamp ? lhs : rhs
1365        }
1366        return historyQualityScore(lhs) >= historyQualityScore(rhs) ? lhs : rhs
1367    }
1368
1369    private static func historyQualityScore(_ entry: HistoryEntry) -> Int {
1370        var score = 0
1371        score += entry.dnsSections.count
1372        score += entry.httpHeaders.count
1373        score += entry.reachabilityResults.count
1374        score += entry.redirectChain.count
1375        score += entry.subdomains.count
1376        score += entry.portScanResults.count
1377        score += entry.validationIssues.isEmpty ? 2 : 0
1378        score += entry.sslInfo == nil ? 0 : 4
1379        score += entry.ownership == nil ? 0 : 4
1380        score += entry.emailSecurity == nil ? 0 : 3
1381        score += entry.ipGeolocation == nil ? 0 : 2
1382        return score
1383    }
1384
1385    private static func historyKey(for entry: HistoryEntry) -> String {
1386        if entry.id != UUID() {
1387            return "id:\(entry.id.uuidString)"
1388        }
1389        return "domain:\(normalizeDomain(entry.domain))|timestamp:\(entry.timestamp.timeIntervalSince1970)"
1390    }
1391
1392    private nonisolated static func normalizeDomain(_ value: String) -> String {
1393        value
1394            .trimmingCharacters(in: .whitespacesAndNewlines)
1395            .replacingOccurrences(of: "https://", with: "")
1396            .replacingOccurrences(of: "http://", with: "")
1397            .components(separatedBy: "/")
1398            .first?
1399            .lowercased() ?? ""
1400    }
1401
1402    private static func normalizedTrackedDomain(_ trackedDomain: TrackedDomain) -> TrackedDomain {
1403        TrackedDomain(
1404            id: trackedDomain.id,
1405            domain: normalizeDomain(trackedDomain.domain),
1406            createdAt: trackedDomain.createdAt,
1407            updatedAt: trackedDomain.updatedAt,
1408            note: trackedDomain.note,
1409            isPinned: trackedDomain.isPinned,
1410            monitoringEnabled: trackedDomain.monitoringEnabled,
1411            lastKnownAvailability: trackedDomain.lastKnownAvailability,
1412            lastSnapshotID: trackedDomain.lastSnapshotID,
1413            lastChangeSummary: trackedDomain.lastChangeSummary,
1414            lastChangeSeverity: trackedDomain.lastChangeSeverity,
1415            certificateWarningLevel: trackedDomain.certificateWarningLevel,
1416            certificateDaysRemaining: trackedDomain.certificateDaysRemaining,
1417            lastMonitoredAt: trackedDomain.lastMonitoredAt,
1418            lastAlertAt: trackedDomain.lastAlertAt,
1419            monitoringState: trackedDomain.monitoringState,
1420            pendingMonitoringAlerts: trackedDomain.pendingMonitoringAlerts
1421        )
1422    }
1423
1424    private static func preferredMonitoringState(_ lhs: MonitoringState, _ rhs: MonitoringState) -> MonitoringState {
1425        let lhsDate = [lhs.lastCheck, lhs.lastAlertDate, lhs.lastChangeDate].compactMap { $0 }.max() ?? .distantPast
1426        let rhsDate = [rhs.lastCheck, rhs.lastAlertDate, rhs.lastChangeDate].compactMap { $0 }.max() ?? .distantPast
1427        return lhsDate >= rhsDate ? lhs : rhs
1428    }
1429
1430    private static func deduplicatedPendingAlerts(_ alerts: [MonitoringPendingAlert]) -> [MonitoringPendingAlert] {
1431        var uniqueByHash: [String: MonitoringPendingAlert] = [:]
1432        for alert in alerts {
1433            if let existing = uniqueByHash[alert.changeHash] {
1434                uniqueByHash[alert.changeHash] = existing.detectedAt >= alert.detectedAt ? existing : alert
1435            } else {
1436                uniqueByHash[alert.changeHash] = alert
1437            }
1438        }
1439        return uniqueByHash.values.sorted { $0.detectedAt < $1.detectedAt }
1440    }
1441
1442    private static func normalizedWorkflow(_ workflow: DomainWorkflow) -> DomainWorkflow {
1443        DomainWorkflow(
1444            id: workflow.id,
1445            name: workflow.name.trimmingCharacters(in: .whitespacesAndNewlines),
1446            domains: deduplicated(workflow.domains.map(Self.normalizeDomain).filter { !$0.isEmpty }),
1447            createdAt: workflow.createdAt,
1448            updatedAt: workflow.updatedAt,
1449            notes: workflow.notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
1450        )
1451    }
1452
1453    private static func deduplicatedTrackedDomains(_ trackedDomains: [TrackedDomain]) -> [TrackedDomain] {
1454        mergeTrackedDomains(existing: [], incoming: trackedDomains).domains
1455    }
1456
1457    private static func deduplicatedHistoryEntries(_ historyEntries: [HistoryEntry]) -> [HistoryEntry] {
1458        mergeHistoryEntries(existing: [], incoming: historyEntries)
1459    }
1460
1461    private static func deduplicatedWorkflows(_ workflows: [DomainWorkflow]) -> [DomainWorkflow] {
1462        mergeWorkflows(existing: [], incoming: workflows)
1463    }
1464
1465    private nonisolated static func deduplicated(_ values: [String]) -> [String] {
1466        var seen = Set<String>()
1467        return values.filter { seen.insert($0).inserted }
1468    }
1469
1470    private nonisolated static func deduplicatedUUIDs(_ values: [UUID]) -> [UUID] {
1471        var seen = Set<UUID>()
1472        return values.filter { seen.insert($0).inserted }
1473    }
1474
1475    private nonisolated static func workflowSort(lhs: DomainWorkflow, rhs: DomainWorkflow) -> Bool {
1476        if lhs.updatedAt != rhs.updatedAt {
1477            return lhs.updatedAt > rhs.updatedAt
1478        }
1479        return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
1480    }
1481
1482    private nonisolated static func higherCertificateWarningLevel(
1483        _ lhs: CertificateWarningLevel,
1484        _ rhs: CertificateWarningLevel
1485    ) -> CertificateWarningLevel {
1486        let rank: [CertificateWarningLevel: Int] = [
1487            .none: 0,
1488            .warning: 1,
1489            .critical: 2
1490        ]
1491        return (rank[lhs] ?? 0) >= (rank[rhs] ?? 0) ? lhs : rhs
1492    }
1493
1494    private static func makeEncoder() -> JSONEncoder {
1495        let encoder = JSONEncoder()
1496        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
1497        return encoder
1498    }
1499}
1500
1501private struct MergedTrackedDomains {
1502    let domains: [TrackedDomain]
1503    let idMap: [UUID: UUID]
1504}
1505
1506private enum ImportPayload {
1507    case backup(DomainDigBackup, DataValidationReport)
1508    case trackedDomains([TrackedDomain], DataValidationReport)
1509    case workflows([DomainWorkflow], DataValidationReport)
1510
1511    var kind: DataPortabilityImportKind {
1512        switch self {
1513        case .backup:
1514            return .backup
1515        case .trackedDomains:
1516            return .trackedDomains
1517        case .workflows:
1518            return .workflows
1519        }
1520    }
1521
1522    var validationReport: DataValidationReport {
1523        switch self {
1524        case .backup(_, let report), .trackedDomains(_, let report), .workflows(_, let report):
1525            return report
1526        }
1527    }
1528
1529    func summaryLines(mode: DataPortabilityImportMode) -> [String] {
1530        switch self {
1531        case .backup(let backup, _):
1532            return [
1533                "Full backup import",
1534                "\(backup.trackedDomains.count) tracked domains",
1535                "\(backup.historyEntries.count) history snapshots",
1536                "\(backup.auditSessions?.count ?? 0) audit sessions",
1537                "\(backup.workflows.count) workflows",
1538                "\(backup.monitoringLogs.count) monitoring logs",
1539                mode == .replace ? "Replace mode will overwrite local backupable data." : "Merge mode will keep local data and consolidate duplicates."
1540            ]
1541        case .trackedDomains(let trackedDomains, _):
1542            return [
1543                "Tracked domains import",
1544                "\(trackedDomains.count) tracked domains",
1545                mode == .replace ? "Replace mode will overwrite the watchlist." : "Merge mode will consolidate duplicates by normalized domain."
1546            ]
1547        case .workflows(let workflows, _):
1548            return [
1549                "Workflow import",
1550                "\(workflows.count) workflows",
1551                mode == .replace ? "Replace mode will overwrite saved workflows." : "Merge mode will merge matching workflow IDs and deduplicate domains."
1552            ]
1553        }
1554    }
1555}
1556
1557private extension String {
1558    var nilIfEmpty: String? {
1559        isEmpty ? nil : self
1560    }
1561}