krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.7.0: DomainDig/DomainViewModel.swift · raw
1import Foundation
2import SwiftUI
3import UserNotifications
4
5enum ResultTone {
6 case primary
7 case secondary
8 case success
9 case warning
10 case failure
11}
12
13struct SummaryFieldViewData: Identifiable {
14 let id = UUID()
15 let label: String
16 let value: String
17 let tone: ResultTone
18}
19
20struct InfoRowViewData: Identifiable {
21 let id = UUID()
22 let label: String
23 let value: String
24 let tone: ResultTone
25}
26
27struct SectionMessageViewData {
28 let text: String
29 let isError: Bool
30}
31
32struct DNSRecordSectionViewData: Identifiable {
33 let id = UUID()
34 let title: String
35 let rows: [InfoRowViewData]
36 let wildcardRows: [InfoRowViewData]
37 let wildcardTitle: String?
38 let message: SectionMessageViewData?
39}
40
41struct EmailRowViewData: Identifiable {
42 let id = UUID()
43 let label: String
44 let status: String
45 let statusTone: ResultTone
46 let detail: String
47 let auxiliaryDetail: String?
48}
49
50struct RedirectHopViewData: Identifiable {
51 let id = UUID()
52 let stepLabel: String
53 let statusCode: String
54 let url: String
55 let isFinal: Bool
56}
57
58struct ReachabilityRowViewData: Identifiable {
59 let id = UUID()
60 let portLabel: String
61 let latencyLabel: String
62 let statusLabel: String
63 let statusTone: ResultTone
64}
65
66struct PortScanRowViewData: Identifiable {
67 let id = UUID()
68 let portLabel: String
69 let service: String
70 let statusLabel: String
71 let statusTone: ResultTone
72 let banner: String?
73 let durationLabel: String?
74}
75
76struct SubdomainRowViewData: Identifiable {
77 let id: String
78 let hostname: String
79 let isInteresting: Bool
80
81 init(hostname: String, isInteresting: Bool) {
82 self.id = hostname
83 self.hostname = hostname
84 self.isInteresting = isInteresting
85 }
86}
87
88struct DomainSuggestionViewData: Identifiable {
89 let id: UUID
90 let domain: String
91 let availabilityStatus: DomainAvailabilityStatus
92 let status: String
93 let tone: ResultTone
94}
95
96private struct BatchLookupPayload {
97 let snapshot: LookupSnapshot
98}
99
100private struct WorkflowExportPayload: Codable {
101 let workflowName: String
102 let generatedAt: Date
103 let workflowInsights: [WorkflowInsight]
104 let reports: [DomainReport]
105}
106
107struct PortfolioDomainStatus: Identifiable {
108 let trackedDomain: TrackedDomain
109 let latestEntry: HistoryEntry?
110 let report: DomainReport
111 let apexDomain: String
112 let health: DomainHealth
113 let lastChangeDate: Date?
114 let lastMonitoringFailure: Date?
115 let instabilityScore: Int
116 let certificateExpiryState: CertificateWarningLevel
117 let certificateDaysRemaining: Int?
118 let isUnreachable: Bool
119 let recentDNSChange: Bool
120 let recentCriticalChange: Bool
121 let recentFailureCount: Int
122
123 var id: UUID { trackedDomain.id }
124}
125
126struct PortfolioActivityItem: Identifiable {
127 let id: String
128 let trackedDomainID: UUID
129 let domain: String
130 let message: String
131 let timestamp: Date
132 let health: DomainHealth
133 let systemImage: String
134}
135
136struct PortfolioAttentionItem: Identifiable {
137 let id: String
138 let trackedDomainID: UUID
139 let domain: String
140 let reason: String
141 let timestamp: Date
142 let health: DomainHealth
143}
144
145struct PortfolioGroup: Identifiable {
146 let apexDomain: String
147 let domains: [PortfolioDomainStatus]
148
149 var id: String { apexDomain }
150}
151
152struct PortfolioDashboardData {
153 let snapshot: PortfolioSnapshot
154 let domainStates: [PortfolioDomainStatus]
155 let recentActivity: [PortfolioActivityItem]
156 let attentionRequired: [PortfolioAttentionItem]
157 let expiringSoon: [PortfolioDomainStatus]
158 let groups: [PortfolioGroup]
159}
160
161private struct PortfolioDashboardStamp: Equatable {
162 let trackedDomainSignature: [String]
163 let historySignature: [String]
164 let monitoringSignature: [String]
165}
166
167@MainActor
168@Observable
169final class DomainViewModel {
170 var domain: String = ""
171 var bulkInput: String = ""
172
173 var dnsSections: [DNSSection] = []
174 var dnsLoading = false
175 var dnsError: String?
176 var availabilityResult: DomainAvailabilityResult?
177 var availabilityLoading = false
178 var suggestions: [DomainSuggestionResult] = []
179 var suggestionsLoading = false
180
181 var sslInfo: SSLCertificateInfo?
182 var sslLoading = false
183 var sslError: String?
184 var hstsPreloaded: Bool?
185 var hstsLoading = false
186
187 var httpHeaders: [HTTPHeader] = []
188 var httpSecurityGrade: String?
189 var httpStatusCode: Int?
190 var httpResponseTimeMs: Int?
191 var httpProtocol: String?
192 var http3Advertised = false
193 var httpHeadersLoading = false
194 var httpHeadersError: String?
195
196 var reachabilityResults: [PortReachability] = []
197 var reachabilityLoading = false
198 var reachabilityError: String?
199
200 var ipGeolocation: IPGeolocation?
201 var ipGeolocationLoading = false
202 var ipGeolocationError: String?
203
204 var emailSecurity: EmailSecurityResult?
205 var emailSecurityLoading = false
206 var emailSecurityError: String?
207
208 var ownershipResult: DomainOwnership?
209 var ownershipLoading = false
210 var ownershipError: String?
211 var ownershipHistory: [DomainOwnershipHistoryEvent] = []
212 var ownershipHistoryLoading = false
213 var ownershipHistoryError: String?
214
215 var ptrRecord: String?
216 var ptrLoading = false
217 var ptrError: String?
218
219 var redirectChain: [RedirectHop] = []
220 var redirectChainLoading = false
221 var redirectChainError: String?
222
223 var subdomains: [DiscoveredSubdomain] = []
224 var subdomainsLoading = false
225 var subdomainsError: String?
226 var extendedSubdomains: [DiscoveredSubdomain] = []
227 var extendedSubdomainsLoading = false
228 var extendedSubdomainsError: String?
229 var dnsHistory: [DNSHistoryEvent] = []
230 var dnsHistoryLoading = false
231 var dnsHistoryError: String?
232 var domainPricing: DomainPricingInsight?
233 var domainPricingLoading = false
234 var domainPricingError: String?
235 var reputation: DomainReputationResult?
236 var reputationLoading = false
237 var reputationError: String?
238 var usageCredits: [UsageCreditFeature: UsageCreditStatus] = DomainViewModel.defaultUsageCredits()
239
240 var portScanResults: [PortScanResult] = []
241 var portScanLoading = false
242 var portScanError: String?
243 var customPortResults: [PortScanResult] = []
244 var customPortScanLoading = false
245 var customPortScanError: String?
246
247 var hasRun = false
248 private(set) var searchedDomain: String = ""
249 private(set) var lastLookupDurationMs: Int?
250 private(set) var currentDiffSections: [DomainDiffSection] = []
251 private(set) var currentChangeSummary: DomainChangeSummary?
252 private(set) var ownershipDiff: [DomainDiffItem] = []
253 private(set) var refreshingTrackedDomainID: UUID?
254 private(set) var rerunNavigationToken = UUID()
255 private(set) var batchResults: [BatchLookupResult] = []
256 private(set) var batchLookupSource: BatchLookupSource = .manual
257 private(set) var batchCurrentDomain: String?
258 private(set) var batchCompletedCount = 0
259 private(set) var batchTotalCount = 0
260 private(set) var batchLookupRunning = false
261 var latestBatchSweepSummary: BatchSweepSummary?
262 var latestWorkflowRunSummary: WorkflowRunSummary?
263 private(set) var notificationsAuthorized = false
264
265 private var lookupTask: Task<Void, Never>?
266 private var customPortScanTask: Task<Void, Never>?
267 private var batchTask: Task<Void, Never>?
268 private var activeLookupID = UUID()
269 private var lookupStartedAt: Date?
270 private var activeBatchDomains: [String] = []
271 private var lastBatchStartedAt: Date?
272 private var activeWorkflowRunID: UUID?
273 private var activeWorkflowRunName: String?
274 private var historyPersistenceSuspended = false
275 private var trackedDomainsPersistenceSuspended = false
276 private var historyPersistenceDirty = false
277 private var trackedDomainsPersistenceDirty = false
278 private let reportBuilder = DomainReportBuilder()
279 private let inspectionService = DomainInspectionService()
280 private var cachedPortfolioDashboardData: PortfolioDashboardData?
281 private var cachedPortfolioDashboardStamp: PortfolioDashboardStamp?
282 private(set) var currentResultSource: LookupResultSource = .live
283 private(set) var currentCachedSections: [LookupSectionKind] = []
284 private(set) var currentStatusMessage: String?
285 private(set) var currentSnapshotTimestamp = Date()
286 private(set) var currentHistoryEntryID: UUID?
287 private(set) var currentReport: DomainReport?
288
289 private static let recentSearchesKey = "recentSearches"
290 private static let maxRecent = 20
291 var recentSearches: [String] = DomainDataPortabilityService.loadRecentSearches()
292
293 private static let savedDomainsKey = "savedDomains"
294 var savedDomains: [String] = DomainDataPortabilityService.loadSavedDomains()
295
296 private static let trackedDomainsKey = "trackedDomains"
297 private static let legacyWatchedDomainsKey = "watchedDomains"
298 var trackedDomains: [TrackedDomain] = DomainViewModel.loadTrackedDomains()
299
300 private static let historyKey = "lookupHistory"
301 private static let maxHistory = 250
302 var history: [HistoryEntry] = DomainViewModel.loadHistoryEntries()
303 var auditSessions: [AuditSession] = DomainDataPortabilityService.loadAuditSessions()
304 private static let workflowsKey = "domainWorkflows"
305 var workflows: [DomainWorkflow] = DomainViewModel.loadWorkflows()
306 var historySearchText = ""
307 var historyDateFilter: HistoryDateFilter = .all
308 var historyChangeFilter: ChangeFilterOption = .all
309 var historySortOption: HistorySortOption = .newest
310 var timelineGrouping: TimelineGroupingOption = .relativeDay
311 var timelineDomainFilter = ""
312 var watchlistSearchText = ""
313 var watchlistFilter: WatchlistFilterOption = .all
314 var watchlistSortOption: WatchlistSortOption = .pinned
315 var watchlistTagFilter: String?
316 var watchlistSavedViews: [WatchlistSavedView] = DomainViewModel.loadWatchlistSavedViews()
317 var dashboardSearchText = ""
318 var dashboardFilter: PortfolioFilterOption = .all
319 var monitoringSettings: MonitoringSettings = MonitoringStorage.loadSettings()
320 var monitoringLogs: [MonitoringLog] = MonitoringStorage.loadLogs()
321 var monitoringRunInProgress = false
322 var monitoringStatusMessage: String?
323 var monitoringNotificationStatus: UNAuthorizationStatus = .notDetermined
324 var dataLifecycleSummary = DomainDataPortabilityService.lifecycleSummary()
325 var portabilityStatusMessage: String?
326 var upgradePrompt: UpgradePromptContext?
327 var isPaywallPresented = false
328 var selectedSnapshotIDs = Set<UUID>()
329 var activeDomainDiff: DomainDiff?
330 var activeDiffChangeIndex = 0
331
332 private static let historyAutoPruneKey = "historyAutoPrune"
333 var historyAutoPruneOption: HistoryAutoPruneOption = DomainViewModel.loadHistoryAutoPruneOption()
334
335 var trimmedDomain: String {
336 domain
337 .trimmingCharacters(in: .whitespacesAndNewlines)
338 .replacingOccurrences(of: "https://", with: "")
339 .replacingOccurrences(of: "http://", with: "")
340 .components(separatedBy: "/").first ?? ""
341 }
342
343 var resultsLoaded: Bool {
344 hasRun &&
345 !dnsLoading &&
346 !availabilityLoading &&
347 !suggestionsLoading &&
348 !sslLoading &&
349 !hstsLoading &&
350 !httpHeadersLoading &&
351 !reachabilityLoading &&
352 !ipGeolocationLoading &&
353 !emailSecurityLoading &&
354 !ownershipLoading &&
355 !ptrLoading &&
356 !redirectChainLoading &&
357 !subdomainsLoading &&
358 !portScanLoading &&
359 !customPortScanLoading
360 }
361
362 var activeLoadingLabels: [String] {
363 var labels: [String] = []
364 if availabilityLoading { labels.append("Availability") }
365 if dnsLoading { labels.append("DNS") }
366 if sslLoading || hstsLoading { labels.append("TLS") }
367 if httpHeadersLoading { labels.append("HTTP") }
368 if ownershipLoading { labels.append("Ownership") }
369 if ownershipHistoryLoading { labels.append("Ownership History") }
370 if emailSecurityLoading { labels.append("Email") }
371 if subdomainsLoading { labels.append("Subdomains") }
372 if extendedSubdomainsLoading { labels.append("Extended Subdomains") }
373 if dnsHistoryLoading { labels.append("DNS History") }
374 if domainPricingLoading { labels.append("Pricing") }
375 if redirectChainLoading { labels.append("Redirects") }
376 if reachabilityLoading { labels.append("Reachability") }
377 if ipGeolocationLoading { labels.append("Geolocation") }
378 if ptrLoading { labels.append("PTR") }
379 if portScanLoading { labels.append("Port Scan") }
380 if customPortScanLoading { labels.append("Custom Ports") }
381 return labels
382 }
383
384 var isCloudflareProxied: Bool {
385 httpHeaders.contains { $0.name.lowercased() == "cf-ray" }
386 }
387
388 var isCurrentDomainSaved: Bool {
389 !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() })
390 }
391
392 var sortedTrackedDomains: [TrackedDomain] {
393 sortedTrackedDomains(from: trackedDomains, using: .pinned)
394 }
395
396 var filteredHistory: [HistoryEntry] {
397 let calendar = Calendar.current
398 let now = Date()
399 let query = historySearchText.trimmingCharacters(in: .whitespacesAndNewlines)
400
401 return history
402 .lazy
403 .filter { entry in
404 query.isEmpty || entry.domain.localizedCaseInsensitiveContains(query)
405 }
406 .filter { entry in
407 switch self.historyDateFilter {
408 case .today:
409 return calendar.isDate(entry.timestamp, inSameDayAs: now)
410 case .last7Days:
411 guard let startDate = calendar.date(byAdding: .day, value: -7, to: now) else { return true }
412 return entry.timestamp >= startDate
413 case .all:
414 return true
415 }
416 }
417 .filter { entry in
418 switch self.historyChangeFilter {
419 case .all:
420 return true
421 case .changed:
422 return entry.changeSummary?.hasChanges == true
423 case .unchanged:
424 return entry.changeSummary?.hasChanges != true
425 }
426 }
427 .sorted(by: historySortPredicate)
428 }
429
430 var filteredTrackedDomains: [TrackedDomain] {
431 let query = watchlistSearchText.trimmingCharacters(in: .whitespacesAndNewlines)
432 let filtered = trackedDomains.filter { trackedDomain in
433 if !query.isEmpty, !trackedDomain.domain.localizedCaseInsensitiveContains(query) {
434 return false
435 }
436
437 if let watchlistTagFilter, !trackedDomain.tags.contains(watchlistTagFilter) {
438 return false
439 }
440
441 switch watchlistFilter {
442 case .all:
443 return true
444 case .pinnedOnly:
445 return trackedDomain.isPinned
446 case .changedOnly:
447 return trackedDomain.lastChangeSummary?.hasChanges == true
448 }
449 }
450
451 return sortedTrackedDomains(from: filtered, using: watchlistSortOption)
452 }
453
454 var portfolioDashboardData: PortfolioDashboardData {
455 let stamp = portfolioDashboardStamp()
456 if let cachedPortfolioDashboardData, cachedPortfolioDashboardStamp == stamp {
457 return cachedPortfolioDashboardData
458 }
459
460 let domainStates = buildPortfolioDomainStates()
461 let recentActivity = buildPortfolioActivity(from: domainStates)
462 let attentionRequired = buildAttentionQueue(from: domainStates)
463 let expiringSoon = domainStates
464 .filter { $0.certificateExpiryState != .none }
465 .sorted {
466 ($0.certificateDaysRemaining ?? .max, $0.trackedDomain.domain)
467 < ($1.certificateDaysRemaining ?? .max, $1.trackedDomain.domain)
468 }
469 let groups = buildPortfolioGroups(from: domainStates)
470 let snapshot = PortfolioSnapshot(
471 totalDomains: domainStates.count,
472 healthyCount: domainStates.filter { $0.health == .healthy }.count,
473 warningCount: domainStates.filter { $0.health == .warning }.count,
474 criticalCount: domainStates.filter { $0.health == .critical }.count,
475 changedLast24h: domainStates.filter {
476 guard let lastChangeDate = $0.lastChangeDate else { return false }
477 return Date().timeIntervalSince(lastChangeDate) <= 24 * 60 * 60
478 }.count,
479 expiringSoonCount: expiringSoon.count,
480 unreachableCount: domainStates.filter(\.isUnreachable).count
481 )
482 let data = PortfolioDashboardData(
483 snapshot: snapshot,
484 domainStates: domainStates,
485 recentActivity: recentActivity,
486 attentionRequired: attentionRequired,
487 expiringSoon: expiringSoon,
488 groups: groups
489 )
490 cachedPortfolioDashboardStamp = stamp
491 cachedPortfolioDashboardData = data
492 return data
493 }
494
495 var filteredPortfolioDomainStates: [PortfolioDomainStatus] {
496 let query = dashboardSearchText.trimmingCharacters(in: .whitespacesAndNewlines)
497 return portfolioDashboardData.domainStates.filter { state in
498 matchesPortfolioFilter(state) && matchesDashboardSearch(state, query: query)
499 }
500 }
501
502 var filteredPortfolioGroups: [PortfolioGroup] {
503 buildPortfolioGroups(from: filteredPortfolioDomainStates)
504 }
505
506 var filteredPortfolioRecentActivity: [PortfolioActivityItem] {
507 let visibleDomainIDs = Set(filteredPortfolioDomainStates.map(\.trackedDomain.id))
508 return portfolioDashboardData.recentActivity.filter { visibleDomainIDs.contains($0.trackedDomainID) }
509 }
510
511 var filteredPortfolioAttentionRequired: [PortfolioAttentionItem] {
512 let visibleDomainIDs = Set(filteredPortfolioDomainStates.map(\.trackedDomain.id))
513 return portfolioDashboardData.attentionRequired.filter { visibleDomainIDs.contains($0.trackedDomainID) }
514 }
515
516 var filteredPortfolioExpiringSoon: [PortfolioDomainStatus] {
517 let visibleDomainIDs = Set(filteredPortfolioDomainStates.map(\.trackedDomain.id))
518 return portfolioDashboardData.expiringSoon.filter { visibleDomainIDs.contains($0.trackedDomain.id) }
519 }
520
521 var timelineDomains: [String] {
522 let query = timelineDomainFilter.trimmingCharacters(in: .whitespacesAndNewlines)
523 let domains = history.map(\.domain)
524 let filtered = query.isEmpty
525 ? domains
526 : domains.filter { $0.localizedCaseInsensitiveContains(query) }
527 return Array(Set(filtered)).sorted()
528 }
529
530 var batchProgressLabel: String {
531 guard batchTotalCount > 0 else { return "No active batch" }
532 if !batchLookupRunning, batchCompletedCount >= batchTotalCount {
533 return "\(batchCompletedCount)/\(batchTotalCount) • Complete"
534 }
535 let domainLabel = activeBatchDomains.first ?? batchCurrentDomain ?? "Preparing"
536 return "\(batchCompletedCount)/\(batchTotalCount) • \(domainLabel)"
537 }
538
539 var currentBatchResultEntries: [HistoryEntry] {
540 batchResults.compactMap { result in
541 guard let historyEntryID = result.historyEntryID else { return nil }
542 return history.first(where: { $0.id == historyEntryID })
543 }
544 }
545
546 var currentTrackedDomain: TrackedDomain? {
547 guard !searchedDomain.isEmpty else { return nil }
548 return trackedDomain(for: searchedDomain)
549 }
550
551 var currentDomainWorkflows: [DomainWorkflow] {
552 guard !searchedDomain.isEmpty else { return [] }
553 return workflowsContaining(domain: searchedDomain)
554 }
555
556 var isCurrentDomainTracked: Bool {
557 currentTrackedDomain != nil
558 }
559
560 var trackingLimitMessage: String? {
561 FeatureAccessService.trackedDomainLimitMessage(currentCount: trackedDomains.count)
562 }
563
564 var canTrackCurrentDomain: Bool {
565 FeatureAccessService.canAddTrackedDomain(currentCount: trackedDomains.count)
566 }
567
568 var resolverDisplayName: String {
569 DNSLookupService.currentResolverDisplayName()
570 }
571
572 var resolverURLString: String {
573 DNSLookupService.currentResolverURLString()
574 }
575
576 var allPortScanResults: [PortScanResult] {
577 (portScanResults + customPortResults).sorted {
578 if $0.kind == $1.kind {
579 return $0.port < $1.port
580 }
581 return $0.kind == .standard
582 }
583 }
584
585 var currentSnapshot: LookupSnapshot {
586 LookupSnapshot(
587 historyEntryID: currentHistoryEntryID,
588 domain: searchedDomain,
589 timestamp: currentSnapshotTimestamp,
590 trackedDomainID: currentTrackedDomain?.id,
591 note: currentHistoryEntry?.note ?? currentTrackedDomain?.note,
592 appVersion: AppVersion.current,
593 resolverDisplayName: resolverDisplayName,
594 resolverURLString: resolverURLString,
595 dataSources: currentHistoryEntry?.dataSources ?? [],
596 provenanceBySection: currentHistoryEntry?.provenanceBySection ?? [:],
597 availabilityConfidence: currentHistoryEntry?.availabilityConfidence,
598 ownershipConfidence: currentHistoryEntry?.ownershipConfidence,
599 subdomainConfidence: currentHistoryEntry?.subdomainConfidence,
600 emailSecurityConfidence: currentHistoryEntry?.emailSecurityConfidence,
601 geolocationConfidence: currentHistoryEntry?.geolocationConfidence,
602 errorDetails: currentHistoryEntry?.errorDetails ?? [:],
603 isPartialSnapshot: currentHistoryEntry?.isPartialSnapshot ?? false,
604 validationIssues: currentHistoryEntry?.validationIssues ?? [],
605 totalLookupDurationMs: lastLookupDurationMs,
606 snapshotIndex: currentHistoryEntry?.snapshotIndex,
607 previousSnapshotID: currentHistoryEntry?.previousSnapshotID,
608 changeCount: currentHistoryEntry?.changeCount ?? currentChangeSummary?.changedSections.count ?? 0,
609 severitySummary: currentHistoryEntry?.severitySummary ?? currentChangeSummary?.severity,
610 dnsSections: dnsSections,
611 dnsError: dnsError,
612 availabilityResult: availabilityResult,
613 suggestions: suggestions,
614 sslInfo: sslInfo,
615 sslError: sslError,
616 hstsPreloaded: hstsPreloaded,
617 httpHeaders: httpHeaders,
618 httpSecurityGrade: httpSecurityGrade,
619 httpStatusCode: httpStatusCode,
620 httpResponseTimeMs: httpResponseTimeMs,
621 httpProtocol: httpProtocol,
622 http3Advertised: http3Advertised,
623 httpHeadersError: httpHeadersError,
624 reachabilityResults: reachabilityResults,
625 reachabilityError: reachabilityError,
626 ipGeolocation: ipGeolocation,
627 ipGeolocationError: ipGeolocationError,
628 emailSecurity: emailSecurity,
629 emailSecurityError: emailSecurityError,
630 ownership: ownershipResult,
631 ownershipError: ownershipError,
632 ownershipHistory: ownershipHistory,
633 ownershipHistoryError: ownershipHistoryError,
634 inferredProvider: currentHistoryEntry?.inferredProvider ?? currentReport?.inferredProvider,
635 priorProviders: currentHistoryEntry?.priorProviders ?? currentReport?.priorProviders ?? [],
636 domainClassification: currentHistoryEntry?.domainClassification ?? currentReport?.domainClassification,
637 ownershipTransitions: currentHistoryEntry?.ownershipTransitions ?? currentReport?.ownershipTransitions ?? [],
638 hostingTransitions: currentHistoryEntry?.hostingTransitions ?? currentReport?.hostingTransitions ?? [],
639 subdomainHistory: currentHistoryEntry?.subdomainHistory ?? currentReport?.subdomainHistory ?? [],
640 riskSignals: currentHistoryEntry?.riskSignals ?? currentReport?.riskSignals ?? [],
641 intelligenceTimeline: currentHistoryEntry?.intelligenceTimeline ?? currentReport?.intelligenceTimeline ?? [],
642 ptrRecord: ptrRecord,
643 ptrError: ptrError,
644 redirectChain: redirectChain,
645 redirectChainError: redirectChainError,
646 subdomains: subdomains,
647 subdomainsError: subdomainsError,
648 extendedSubdomains: extendedSubdomains,
649 extendedSubdomainsError: extendedSubdomainsError,
650 dnsHistory: dnsHistory,
651 dnsHistoryError: dnsHistoryError,
652 domainPricing: domainPricing,
653 domainPricingError: domainPricingError,
654 reputation: reputation,
655 reputationError: reputationError,
656 portScanResults: allPortScanResults,
657 portScanError: combinedPortScanError,
658 changeSummary: currentChangeSummary,
659 resultSource: currentResultSource,
660 cachedSections: currentCachedSections,
661 statusMessage: currentStatusMessage
662 )
663 }
664
665 private var currentHistoryEntry: HistoryEntry? {
666 guard let currentHistoryEntryID else { return nil }
667 return history.first(where: { $0.id == currentHistoryEntryID })
668 }
669
670 var currentRiskAssessment: DomainRiskAssessment? {
671 currentReport?.riskAssessment
672 }
673
674 var currentInsights: [String] {
675 currentReport?.insights ?? []
676 }
677
678 var currentSubdomainGroups: [SubdomainGroup] {
679 currentReport?.subdomainGroups ?? []
680 }
681
682 var currentDNSPatterns: DNSPatternSummary? {
683 currentReport?.dns.patternSummary
684 }
685
686 var currentEmailAssessment: EmailSecuritySummary? {
687 currentReport?.email
688 }
689
690 var currentTLSSummary: WebResultSummary? {
691 currentReport?.web
692 }
693
694 var ownershipHistoryCreditStatus: UsageCreditStatus {
695 usageCredits[.ownershipHistory] ?? Self.fallbackCreditStatus(for: .ownershipHistory)
696 }
697
698 var dnsHistoryCreditStatus: UsageCreditStatus {
699 usageCredits[.dnsHistory] ?? Self.fallbackCreditStatus(for: .dnsHistory)
700 }
701
702 var extendedSubdomainsCreditStatus: UsageCreditStatus {
703 usageCredits[.extendedSubdomains] ?? Self.fallbackCreditStatus(for: .extendedSubdomains)
704 }
705
706 var combinedSubdomains: [DiscoveredSubdomain] {
707 let existingHosts = Set(subdomains.map { $0.hostname.lowercased() })
708 return subdomains + extendedSubdomains.filter { !existingHosts.contains($0.hostname.lowercased()) }
709 }
710
711 var summaryFields: [SummaryFieldViewData] {
712 Self.summaryFields(from: currentSnapshot)
713 }
714
715 var domainRows: [InfoRowViewData] {
716 Self.domainRows(from: currentSnapshot)
717 }
718
719 var dnsRows: [DNSRecordSectionViewData] {
720 Self.dnsRows(from: currentSnapshot)
721 }
722
723 var suggestionRows: [DomainSuggestionViewData] {
724 Self.suggestionRows(from: currentSnapshot)
725 }
726
727 var dnssecLabel: String? {
728 Self.dnssecLabel(from: currentSnapshot)
729 }
730
731 var ptrMessage: SectionMessageViewData? {
732 Self.ptrMessage(from: currentSnapshot)
733 }
734
735 var webCertificateRows: [InfoRowViewData] {
736 Self.webCertificateRows(from: currentSnapshot)
737 }
738
739 var webResponseRows: [InfoRowViewData] {
740 Self.webResponseRows(from: currentSnapshot)
741 }
742
743 var redirectRows: [RedirectHopViewData] {
744 Self.redirectRows(from: currentSnapshot)
745 }
746
747 var emailRows: [EmailRowViewData] {
748 Self.emailRows(from: currentSnapshot)
749 }
750
751 var ownershipRows: [InfoRowViewData] {
752 Self.ownershipRows(from: currentSnapshot)
753 }
754
755 var subdomainRows: [SubdomainRowViewData] {
756 Self.subdomainRows(from: combinedSubdomains)
757 }
758
759 var reachabilityRows: [ReachabilityRowViewData] {
760 Self.reachabilityRows(from: currentSnapshot)
761 }
762
763 var locationRows: [InfoRowViewData] {
764 Self.locationRows(from: currentSnapshot)
765 }
766
767 var standardPortRows: [PortScanRowViewData] {
768 Self.portRows(from: currentSnapshot, kind: .standard)
769 }
770
771 var customPortRows: [PortScanRowViewData] {
772 Self.portRows(from: currentSnapshot, kind: .custom)
773 }
774
775 var combinedPortScanError: String? {
776 [portScanError, customPortScanError].compactMap { $0 }.joined(separator: "\n").nilIfEmpty
777 }
778
779 func toggleSavedDomain() {
780 if isCurrentDomainSaved {
781 savedDomains.removeAll { $0.lowercased() == searchedDomain.lowercased() }
782 } else {
783 savedDomains.append(searchedDomain)
784 }
785 DomainDataPortabilityService.saveSavedDomains(savedDomains)
786 CloudSyncService.shared.markAppSettingsChanged()
787 refreshDataLifecycleSummary()
788 }
789
790 func removeSavedDomains(at offsets: IndexSet) {
791 savedDomains.remove(atOffsets: offsets)
792 DomainDataPortabilityService.saveSavedDomains(savedDomains)
793 CloudSyncService.shared.markAppSettingsChanged()
794 refreshDataLifecycleSummary()
795 }
796
797 @discardableResult
798 func trackCurrentDomain() -> Bool {
799 guard !searchedDomain.isEmpty else { return false }
800 return trackDomain(domain: searchedDomain, availabilityStatus: availabilityResult?.status)
801 }
802
803 @discardableResult
804 func trackDomain(domain: String, availabilityStatus: DomainAvailabilityStatus?) -> Bool {
805 let normalizedDomain = normalizedDomain(domain)
806 guard !normalizedDomain.isEmpty else { return false }
807
808 if trackedDomain(for: normalizedDomain) != nil {
809 return true
810 }
811
812 guard PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else {
813 upgradePrompt = FeatureAccessService.upgradePromptForTrackedDomains(currentCount: trackedDomains.count)
814 return false
815 }
816
817 trackedDomains.insert(
818 TrackedDomain(
819 domain: normalizedDomain,
820 createdAt: Date(),
821 updatedAt: Date(),
822 lastKnownAvailability: availabilityStatus,
823 collaboration: CollaborationMetadata(
824 scope: .privateDatabase,
825 ownership: .owner,
826 permission: .editable
827 )
828 ),
829 at: 0
830 )
831 persistTrackedDomains()
832 sanitizeMonitoringSelection()
833 linkTrackedDomainHistory(for: normalizedDomain)
834 return true
835 }
836
837 func refreshTrackedDomain(_ trackedDomain: TrackedDomain) {
838 refreshingTrackedDomainID = trackedDomain.id
839 domain = trackedDomain.domain
840 Task { [weak self] in
841 guard let self else { return }
842 self.notificationsAuthorized = await LocalNotificationService.shared.requestAuthorizationIfNeeded()
843 }
844 run()
845 }
846
847 func rerunInspection(for trackedDomain: TrackedDomain) {
848 rerunInspection(for: trackedDomain, useSnapshotResolver: false)
849 }
850
851 func deleteTrackedDomains(at offsets: IndexSet) {
852 let removedDomains = offsets.map { sortedTrackedDomains[$0] }
853 let ids = removedDomains.map(\.id)
854 CloudSyncService.shared.recordTrackedDomainReset(removedDomains)
855 trackedDomains.removeAll { ids.contains($0.id) }
856 history.indices.forEach { index in
857 if let trackedDomainID = history[index].trackedDomainID, ids.contains(trackedDomainID) {
858 history[index].trackedDomainID = nil
859 }
860 }
861 persistTrackedDomains()
862 persistHistory()
863 sanitizeMonitoringSelection()
864 }
865
866 func deleteTrackedDomain(_ trackedDomain: TrackedDomain) {
867 guard canDelete(trackedDomain) else { return }
868 CloudSyncService.shared.recordTrackedDomainDeletion(trackedDomain)
869 trackedDomains.removeAll { $0.id == trackedDomain.id }
870 history.indices.forEach { index in
871 if history[index].trackedDomainID == trackedDomain.id {
872 history[index].trackedDomainID = nil
873 }
874 }
875 persistTrackedDomains()
876 persistHistory()
877 sanitizeMonitoringSelection()
878 }
879
880 func togglePinned(for trackedDomain: TrackedDomain) {
881 guard canEdit(trackedDomain) else { return }
882 guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
883 trackedDomains[index].isPinned.toggle()
884 trackedDomains[index].updatedAt = Date()
885 persistTrackedDomains()
886 }
887
888 func updateNote(_ note: String, for trackedDomain: TrackedDomain) {
889 guard canEdit(trackedDomain) else { return }
890 guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
891 trackedDomains[index].note = note.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
892 trackedDomains[index].updatedAt = Date()
893 CloudSyncService.shared.markNoteChanged(for: trackedDomains[index].domain, updatedAt: trackedDomains[index].updatedAt)
894 persistTrackedDomains()
895 }
896
897 func updateTags(_ tags: [String], for trackedDomain: TrackedDomain) {
898 guard canEdit(trackedDomain) else { return }
899 guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
900 let normalized = Self.normalizedTags(tags)
901 trackedDomains[index].tags = normalized
902 trackedDomains[index].updatedAt = Date()
903 persistTrackedDomains()
904 }
905
906 /// All tags currently in use across the watchlist, sorted for stable display.
907 var allWatchlistTags: [String] {
908 Array(Set(trackedDomains.flatMap(\.tags))).sorted()
909 }
910
911 func saveCurrentWatchlistView(name: String) {
912 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
913 guard !trimmedName.isEmpty else { return }
914 let view = WatchlistSavedView(
915 name: trimmedName,
916 tag: watchlistTagFilter,
917 filter: watchlistFilter,
918 sort: watchlistSortOption
919 )
920 watchlistSavedViews.append(view)
921 persistWatchlistSavedViews()
922 }
923
924 func applyWatchlistSavedView(_ view: WatchlistSavedView) {
925 watchlistTagFilter = view.tag
926 watchlistFilter = view.filter
927 watchlistSortOption = view.sort
928 }
929
930 func deleteWatchlistSavedViews(at offsets: IndexSet) {
931 watchlistSavedViews.remove(atOffsets: offsets)
932 persistWatchlistSavedViews()
933 }
934
935 private static let watchlistSavedViewsKey = "watchlistSavedViews"
936
937 private static func loadWatchlistSavedViews() -> [WatchlistSavedView] {
938 guard let data = UserDefaults.standard.data(forKey: watchlistSavedViewsKey),
939 let views = try? JSONDecoder().decode([WatchlistSavedView].self, from: data)
940 else { return [] }
941 return views
942 }
943
944 private func persistWatchlistSavedViews() {
945 guard let data = try? JSONEncoder().encode(watchlistSavedViews) else { return }
946 UserDefaults.standard.set(data, forKey: Self.watchlistSavedViewsKey)
947 }
948
949 private static func normalizedTags(_ tags: [String]) -> [String] {
950 var seen = Set<String>()
951 var normalized: [String] = []
952 for tag in tags {
953 let trimmed = tag.trimmingCharacters(in: .whitespacesAndNewlines)
954 guard !trimmed.isEmpty, seen.insert(trimmed.lowercased()).inserted else { continue }
955 normalized.append(trimmed)
956 }
957 return normalized.sorted()
958 }
959
960 func removeHistoryEntries(at offsets: IndexSet) {
961 history.remove(atOffsets: offsets)
962 persistHistory()
963 }
964
965 func removeHistoryEntries(withIDs ids: [UUID]) {
966 history.removeAll { ids.contains($0.id) }
967 persistHistory()
968 }
969
970 func clearHistory() {
971 history.removeAll()
972 persistHistory()
973 clearMonitoringLogs()
974 refreshDataLifecycleSummary()
975 }
976
977 func clearLookupCache() {
978 Task {
979 await LookupRuntime.shared.clearCache()
980 }
981 }
982
983 func clearWorkflows() {
984 CloudSyncService.shared.recordWorkflowReset(workflows)
985 workflows.removeAll()
986 latestWorkflowRunSummary = nil
987 persistWorkflows()
988 refreshDataLifecycleSummary()
989 }
990
991 func clearTrackedDomains() {
992 CloudSyncService.shared.recordTrackedDomainReset(trackedDomains)
993 trackedDomains.removeAll()
994 refreshingTrackedDomainID = nil
995 persistTrackedDomains()
996 sanitizeMonitoringSelection()
997 clearMonitoringLogs()
998 refreshDataLifecycleSummary()
999 }
1000
1001 func clearRecentSearches() {
1002 recentSearches.removeAll()
1003 DomainDataPortabilityService.saveRecentSearches([])
1004 CloudSyncService.shared.markAppSettingsChanged()
1005 refreshDataLifecycleSummary()
1006 }
1007
1008 func refreshMonitoringState() {
1009 DataMigrationService.migrateIfNeeded()
1010 trackedDomains = Self.loadTrackedDomains()
1011 history = Self.loadHistoryEntries()
1012 monitoringSettings = MonitoringStorage.sanitizeSettings(
1013 MonitoringStorage.loadSettings(),
1014 trackedDomains: trackedDomains
1015 )
1016 monitoringLogs = MonitoringStorage.loadLogs()
1017 persistMonitoringSettings()
1018 refreshDataLifecycleSummary()
1019 }
1020
1021 func refreshDataLifecycleSummary() {
1022 dataLifecycleSummary = DomainDataPortabilityService.lifecycleSummary()
1023 }
1024
1025 func refreshPersistedData() {
1026 recentSearches = DomainDataPortabilityService.loadRecentSearches()
1027 savedDomains = DomainDataPortabilityService.loadSavedDomains()
1028 trackedDomains = Self.loadTrackedDomains()
1029 history = Self.loadHistoryEntries()
1030 auditSessions = DomainDataPortabilityService.loadAuditSessions()
1031 workflows = Self.loadWorkflows()
1032 monitoringSettings = MonitoringStorage.sanitizeSettings(
1033 MonitoringStorage.loadSettings(),
1034 trackedDomains: trackedDomains
1035 )
1036 monitoringLogs = MonitoringStorage.loadLogs()
1037 refreshDataLifecycleSummary()
1038 }
1039
1040 func applyLocalDataReset() async {
1041 domain = ""
1042 bulkInput = ""
1043 reset()
1044
1045 recentSearches = []
1046 savedDomains = []
1047 trackedDomains = []
1048 history = []
1049 auditSessions = []
1050 workflows = []
1051 historySearchText = ""
1052 historyDateFilter = .all
1053 historyChangeFilter = .all
1054 historySortOption = .newest
1055 timelineGrouping = .relativeDay
1056 timelineDomainFilter = ""
1057 watchlistSearchText = ""
1058 watchlistFilter = .all
1059 watchlistSortOption = .pinned
1060 dashboardSearchText = ""
1061 dashboardFilter = .all
1062 monitoringSettings = MonitoringSettings()
1063 monitoringLogs = []
1064 notificationsAuthorized = false
1065 monitoringRunInProgress = false
1066 monitoringStatusMessage = nil
1067 portabilityStatusMessage = "All local data removed."
1068 upgradePrompt = nil
1069 isPaywallPresented = false
1070 selectedSnapshotIDs.removeAll()
1071 activeDomainDiff = nil
1072 activeDiffChangeIndex = 0
1073 latestBatchSweepSummary = nil
1074 latestWorkflowRunSummary = nil
1075 historyAutoPruneOption = Self.loadHistoryAutoPruneOption()
1076 refreshDataLifecycleSummary()
1077 await refreshUsageCredits()
1078 await refreshMonitoringAuthorizationStatus()
1079 }
1080
1081 func refreshMonitoringAuthorizationStatus() async {
1082 let settings = await UNUserNotificationCenter.current().notificationSettings()
1083 monitoringNotificationStatus = settings.authorizationStatus
1084 }
1085
1086 func setMonitoringEnabled(_ isEnabled: Bool) {
1087 guard !isEnabled || FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
1088 monitoringSettings.isEnabled = false
1089 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
1090 return
1091 }
1092
1093 monitoringSettings.isEnabled = isEnabled
1094 persistMonitoringSettings(localActivationConfirmed: isEnabled)
1095 monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
1096 }
1097
1098 func setMonitoringScope(_ scope: MonitoringScope) {
1099 monitoringSettings.scope = scope
1100 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
1101 }
1102
1103 func setMonitoringBaseInterval(_ baseInterval: MonitoringBaseInterval) {
1104 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
1105 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
1106 return
1107 }
1108 monitoringSettings.baseInterval = baseInterval.interval
1109 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
1110 monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
1111 }
1112
1113 func setMonitoringAdaptiveEnabled(_ isEnabled: Bool) {
1114 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
1115 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
1116 return
1117 }
1118 monitoringSettings.adaptiveEnabled = isEnabled
1119 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
1120 monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
1121 }
1122
1123 func setMonitoringSensitivity(_ sensitivity: MonitoringSensitivity) {
1124 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
1125 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
1126 return
1127 }
1128 monitoringSettings.sensitivity = sensitivity
1129 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
1130 monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
1131 }
1132
1133 func setMonitoringQuietHours(startHour: Int, endHour: Int, isEnabled: Bool) {
1134 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
1135 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
1136 return
1137 }
1138 monitoringSettings.quietHours = isEnabled ? QuietHours(startHour: startHour, endHour: endHour) : nil
1139 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
1140 }
1141
1142 func setMonitoringAlertFilter(_ filter: MonitoringAlertFilter) {
1143 guard FeatureAccessService.hasAccess(to: .localAlerts) else {
1144 monitoringSettings.alertsEnabled = false
1145 upgradePrompt = FeatureAccessService.upgradePrompt(for: .localAlerts)
1146 return
1147 }
1148 monitoringSettings.alertFilter = filter
1149 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
1150 }
1151
1152 func setMonitoringAlertsEnabled(_ isEnabled: Bool) {
1153 guard !isEnabled || FeatureAccessService.hasAccess(to: .localAlerts) else {
1154 monitoringSettings.alertsEnabled = false
1155 upgradePrompt = FeatureAccessService.upgradePrompt(for: .localAlerts)
1156 return
1157 }
1158 monitoringSettings.alertsEnabled = isEnabled
1159 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
1160 }
1161
1162 func setMonitoringSelection(for trackedDomain: TrackedDomain, isSelected: Bool) {
1163 if isSelected {
1164 if !monitoringSettings.selectedDomainIDs.contains(trackedDomain.id) {
1165 monitoringSettings.selectedDomainIDs.append(trackedDomain.id)
1166 }
1167 } else {
1168 monitoringSettings.selectedDomainIDs.removeAll { $0 == trackedDomain.id }
1169 }
1170 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
1171 }
1172
1173 func toggleMonitoring(for trackedDomain: TrackedDomain) {
1174 guard canEdit(trackedDomain) else { return }
1175 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
1176 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
1177 return
1178 }
1179 guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
1180 trackedDomains[index].monitoringEnabled.toggle()
1181 trackedDomains[index].updatedAt = Date()
1182 MonitoringStorage.saveTrackedDomains(trackedDomains)
1183 CloudSyncService.shared.scheduleSyncIfNeeded()
1184 sanitizeMonitoringSelection()
1185 }
1186
1187 func requestMonitoringNotificationAuthorization() async {
1188 let granted = await LocalNotificationService.shared.requestAuthorizationIfNeeded()
1189 monitoringSettings.alertsEnabled = granted
1190 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
1191 await refreshMonitoringAuthorizationStatus()
1192 }
1193
1194 func runMonitoringNow() {
1195 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
1196 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
1197 return
1198 }
1199 guard !monitoringRunInProgress else { return }
1200
1201 monitoringRunInProgress = true
1202 monitoringStatusMessage = nil
1203
1204 Task { [weak self] in
1205 guard let self else { return }
1206 let outcome = await DomainMonitoringService.shared.performMonitoring(
1207 trigger: .manual,
1208 requireEnabledSetting: false
1209 )
1210 await MainActor.run {
1211 self.refreshMonitoringState()
1212 self.monitoringRunInProgress = false
1213 self.monitoringStatusMessage = outcome.message
1214 }
1215 }
1216 }
1217
1218 func monitoringIntervalLabel(for trackedDomain: TrackedDomain) -> String {
1219 let state = DomainMonitoringScheduler.normalizedMonitoringState(for: trackedDomain, settings: monitoringSettings)
1220 return Self.intervalLabel(for: state.currentInterval)
1221 }
1222
1223 func monitoringStatusLabel(for trackedDomain: TrackedDomain) -> String {
1224 let state = trackedDomain.monitoringState
1225 if let lastChangeDate = state.lastChangeDate,
1226 Date().timeIntervalSince(lastChangeDate) <= 6 * 60 * 60 {
1227 return "Recently Changed"
1228 }
1229 if state.consecutiveStableChecks >= 2 {
1230 return "Stable"
1231 }
1232 return trackedDomain.monitoringEnabled ? "Active" : "Paused"
1233 }
1234
1235 private static func intervalLabel(for interval: TimeInterval) -> String {
1236 let formatter = DateComponentsFormatter()
1237 formatter.allowedUnits = interval < 3600 ? [.minute] : [.hour, .minute]
1238 formatter.unitsStyle = .full
1239 return formatter.string(from: interval) ?? "Unknown"
1240 }
1241
1242 func rerunLookup(from entry: HistoryEntry, useSnapshotResolver: Bool) {
1243 if useSnapshotResolver {
1244 UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey)
1245 }
1246 domain = entry.domain
1247 run()
1248 rerunNavigationToken = UUID()
1249 }
1250
1251 func rerunInspection(for trackedDomain: TrackedDomain, useSnapshotResolver: Bool) {
1252 if useSnapshotResolver, let snapshot = latestSnapshot(for: trackedDomain) {
1253 UserDefaults.standard.set(snapshot.resolverURLString, forKey: DNSResolverOption.userDefaultsKey)
1254 }
1255 domain = trackedDomain.domain
1256 run()
1257 rerunNavigationToken = UUID()
1258 }
1259
1260 func openInspection(for domain: String) {
1261 self.domain = normalizedDomain(domain)
1262 run()
1263 rerunNavigationToken = UUID()
1264 }
1265
1266 func reset() {
1267 lookupTask?.cancel()
1268 customPortScanTask?.cancel()
1269 batchTask?.cancel()
1270 hasRun = false
1271 searchedDomain = ""
1272 lastLookupDurationMs = nil
1273 currentDiffSections = []
1274 currentChangeSummary = nil
1275 ownershipDiff = []
1276 currentReport = nil
1277 refreshingTrackedDomainID = nil
1278 clearBatchState()
1279 clearLookupState()
1280 }
1281
1282 func clearPresentedResults() {
1283 lookupTask?.cancel()
1284 customPortScanTask?.cancel()
1285 batchTask?.cancel()
1286 hasRun = false
1287 searchedDomain = ""
1288 lastLookupDurationMs = nil
1289 currentDiffSections = []
1290 currentChangeSummary = nil
1291 ownershipDiff = []
1292 currentReport = nil
1293 refreshingTrackedDomainID = nil
1294 clearBatchState()
1295 clearLookupState()
1296 }
1297
1298 func run() {
1299 let target = trimmedDomain
1300 guard !target.isEmpty else { return }
1301 clearBatchState()
1302 let lookupID = beginLookup(for: target)
1303
1304 lookupTask = Task { [weak self] in
1305 guard let self else { return }
1306 _ = await self.performLookup(domain: target, lookupID: lookupID)
1307 }
1308 }
1309
1310 func runBulkLookup() {
1311 let domains = parsedDomains(from: bulkInput)
1312 guard !domains.isEmpty else { return }
1313 guard FeatureAccessService.canRunBatch(domainCount: domains.count) else {
1314 upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: domains.count)
1315 return
1316 }
1317 startBatchLookup(domains: domains, source: .manual)
1318 }
1319
1320 func refreshAllTrackedDomains() {
1321 guard FeatureAccessService.canRunBatch(domainCount: sortedTrackedDomains.count) else {
1322 upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: sortedTrackedDomains.count)
1323 return
1324 }
1325 startBatchLookup(domains: sortedTrackedDomains.map(\.domain), source: .watchlistRefresh)
1326 }
1327
1328 func cancelBatchLookup() {
1329 batchTask?.cancel()
1330 batchLookupRunning = false
1331 batchCurrentDomain = nil
1332 activeBatchDomains = []
1333 refreshingTrackedDomainID = nil
1334
1335 for index in batchResults.indices where batchResults[index].status == .pending || batchResults[index].status == .running {
1336 batchResults[index] = BatchLookupResult(
1337 id: batchResults[index].id,
1338 domain: batchResults[index].domain,
1339 historyEntryID: batchResults[index].historyEntryID,
1340 availability: batchResults[index].availability,
1341 primaryIP: batchResults[index].primaryIP,
1342 quickStatus: "Cancelled",
1343 summaryMessage: batchResults[index].summaryMessage,
1344 changeSeverity: batchResults[index].changeSeverity,
1345 changeClassification: batchResults[index].changeClassification,
1346 certificateWarningLevel: batchResults[index].certificateWarningLevel,
1347 riskScore: batchResults[index].riskScore,
1348 riskLevel: batchResults[index].riskLevel,
1349 timestamp: Date(),
1350 status: .failed,
1351 errorMessage: "Lookup cancelled"
1352 )
1353 }
1354 }
1355
1356 func workflow(withID id: UUID) -> DomainWorkflow? {
1357 workflows.first(where: { $0.id == id })
1358 }
1359
1360 func workflowsContaining(domain: String) -> [DomainWorkflow] {
1361 let normalized = normalizedDomain(domain)
1362 guard !normalized.isEmpty else { return [] }
1363 return workflows.filter { workflow in
1364 workflow.domains.contains(where: { $0.caseInsensitiveCompare(normalized) == .orderedSame })
1365 }
1366 }
1367
1368 func canEdit(_ trackedDomain: TrackedDomain) -> Bool {
1369 trackedDomain.collaboration?.canEdit ?? true
1370 }
1371
1372 func canDelete(_ trackedDomain: TrackedDomain) -> Bool {
1373 trackedDomain.collaboration?.isOwner ?? true
1374 }
1375
1376 func collaborationLabel(for trackedDomain: TrackedDomain) -> String? {
1377 guard let collaboration = trackedDomain.collaboration, collaboration.isShared else { return nil }
1378 return "\(collaboration.ownership.title) • \(collaboration.permission.title)"
1379 }
1380
1381 func canEdit(_ workflow: DomainWorkflow) -> Bool {
1382 workflow.collaboration?.canEdit ?? true
1383 }
1384
1385 func canDelete(_ workflow: DomainWorkflow) -> Bool {
1386 workflow.collaboration?.isOwner ?? true
1387 }
1388
1389 func collaborationLabel(for workflow: DomainWorkflow) -> String? {
1390 guard let collaboration = workflow.collaboration, collaboration.isShared else { return nil }
1391 return "\(collaboration.ownership.title) • \(collaboration.permission.title)"
1392 }
1393
1394 @discardableResult
1395 func createWorkflow(name: String, domains: [String], notes: String? = nil) -> DomainWorkflow? {
1396 let normalizedDomains = normalizedDomains(domains)
1397 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
1398 guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return nil }
1399 guard FeatureAccessService.canCreateWorkflow(currentCount: workflows.count) else {
1400 upgradePrompt = FeatureAccessService.upgradePromptForWorkflows(currentCount: workflows.count)
1401 return nil
1402 }
1403
1404 let workflow = DomainWorkflow(
1405 name: trimmedName,
1406 domains: normalizedDomains,
1407 createdAt: Date(),
1408 updatedAt: Date(),
1409 notes: notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
1410 collaboration: CollaborationMetadata(
1411 scope: .privateDatabase,
1412 ownership: .owner,
1413 permission: .editable
1414 )
1415 )
1416 workflows.insert(workflow, at: 0)
1417 persistWorkflows()
1418 return workflow
1419 }
1420
1421 func updateWorkflow(_ workflow: DomainWorkflow, name: String, domains: [String], notes: String?) {
1422 guard canEdit(workflow) else { return }
1423 guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
1424 let normalizedDomains = normalizedDomains(domains)
1425 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
1426 guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return }
1427
1428 workflows[index].name = trimmedName
1429 workflows[index].domains = normalizedDomains
1430 workflows[index].notes = notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
1431 workflows[index].updatedAt = Date()
1432 persistWorkflows()
1433 }
1434
1435 func deleteWorkflow(_ workflow: DomainWorkflow) {
1436 guard canDelete(workflow) else { return }
1437 CloudSyncService.shared.recordWorkflowDeletion(workflow)
1438 workflows.removeAll { $0.id == workflow.id }
1439 if latestWorkflowRunSummary?.workflowID == workflow.id {
1440 latestWorkflowRunSummary = nil
1441 }
1442 if activeWorkflowRunID == workflow.id {
1443 activeWorkflowRunID = nil
1444 activeWorkflowRunName = nil
1445 }
1446 persistWorkflows()
1447 }
1448
1449 func addDomains(_ domains: [String], to workflow: DomainWorkflow) {
1450 guard canEdit(workflow) else { return }
1451 guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
1452 let mergedDomains = normalizedDomains(workflows[index].domains + domains)
1453 guard mergedDomains != workflows[index].domains else { return }
1454 workflows[index].domains = mergedDomains
1455 workflows[index].updatedAt = Date()
1456 persistWorkflows()
1457 }
1458
1459 func removeWorkflowDomains(at offsets: IndexSet, from workflow: DomainWorkflow) {
1460 guard canEdit(workflow) else { return }
1461 guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
1462 workflows[index].domains.remove(atOffsets: offsets)
1463 workflows[index].updatedAt = Date()
1464 persistWorkflows()
1465 }
1466
1467 func moveWorkflowDomains(from offsets: IndexSet, to destination: Int, in workflow: DomainWorkflow) {
1468 guard canEdit(workflow) else { return }
1469 guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
1470 workflows[index].domains.move(fromOffsets: offsets, toOffset: destination)
1471 workflows[index].updatedAt = Date()
1472 persistWorkflows()
1473 }
1474
1475 func runWorkflow(_ workflow: DomainWorkflow) {
1476 guard !workflow.domains.isEmpty else { return }
1477 guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else {
1478 upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: workflow.domains.count)
1479 return
1480 }
1481 startBatchLookup(domains: workflow.domains, source: .workflow, workflow: workflow)
1482 }
1483
1484 func rerunCurrentDomain(in workflow: DomainWorkflow) {
1485 guard workflow.domains.contains(where: { $0.caseInsensitiveCompare(searchedDomain) == .orderedSame }) else {
1486 return
1487 }
1488 runWorkflow(workflow)
1489 }
1490
1491 func refreshWorkflowList() async {
1492 workflows = Self.loadWorkflows()
1493 await Task.yield()
1494 }
1495
1496 func runCustomPortScan(ports: [UInt16]) async {
1497 guard !searchedDomain.isEmpty else {
1498 customPortScanError = "Run a domain lookup first"
1499 return
1500 }
1501
1502 guard !ports.isEmpty else {
1503 customPortScanError = "Enter at least one valid port"
1504 customPortResults = []
1505 return
1506 }
1507
1508 customPortScanTask?.cancel()
1509 let domain = searchedDomain
1510 let lookupID = activeLookupID
1511
1512 customPortScanLoading = true
1513 customPortScanError = nil
1514 customPortResults = []
1515
1516 customPortScanTask = Task { [weak self] in
1517 guard let self else { return }
1518 let result = await PortScanService.scanPorts(domain: domain, ports: ports, timeout: 3.0)
1519 guard !Task.isCancelled, self.isCurrentLookup(lookupID) else { return }
1520 self.applyCustomPortResult(result)
1521 }
1522 }
1523
1524 func exportText() -> String {
1525 guard let currentReport else { return "No results available." }
1526 return DomainReportExporter.text(for: currentReport)
1527 }
1528
1529 func exportCSV() -> String {
1530 guard let currentReport else { return DomainReportExporter.csv(for: []) }
1531 return DomainReportExporter.csv(for: [currentReport])
1532 }
1533
1534 func exportJSONData() -> Data? {
1535 guard let currentReport else { return nil }
1536 return try? DomainReportExporter.data(for: currentReport, format: .json)
1537 }
1538
1539 func exportJSONString() -> String? {
1540 guard let data = exportJSONData() else { return nil }
1541 return String(data: data, encoding: .utf8)
1542 }
1543
1544 func audits(for domain: String) -> [AuditSession] {
1545 auditSessions
1546 .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
1547 .sorted { $0.createdAt > $1.createdAt }
1548 }
1549
1550 func auditSession(withID id: UUID) -> AuditSession? {
1551 auditSessions.first(where: { $0.id == id })
1552 }
1553
1554 func auditTimeline(for domain: String) -> [AuditTimelinePoint] {
1555 let sessions = audits(for: domain).sorted { $0.createdAt > $1.createdAt }
1556 return sessions.map { session in
1557 let repeatedIssues = sessions
1558 .filter { $0.id != session.id }
1559 .flatMap(\.findings)
1560 .map { $0.title.lowercased() }
1561 let repeatedIssueCount = session.findings.filter {
1562 repeatedIssues.contains($0.title.lowercased())
1563 }.count
1564
1565 return AuditTimelinePoint(
1566 id: session.id,
1567 sessionID: session.id,
1568 domain: session.domain,
1569 createdAt: session.createdAt,
1570 status: session.status,
1571 findingCount: session.findings.count,
1572 openHighSeverityCount: session.findings.filter { $0.severity == .high && $0.status != .resolved }.count,
1573 repeatedIssueCount: repeatedIssueCount
1574 )
1575 }
1576 }
1577
1578 @discardableResult
1579 func startAudit(for domain: String, reviewer: String? = nil) async -> AuditSession? {
1580 let normalizedDomain = domain
1581 .trimmingCharacters(in: .whitespacesAndNewlines)
1582 .replacingOccurrences(of: "https://", with: "")
1583 .replacingOccurrences(of: "http://", with: "")
1584 .components(separatedBy: "/").first?
1585 .lowercased() ?? domain.lowercased()
1586 guard !normalizedDomain.isEmpty else { return nil }
1587
1588 let previous = historyEntries(for: normalizedDomain).first?.snapshot
1589 let snapshot = await inspectionService.inspectSnapshot(domain: normalizedDomain, previousSnapshot: previous)
1590 guard let entry = saveHistoryEntry(
1591 from: snapshot,
1592 replaceLatest: false,
1593 updateCurrentState: searchedDomain.caseInsensitiveCompare(normalizedDomain) == .orderedSame
1594 ) else {
1595 return nil
1596 }
1597
1598 let report = report(for: entry)
1599 let historicalContext = Array(historyEntries(for: normalizedDomain).dropFirst().prefix(6)).map(\.snapshotSummary)
1600 let screenshots = snapshotEvidenceAssets(from: report)
1601 let session = AuditSession(
1602 domain: normalizedDomain,
1603 createdAt: entry.timestamp,
1604 reviewer: (reviewer?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty) ?? Self.defaultAuditReviewer,
1605 status: .draft,
1606 evidence: AuditEvidenceSnapshot(
1607 capturedAt: entry.timestamp,
1608 lookup: entry,
1609 report: report,
1610 historicalContext: historicalContext,
1611 screenshots: screenshots
1612 ),
1613 findings: [],
1614 notes: "",
1615 checklist: AuditChecklistArea.defaultItems
1616 )
1617
1618 auditSessions.insert(session, at: 0)
1619 persistAuditSessions()
1620 return session
1621 }
1622
1623 func updateAuditStatus(_ status: AuditStatus, sessionID: UUID) {
1624 guard let index = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return }
1625 auditSessions[index].status = status
1626 persistAuditSessions()
1627 }
1628
1629 func updateAuditNotes(_ notes: String, sessionID: UUID) {
1630 guard let index = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return }
1631 auditSessions[index].notes = notes.trimmingCharacters(in: .whitespacesAndNewlines)
1632 persistAuditSessions()
1633 }
1634
1635 func toggleAuditChecklistItem(sessionID: UUID, itemID: UUID) {
1636 guard let sessionIndex = auditSessions.firstIndex(where: { $0.id == sessionID }),
1637 let itemIndex = auditSessions[sessionIndex].checklist.firstIndex(where: { $0.id == itemID }) else {
1638 return
1639 }
1640
1641 auditSessions[sessionIndex].checklist[itemIndex].isComplete.toggle()
1642 auditSessions[sessionIndex].checklist[itemIndex].completedAt = auditSessions[sessionIndex].checklist[itemIndex].isComplete ? Date() : nil
1643 persistAuditSessions()
1644 }
1645
1646 func addAuditFinding(
1647 sessionID: UUID,
1648 title: String,
1649 severity: AuditFindingSeverity,
1650 summary: String,
1651 evidenceReferences: [String],
1652 notes: String,
1653 checklistAreas: [AuditChecklistArea]
1654 ) {
1655 guard let index = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return }
1656 let finding = AuditFinding(
1657 title: title,
1658 severity: severity,
1659 summary: summary,
1660 evidenceReferences: evidenceReferences,
1661 notes: notes,
1662 status: .open,
1663 checklistAreas: checklistAreas
1664 )
1665 auditSessions[index].findings.insert(finding, at: 0)
1666 persistAuditSessions()
1667 }
1668
1669 func updateAuditFinding(_ finding: AuditFinding, sessionID: UUID) {
1670 guard let sessionIndex = auditSessions.firstIndex(where: { $0.id == sessionID }),
1671 let findingIndex = auditSessions[sessionIndex].findings.firstIndex(where: { $0.id == finding.id }) else {
1672 return
1673 }
1674
1675 var updatedFinding = finding
1676 updatedFinding.updatedAt = Date()
1677 auditSessions[sessionIndex].findings[findingIndex] = updatedFinding
1678 persistAuditSessions()
1679 }
1680
1681 func removeAuditFindings(at offsets: IndexSet, sessionID: UUID) {
1682 guard let sessionIndex = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return }
1683 auditSessions[sessionIndex].findings.remove(atOffsets: offsets)
1684 persistAuditSessions()
1685 }
1686
1687 func exportAuditData(sessionID: UUID, format: AuditExportFormat) -> Data? {
1688 guard let session = auditSession(withID: sessionID) else { return nil }
1689 return try? AuditExporter.data(for: session, format: format)
1690 }
1691
1692 func loadOwnershipHistory() async {
1693 guard !searchedDomain.isEmpty else { return }
1694 guard DataAccessService.hasAccess(to: .ownershipHistory) else {
1695 upgradePrompt = FeatureAccessService.upgradePrompt(for: .ownershipHistory)
1696 return
1697 }
1698 guard ownershipHistory.isEmpty else { return }
1699
1700 ownershipHistoryLoading = true
1701 ownershipHistoryError = nil
1702
1703 let outcome = await ExternalDataService.shared.ownershipHistory(
1704 domain: searchedDomain,
1705 currentOwnership: ownershipResult,
1706 historyEntries: history
1707 )
1708
1709 switch outcome.value {
1710 case let .success(events):
1711 ownershipHistory = events
1712 ownershipHistoryError = nil
1713 case let .empty(message):
1714 ownershipHistory = []
1715 ownershipHistoryError = message
1716 case let .error(message):
1717 ownershipHistory = []
1718 ownershipHistoryError = conciseExternalMessage(message, fallback: "Ownership history unavailable")
1719 }
1720
1721 ownershipHistoryLoading = false
1722 _ = saveHistoryEntry(replaceLatest: true)
1723 }
1724
1725 func loadDNSHistory() async {
1726 guard !searchedDomain.isEmpty else { return }
1727 guard DataAccessService.hasAccess(to: .dnsHistory) else {
1728 upgradePrompt = FeatureAccessService.upgradePrompt(for: .dnsHistory)
1729 return
1730 }
1731 guard dnsHistory.isEmpty else { return }
1732
1733 dnsHistoryLoading = true
1734 dnsHistoryError = nil
1735
1736 let outcome = await ExternalDataService.shared.dnsHistory(
1737 domain: searchedDomain,
1738 dnsSections: dnsSections,
1739 historyEntries: history
1740 )
1741
1742 switch outcome.value {
1743 case let .success(events):
1744 dnsHistory = events
1745 dnsHistoryError = nil
1746 case let .empty(message):
1747 dnsHistory = []
1748 dnsHistoryError = message
1749 case let .error(message):
1750 dnsHistory = []
1751 dnsHistoryError = conciseExternalMessage(message, fallback: "DNS history unavailable")
1752 }
1753
1754 dnsHistoryLoading = false
1755 _ = saveHistoryEntry(replaceLatest: true)
1756 }
1757
1758 func loadExtendedSubdomains() async {
1759 guard !searchedDomain.isEmpty else { return }
1760 guard DataAccessService.hasAccess(to: .extendedSubdomains) else {
1761 upgradePrompt = FeatureAccessService.upgradePrompt(for: .extendedSubdomains)
1762 return
1763 }
1764 guard extendedSubdomains.isEmpty else { return }
1765
1766 extendedSubdomainsLoading = true
1767 extendedSubdomainsError = nil
1768
1769 let outcome = await ExternalDataService.shared.extendedSubdomains(
1770 domain: searchedDomain,
1771 existing: subdomains
1772 )
1773
1774 switch outcome.value {
1775 case let .success(results):
1776 extendedSubdomains = results
1777 extendedSubdomainsError = nil
1778 case let .empty(message):
1779 extendedSubdomains = []
1780 extendedSubdomainsError = message
1781 case let .error(message):
1782 extendedSubdomains = []
1783 extendedSubdomainsError = conciseExternalMessage(message, fallback: "Extended subdomains unavailable")
1784 }
1785
1786 extendedSubdomainsLoading = false
1787 _ = saveHistoryEntry(replaceLatest: true)
1788 }
1789
1790 func refreshUsageCredits() async {
1791 let statuses = await UsageCreditService.shared.allStatuses()
1792 usageCredits = Dictionary(uniqueKeysWithValues: statuses.map { ($0.feature, $0) })
1793 }
1794
1795 func exportBatchText() -> String {
1796 DomainReportExporter.batchText(
1797 for: currentBatchReports(),
1798 title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export"
1799 )
1800 }
1801
1802 func exportBatchCSV() -> String {
1803 DomainReportExporter.csv(for: currentBatchReports())
1804 }
1805
1806 func exportBatchJSONData() -> Data? {
1807 try? DomainReportExporter.data(
1808 for: currentBatchReports(),
1809 format: .json,
1810 title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export"
1811 )
1812 }
1813
1814 func exportTrackedDomainsCSV(domains: [TrackedDomain]) -> String {
1815 DomainReportExporter.csv(for: reports(for: domains))
1816 }
1817
1818 func exportTrackedDomainsText(domains: [TrackedDomain]) -> String {
1819 DomainReportExporter.batchText(
1820 for: reports(for: domains),
1821 title: "Tracked Domains Export"
1822 )
1823 }
1824
1825 func exportTrackedDomainsJSONData(domains: [TrackedDomain]) -> Data? {
1826 try? DomainReportExporter.data(
1827 for: reports(for: domains),
1828 format: .json,
1829 title: "Tracked Domains Export"
1830 )
1831 }
1832
1833 func exportTimelineText(domain: String, includeDiffSummary: Bool) -> String {
1834 DomainReportExporter.timelineText(
1835 for: timelineReports(for: domain),
1836 domain: domain,
1837 includeDiffSummary: includeDiffSummary
1838 )
1839 }
1840
1841 func exportTimelineJSONData(domain: String, includeDiffSummary: Bool) -> Data? {
1842 try? DomainReportExporter.timelineData(
1843 for: timelineReports(for: domain),
1844 domain: domain,
1845 includeDiffSummary: includeDiffSummary
1846 )
1847 }
1848
1849 func exportFullBackupData() -> Data? {
1850 try? DomainDataPortabilityService.backupData()
1851 }
1852
1853 func exportPortableTrackedDomainsJSONData() -> Data? {
1854 try? DomainDataPortabilityService.trackedDomainsExportData()
1855 }
1856
1857 func exportPortableTrackedDomainsCSV() -> String {
1858 DomainDataPortabilityService.trackedDomainsCSV()
1859 }
1860
1861 func exportPortableWorkflowsJSONData() -> Data? {
1862 try? DomainDataPortabilityService.workflowsExportData()
1863 }
1864
1865 func exportPortableWorkflowsCSV() -> String {
1866 DomainDataPortabilityService.workflowsCSV()
1867 }
1868
1869 func exportPortableHistoryJSONData() -> Data? {
1870 try? DomainDataPortabilityService.historyExportData()
1871 }
1872
1873 func prepareDataImport(
1874 data: Data,
1875 fileName: String,
1876 mode: DataPortabilityImportMode
1877 ) throws -> DataImportPreview {
1878 try DomainDataPortabilityService.prepareImport(data: data, fileName: fileName, mode: mode)
1879 }
1880
1881 func applyDataImport(_ preview: DataImportPreview, mode: DataPortabilityImportMode) throws -> DataImportResult {
1882 let result = try DomainDataPortabilityService.applyImport(preview, mode: mode)
1883 refreshPersistedData()
1884 portabilityStatusMessage = result.summary
1885 CloudSyncService.shared.markAppSettingsChanged()
1886 CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: monitoringSettings.isEnabled)
1887 CloudSyncService.shared.scheduleSyncIfNeeded(trigger: .import)
1888 return result
1889 }
1890
1891 func persistCurrentAppSettings(resolverURLString: String, appDensityRawValue: String) {
1892 DomainDataPortabilityService.saveAppSettings(
1893 AppSettingsSnapshot(
1894 recentSearches: recentSearches,
1895 savedDomains: savedDomains,
1896 resolverURLString: resolverURLString,
1897 appDensityRawValue: appDensityRawValue
1898 )
1899 )
1900 CloudSyncService.shared.markAppSettingsChanged()
1901 refreshDataLifecycleSummary()
1902 }
1903
1904 func exportWorkflowText(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
1905 let reports = workflowReports(from: summary, changedOnly: changedOnly)
1906 let base = DomainReportExporter.batchText(
1907 for: reports,
1908 title: "\(summary.workflowName) Workflow Export"
1909 )
1910 guard !summary.workflowInsights.isEmpty else { return base }
1911 let insightLines = summary.workflowInsights.map {
1912 "- \($0.description): \($0.domainsInvolved.joined(separator: ", "))"
1913 }
1914 return ([ "\(summary.workflowName) Workflow Insights", String(repeating: "-", count: 32) ] + insightLines + ["", base]).joined(separator: "\n")
1915 }
1916
1917 func exportWorkflowCSV(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
1918 DomainReportExporter.csv(
1919 for: workflowReports(from: summary, changedOnly: changedOnly),
1920 workflowInsights: summary.workflowInsights
1921 )
1922 }
1923
1924 func exportWorkflowJSONData(summary: WorkflowRunSummary, changedOnly: Bool) -> Data? {
1925 let payload = WorkflowExportPayload(
1926 workflowName: summary.workflowName,
1927 generatedAt: summary.generatedAt,
1928 workflowInsights: summary.workflowInsights,
1929 reports: workflowReports(from: summary, changedOnly: changedOnly)
1930 )
1931 let encoder = JSONEncoder()
1932 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
1933 encoder.dateEncodingStrategy = .iso8601
1934 return try? encoder.encode(payload)
1935 }
1936
1937 private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? {
1938 let lookupStartedAt = DomainDebugLog.signpostStart("DomainViewModel.performLookup", domain: domain)
1939 let previous = previousSnapshot(
1940 for: domain,
1941 trackedDomainID: currentTrackedDomain?.id,
1942 replacingLatest: false
1943 )
1944 let inspectedSnapshot = await inspectionService.inspectSnapshot(domain: domain, previousSnapshot: previous)
1945 DomainDebugLog.debug("DomainViewModel.performLookup inspectionReturned domain=\(domain)")
1946 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil }
1947
1948 let snapshot = Self.resolvedSnapshotAfterFallback(inspectedSnapshot, previousSnapshot: previous)
1949 let applyStartedAt = DomainDebugLog.signpostStart("DomainViewModel.applySnapshot", domain: domain)
1950 applySnapshot(snapshot)
1951 DomainDebugLog.signpostEnd("DomainViewModel.applySnapshot", start: applyStartedAt, domain: domain)
1952 lastLookupDurationMs = snapshot.totalLookupDurationMs
1953 refreshingTrackedDomainID = nil
1954
1955 if DataAccessService.hasAccess(to: .domainPricing), domainPricing == nil {
1956 DomainDebugLog.debug("DomainViewModel.performLookup loadingPricing domain=\(domain)")
1957 await refreshDomainPricing(for: snapshot.domain, persistAfterFetch: false)
1958 }
1959
1960 if DataAccessService.hasAccess(to: .reputation), reputation == nil {
1961 DomainDebugLog.debug("DomainViewModel.performLookup loadingReputation domain=\(domain)")
1962 await refreshReputation(for: snapshot.domain, persistAfterFetch: false)
1963 }
1964
1965 guard snapshot.statusMessage == nil else {
1966 return history.first(where: { $0.id == snapshot.historyEntryID })
1967 }
1968
1969 let saveStartedAt = DomainDebugLog.signpostStart("DomainViewModel.saveHistoryEntry", domain: domain)
1970 let entry = saveHistoryEntry(replaceLatest: false, reuseCurrentAnalysis: true)
1971 DomainDebugLog.signpostEnd("DomainViewModel.saveHistoryEntry", start: saveStartedAt, domain: domain)
1972 DomainDebugLog.signpostEnd("DomainViewModel.performLookup", start: lookupStartedAt, domain: domain)
1973 return entry
1974 }
1975
1976 private func applySnapshot(_ snapshot: LookupSnapshot) {
1977 currentHistoryEntryID = snapshot.historyEntryID
1978 currentSnapshotTimestamp = snapshot.timestamp
1979 currentResultSource = snapshot.resultSource
1980 currentCachedSections = snapshot.cachedSections
1981 currentStatusMessage = snapshot.statusMessage
1982 currentDiffSections = []
1983 ownershipDiff = []
1984 let reportStartedAt = DomainDebugLog.signpostStart("DomainViewModel.reportBuilder.build", domain: snapshot.domain)
1985 currentReport = reportBuilder.build(
1986 from: snapshot,
1987 previousSnapshot: previousSnapshot(
1988 for: snapshot.domain,
1989 trackedDomainID: snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id,
1990 replacingLatest: false
1991 ),
1992 historyEntries: historyEntries(for: snapshot.domain)
1993 )
1994 DomainDebugLog.signpostEnd("DomainViewModel.reportBuilder.build", start: reportStartedAt, domain: snapshot.domain)
1995 currentChangeSummary = currentReport?.changeSummary ?? snapshot.changeSummary
1996
1997 dnsSections = snapshot.dnsSections
1998 dnsError = snapshot.dnsError
1999 availabilityResult = snapshot.availabilityResult
2000 suggestions = snapshot.suggestions
2001 sslInfo = snapshot.sslInfo
2002 sslError = snapshot.sslError
2003 hstsPreloaded = snapshot.hstsPreloaded
2004 httpHeaders = snapshot.httpHeaders
2005 httpSecurityGrade = snapshot.httpSecurityGrade
2006 httpStatusCode = snapshot.httpStatusCode
2007 httpResponseTimeMs = snapshot.httpResponseTimeMs
2008 httpProtocol = snapshot.httpProtocol
2009 http3Advertised = snapshot.http3Advertised
2010 httpHeadersError = snapshot.httpHeadersError
2011 reachabilityResults = snapshot.reachabilityResults
2012 reachabilityError = snapshot.reachabilityError
2013 ipGeolocation = snapshot.ipGeolocation
2014 ipGeolocationError = snapshot.ipGeolocationError
2015 emailSecurity = snapshot.emailSecurity
2016 emailSecurityError = snapshot.emailSecurityError
2017 ownershipResult = snapshot.ownership
2018 ownershipError = snapshot.ownershipError
2019 ownershipHistory = snapshot.ownershipHistory
2020 ownershipHistoryError = snapshot.ownershipHistoryError
2021 ptrRecord = snapshot.ptrRecord
2022 ptrError = snapshot.ptrError
2023 redirectChain = snapshot.redirectChain
2024 redirectChainError = snapshot.redirectChainError
2025 subdomains = snapshot.subdomains
2026 subdomainsError = snapshot.subdomainsError
2027 extendedSubdomains = snapshot.extendedSubdomains
2028 extendedSubdomainsError = snapshot.extendedSubdomainsError
2029 dnsHistory = snapshot.dnsHistory
2030 dnsHistoryError = snapshot.dnsHistoryError
2031 domainPricing = snapshot.domainPricing
2032 domainPricingError = snapshot.domainPricingError
2033 portScanResults = snapshot.portScanResults.filter { $0.kind == .standard }
2034 customPortResults = snapshot.portScanResults.filter { $0.kind == .custom }
2035 portScanError = snapshot.portScanError
2036 customPortScanError = nil
2037
2038 dnsLoading = false
2039 availabilityLoading = false
2040 suggestionsLoading = false
2041 sslLoading = false
2042 hstsLoading = false
2043 httpHeadersLoading = false
2044 reachabilityLoading = false
2045 ipGeolocationLoading = false
2046 emailSecurityLoading = false
2047 ownershipLoading = false
2048 ownershipHistoryLoading = false
2049 ptrLoading = false
2050 redirectChainLoading = false
2051 subdomainsLoading = false
2052 extendedSubdomainsLoading = false
2053 dnsHistoryLoading = false
2054 domainPricingLoading = false
2055 portScanLoading = false
2056 customPortScanLoading = false
2057 }
2058
2059 private static func resolvedSnapshotAfterFallback(
2060 _ snapshot: LookupSnapshot,
2061 previousSnapshot: LookupSnapshot?
2062 ) -> LookupSnapshot {
2063 guard shouldFallbackToSnapshot(snapshot), let previousSnapshot else {
2064 return snapshot
2065 }
2066
2067 return LookupSnapshot(
2068 historyEntryID: previousSnapshot.historyEntryID,
2069 domain: previousSnapshot.domain,
2070 timestamp: previousSnapshot.timestamp,
2071 trackedDomainID: previousSnapshot.trackedDomainID,
2072 note: previousSnapshot.note,
2073 appVersion: previousSnapshot.appVersion,
2074 resolverDisplayName: previousSnapshot.resolverDisplayName,
2075 resolverURLString: previousSnapshot.resolverURLString,
2076 dataSources: previousSnapshot.dataSources,
2077 provenanceBySection: previousSnapshot.provenanceBySection,
2078 availabilityConfidence: previousSnapshot.availabilityConfidence,
2079 ownershipConfidence: previousSnapshot.ownershipConfidence,
2080 subdomainConfidence: previousSnapshot.subdomainConfidence,
2081 emailSecurityConfidence: previousSnapshot.emailSecurityConfidence,
2082 geolocationConfidence: previousSnapshot.geolocationConfidence,
2083 errorDetails: previousSnapshot.errorDetails,
2084 isPartialSnapshot: previousSnapshot.isPartialSnapshot,
2085 validationIssues: previousSnapshot.validationIssues,
2086 totalLookupDurationMs: previousSnapshot.totalLookupDurationMs,
2087 snapshotIndex: previousSnapshot.snapshotIndex,
2088 previousSnapshotID: previousSnapshot.previousSnapshotID,
2089 changeCount: previousSnapshot.changeCount,
2090 severitySummary: previousSnapshot.severitySummary,
2091 dnsSections: previousSnapshot.dnsSections,
2092 dnsError: previousSnapshot.dnsError,
2093 availabilityResult: previousSnapshot.availabilityResult,
2094 suggestions: previousSnapshot.suggestions,
2095 sslInfo: previousSnapshot.sslInfo,
2096 sslError: previousSnapshot.sslError,
2097 hstsPreloaded: previousSnapshot.hstsPreloaded,
2098 httpHeaders: previousSnapshot.httpHeaders,
2099 httpSecurityGrade: previousSnapshot.httpSecurityGrade,
2100 httpStatusCode: previousSnapshot.httpStatusCode,
2101 httpResponseTimeMs: previousSnapshot.httpResponseTimeMs,
2102 httpProtocol: previousSnapshot.httpProtocol,
2103 http3Advertised: previousSnapshot.http3Advertised,
2104 httpHeadersError: previousSnapshot.httpHeadersError,
2105 reachabilityResults: previousSnapshot.reachabilityResults,
2106 reachabilityError: previousSnapshot.reachabilityError,
2107 ipGeolocation: previousSnapshot.ipGeolocation,
2108 ipGeolocationError: previousSnapshot.ipGeolocationError,
2109 emailSecurity: previousSnapshot.emailSecurity,
2110 emailSecurityError: previousSnapshot.emailSecurityError,
2111 ownership: previousSnapshot.ownership,
2112 ownershipError: previousSnapshot.ownershipError,
2113 ownershipHistory: previousSnapshot.ownershipHistory,
2114 ownershipHistoryError: previousSnapshot.ownershipHistoryError,
2115 inferredProvider: previousSnapshot.inferredProvider,
2116 priorProviders: previousSnapshot.priorProviders,
2117 domainClassification: previousSnapshot.domainClassification,
2118 ownershipTransitions: previousSnapshot.ownershipTransitions,
2119 hostingTransitions: previousSnapshot.hostingTransitions,
2120 subdomainHistory: previousSnapshot.subdomainHistory,
2121 riskSignals: previousSnapshot.riskSignals,
2122 intelligenceTimeline: previousSnapshot.intelligenceTimeline,
2123 ptrRecord: previousSnapshot.ptrRecord,
2124 ptrError: previousSnapshot.ptrError,
2125 redirectChain: previousSnapshot.redirectChain,
2126 redirectChainError: previousSnapshot.redirectChainError,
2127 subdomains: previousSnapshot.subdomains,
2128 subdomainsError: previousSnapshot.subdomainsError,
2129 extendedSubdomains: previousSnapshot.extendedSubdomains,
2130 extendedSubdomainsError: previousSnapshot.extendedSubdomainsError,
2131 dnsHistory: previousSnapshot.dnsHistory,
2132 dnsHistoryError: previousSnapshot.dnsHistoryError,
2133 domainPricing: previousSnapshot.domainPricing,
2134 domainPricingError: previousSnapshot.domainPricingError,
2135 reputation: previousSnapshot.reputation,
2136 reputationError: previousSnapshot.reputationError,
2137 portScanResults: previousSnapshot.portScanResults,
2138 portScanError: previousSnapshot.portScanError,
2139 changeSummary: previousSnapshot.changeSummary,
2140 resultSource: .snapshot,
2141 cachedSections: [],
2142 statusMessage: "Last known result • \(previousSnapshot.timestamp.formatted(date: .abbreviated, time: .shortened))"
2143 )
2144 }
2145
2146 private static func shouldFallbackToSnapshot(_ snapshot: LookupSnapshot) -> Bool {
2147 let candidateMessages = [
2148 snapshot.dnsError,
2149 snapshot.httpHeadersError,
2150 snapshot.sslError,
2151 snapshot.ownershipError,
2152 snapshot.subdomainsError,
2153 snapshot.redirectChainError,
2154 snapshot.ipGeolocationError
2155 ]
2156 .compactMap { $0?.lowercased() }
2157
2158 guard !candidateMessages.isEmpty else { return false }
2159 let failedDueToConnectivity = candidateMessages.allSatisfy { message in
2160 message.hasPrefix("network error:") || message.hasPrefix("timeout:") || message.hasPrefix("rate limit:")
2161 }
2162
2163 let hasMaterialData = !snapshot.dnsSections.isEmpty
2164 || !snapshot.httpHeaders.isEmpty
2165 || snapshot.sslInfo != nil
2166 || snapshot.ownership != nil
2167 || !snapshot.subdomains.isEmpty
2168
2169 return failedDueToConnectivity && !hasMaterialData
2170 }
2171
2172 private func runDNS(domain: String, lookupID: UUID) async {
2173 let result = await DNSLookupService.lookupAll(domain: domain)
2174 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
2175 switch result {
2176 case let .success(sections):
2177 dnsSections = sections
2178 dnsError = nil
2179 case let .empty(message):
2180 dnsSections = []
2181 dnsError = message
2182 case let .error(message):
2183 dnsSections = []
2184 dnsError = message
2185 }
2186 dnsLoading = false
2187 }
2188
2189 private func runAvailability(domain: String, lookupID: UUID) async {
2190 let result = await DomainAvailabilityService.check(domain: domain)
2191 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
2192 availabilityResult = result
2193 availabilityLoading = false
2194 updateTrackedDomainAvailability(for: result.domain, status: result.status)
2195 }
2196
2197 private func runSSL(domain: String, lookupID: UUID) async {
2198 let result = await SSLCheckService.check(domain: domain)
2199 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
2200 switch result {
2201 case let .success(info):
2202 sslInfo = info
2203 sslError = nil
2204 case let .empty(message):
2205 sslInfo = nil
2206 sslError = message
2207 case let .error(message):
2208 sslInfo = nil
2209 sslError = message
2210 }
2211 sslLoading = false
2212 }
2213
2214 private func runHSTSPreload(domain: String, lookupID: UUID) async {
2215 let result = await SSLCheckService.checkHSTSPreload(domain: domain)
2216 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
2217 hstsPreloaded = result
2218 hstsLoading = false
2219 }
2220
2221 private func runHTTPHeaders(domain: String, lookupID: UUID) async {
2222 let result = await HTTPHeadersService.fetch(domain: domain)
2223 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
2224 switch result {
2225 case let .success(headersResult):
2226 httpHeaders = headersResult.headers
2227 httpSecurityGrade = HTTPSecurityGrade.grade(for: headersResult.headers).rawValue
2228 httpStatusCode = headersResult.statusCode
2229 httpResponseTimeMs = headersResult.responseTimeMs
2230 httpProtocol = headersResult.httpProtocol
2231 http3Advertised = headersResult.http3Advertised
2232 httpHeadersError = nil
2233 case let .empty(message):
2234 httpHeaders = []
2235 httpSecurityGrade = nil
2236 httpStatusCode = nil
2237 httpResponseTimeMs = nil
2238 httpProtocol = nil
2239 http3Advertised = false
2240 httpHeadersError = message
2241 case let .error(message):
2242 httpHeaders = []
2243 httpSecurityGrade = nil
2244 httpStatusCode = nil
2245 httpResponseTimeMs = nil
2246 httpProtocol = nil
2247 http3Advertised = false
2248 httpHeadersError = message
2249 }
2250 httpHeadersLoading = false
2251 }
2252
2253 private func runReachability(domain: String, lookupID: UUID) async {
2254 let result = await ReachabilityService.checkAll(domain: domain)
2255 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
2256 switch result {
2257 case let .success(results):
2258 reachabilityResults = results
2259 reachabilityError = nil
2260 case let .empty(message):
2261 reachabilityResults = []
2262 reachabilityError = message
2263 case let .error(message):
2264 reachabilityResults = []
2265 reachabilityError = message
2266 }
2267 reachabilityLoading = false
2268 }
2269
2270 private func runEmailSecurity(domain: String, txtRecords: [DNSRecord], lookupID: UUID) async {
2271 let result = await EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords)
2272 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
2273 switch result {
2274 case let .success(emailResult):
2275 emailSecurity = emailResult
2276 emailSecurityError = nil
2277 case let .empty(message):
2278 emailSecurity = nil
2279 emailSecurityError = message
2280 case let .error(message):
2281 emailSecurity = nil
2282 emailSecurityError = message
2283 }
2284 emailSecurityLoading = false
2285 }
2286
2287 private func runOwnership(domain: String, lookupID: UUID) async {
2288 let result = await DomainOwnershipService.lookup(domain: domain)
2289 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
2290 switch result {
2291 case let .success(ownership):
2292 ownershipResult = ownership
2293 ownershipError = nil
2294 case let .empty(message):
2295 ownershipResult = nil
2296 ownershipError = message
2297 case let .error(message):
2298 ownershipResult = nil
2299 ownershipError = message
2300 }
2301 ownershipLoading = false
2302 }
2303
2304 private func runReverseDNS(ip: String, lookupID: UUID) async {
2305 let result = await ReverseDNSService.lookup(ip: ip, resolverURLString: resolverURLString)
2306 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
2307 switch result {
2308 case let .success(record):
2309 ptrRecord = record
2310 ptrError = nil
2311 case let .empty(message):
2312 ptrRecord = nil
2313 ptrError = message
2314 case let .error(message):
2315 ptrRecord = nil
2316 ptrError = message
2317 }
2318 ptrLoading = false
2319 }
2320
2321 private func runRedirectChain(domain: String, lookupID: UUID) async {
2322 let result = await RedirectChainService.trace(domain: domain)
2323 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
2324 switch result {
2325 case let .success(hops):
2326 redirectChain = hops
2327 redirectChainError = nil
2328 case let .empty(message):
2329 redirectChain = []
2330 redirectChainError = message
2331 case let .error(message):
2332 redirectChain = []
2333 redirectChainError = message
2334 }
2335 redirectChainLoading = false
2336 }
2337
2338 private func runSubdomains(domain: String, lookupID: UUID) async {
2339 let result = await SubdomainDiscoveryService.discover(for: domain)
2340 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
2341 switch result {
2342 case let .success(results):
2343 subdomains = results
2344 subdomainsError = nil
2345 case let .empty(message):
2346 subdomains = []
2347 subdomainsError = message
2348 case let .error(message):
2349 subdomains = []
2350 subdomainsError = message
2351 }
2352 subdomainsLoading = false
2353 }
2354
2355 private func runPortScan(domain: String, lookupID: UUID) async {
2356 let result = await PortScanService.scanAll(domain: domain)
2357 switch result {
2358 case let .success(results):
2359 let enrichedResults = await enrichOpenPortBanners(in: results, domain: domain)
2360 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
2361 portScanResults = enrichedResults
2362 portScanError = nil
2363 case let .empty(message):
2364 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
2365 portScanResults = []
2366 portScanError = message
2367 case let .error(message):
2368 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
2369 portScanResults = []
2370 portScanError = message
2371 }
2372 portScanLoading = false
2373 }
2374
2375 private func runIPGeolocation(ip: String, lookupID: UUID) async {
2376 let result = await IPGeolocationService.lookup(ip: ip)
2377 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
2378 switch result {
2379 case let .success(geolocation):
2380 ipGeolocation = geolocation
2381 ipGeolocationError = nil
2382 case let .empty(message):
2383 ipGeolocation = nil
2384 ipGeolocationError = message
2385 case let .error(message):
2386 ipGeolocation = nil
2387 ipGeolocationError = message
2388 }
2389 ipGeolocationLoading = false
2390 }
2391
2392 private func finishDependentWithoutPrimaryIP(lookupID: UUID) async {
2393 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
2394 ptrLoading = false
2395 ptrError = "No A record available"
2396 ipGeolocationLoading = false
2397 ipGeolocationError = "No A record available"
2398 }
2399
2400 private func runSuggestions(domain: String, lookupID: UUID) async {
2401 let results = await DomainAvailabilityService.suggestions(for: domain)
2402 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
2403 suggestions = results
2404 suggestionsLoading = false
2405 }
2406
2407 private func applyCustomPortResult(_ result: ServiceResult<[PortScanResult]>) {
2408 switch result {
2409 case let .success(results):
2410 customPortResults = results
2411 customPortScanError = nil
2412 _ = saveHistoryEntry(replaceLatest: true)
2413 case let .empty(message):
2414 customPortResults = []
2415 customPortScanError = message
2416 case let .error(message):
2417 customPortResults = []
2418 customPortScanError = message
2419 }
2420 customPortScanLoading = false
2421 }
2422
2423 private static func performBatchLookup(domain: String, previousSnapshot: LookupSnapshot?) async -> BatchLookupPayload? {
2424 guard !Task.isCancelled else { return nil }
2425 let inspectionService = DomainInspectionService()
2426 let snapshot = await inspectionService.inspectSnapshot(domain: domain, previousSnapshot: previousSnapshot)
2427 guard !Task.isCancelled else { return nil }
2428 return BatchLookupPayload(snapshot: resolvedSnapshotAfterFallback(snapshot, previousSnapshot: previousSnapshot))
2429 }
2430
2431 private static func enrichOpenPortBanners(_ results: [PortScanResult], domain: String) async -> [PortScanResult] {
2432 let banners = await withTaskGroup(of: (UInt16, String?).self, returning: [UInt16: String].self) { group in
2433 for result in results where result.open {
2434 group.addTask {
2435 let banner = await PortScanService.grabBanner(host: domain, port: result.port)
2436 return (result.port, banner)
2437 }
2438 }
2439
2440 var collected: [UInt16: String] = [:]
2441 for await (port, banner) in group {
2442 if let banner {
2443 collected[port] = banner
2444 }
2445 }
2446 return collected
2447 }
2448
2449 return results.map { result in
2450 var updated = result
2451 updated.banner = banners[result.port]
2452 return updated
2453 }
2454 }
2455
2456 private func enrichOpenPortBanners(in results: [PortScanResult], domain: String) async -> [PortScanResult] {
2457 await Self.enrichOpenPortBanners(results, domain: domain)
2458 }
2459
2460 @discardableResult
2461 private func saveHistoryEntry(replaceLatest: Bool, reuseCurrentAnalysis: Bool = false) -> HistoryEntry? {
2462 guard !searchedDomain.isEmpty else { return nil }
2463 return saveHistoryEntry(
2464 from: currentSnapshot,
2465 replaceLatest: replaceLatest,
2466 updateCurrentState: true,
2467 reuseCurrentAnalysis: reuseCurrentAnalysis
2468 )
2469 }
2470
2471 @discardableResult
2472 private func saveHistoryEntry(
2473 from snapshot: LookupSnapshot,
2474 replaceLatest: Bool,
2475 updateCurrentState: Bool,
2476 reuseCurrentAnalysis: Bool = false
2477 ) -> HistoryEntry? {
2478 let trackedDomainID = snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id
2479 let previousSnapshot = previousSnapshot(for: snapshot.domain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest)
2480 let domainHistoryEntries = history.filter {
2481 $0.domain.caseInsensitiveCompare(snapshot.domain) == .orderedSame
2482 }
2483 let analysis = reuseCurrentAnalysis ? nil : DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot)
2484 let intelligence = DomainIntelligenceService.derive(
2485 snapshot: snapshot,
2486 previousSnapshot: previousSnapshot,
2487 historyEntries: domainHistoryEntries
2488 )
2489 let changeSummary = reuseCurrentAnalysis
2490 ? currentChangeSummary ?? snapshot.changeSummary
2491 : previousSnapshot.map {
2492 DiffService.summary(
2493 from: $0,
2494 to: snapshot,
2495 generatedAt: snapshot.timestamp,
2496 riskAssessment: analysis?.riskAssessment,
2497 insights: analysis?.insights
2498 )
2499 }
2500 let domainDiff = reuseCurrentAnalysis
2501 ? previousSnapshot.map {
2502 DomainDiff(
2503 domain: snapshot.domain,
2504 fromTimestamp: $0.timestamp,
2505 toTimestamp: snapshot.timestamp,
2506 sections: currentDiffSections,
2507 changedSectionIDs: currentDiffSections.filter(\.hasChanges).map(\.id),
2508 changedSectionTitles: currentDiffSections.filter(\.hasChanges).map(\.title),
2509 contextNote: currentChangeSummary?.contextNote
2510 )
2511 }
2512 : previousSnapshot.map { DiffService.compare(from: $0, to: snapshot) }
2513 let diffSections = domainDiff?.sections ?? currentDiffSections
2514 let previousSnapshotID = previousSnapshot?.historyEntryID
2515 let nextSnapshotIndex = nextSnapshotIndex(for: snapshot.domain, trackedDomainID: trackedDomainID)
2516
2517 if updateCurrentState {
2518 currentChangeSummary = changeSummary
2519 currentDiffSections = diffSections
2520 ownershipDiff = diffSections.first(where: { $0.title == "Ownership" })?.items.filter(\.hasChanges) ?? []
2521 if !reuseCurrentAnalysis {
2522 currentReport = reportBuilder.build(
2523 from: snapshot,
2524 previousSnapshot: previousSnapshot,
2525 historyEntries: domainHistoryEntries
2526 )
2527 }
2528 }
2529
2530 let entry = HistoryEntry(
2531 domain: snapshot.domain,
2532 timestamp: snapshot.timestamp,
2533 trackedDomainID: trackedDomainID,
2534 note: currentHistoryEntry?.note,
2535 dnsSections: snapshot.dnsSections,
2536 sslInfo: snapshot.sslInfo,
2537 httpHeaders: snapshot.httpHeaders,
2538 reachabilityResults: snapshot.reachabilityResults,
2539 ipGeolocation: snapshot.ipGeolocation,
2540 emailSecurity: snapshot.emailSecurity,
2541 mtaSts: snapshot.emailSecurity?.mtaSts,
2542 ownership: snapshot.ownership,
2543 ownershipHistory: snapshot.ownershipHistory,
2544 inferredProvider: intelligence.inferredProvider,
2545 priorProviders: intelligence.priorProviders,
2546 domainClassification: intelligence.domainClassification,
2547 ownershipTransitions: intelligence.ownershipTransitions,
2548 hostingTransitions: intelligence.hostingTransitions,
2549 subdomainHistory: intelligence.subdomainHistory,
2550 riskSignals: intelligence.riskSignals,
2551 intelligenceTimeline: intelligence.timelineEvents,
2552 ptrRecord: snapshot.ptrRecord,
2553 redirectChain: snapshot.redirectChain,
2554 subdomains: snapshot.subdomains,
2555 extendedSubdomains: snapshot.extendedSubdomains,
2556 dnsHistory: snapshot.dnsHistory,
2557 domainPricing: snapshot.domainPricing,
2558 reputation: snapshot.reputation,
2559 portScanResults: snapshot.portScanResults,
2560 hstsPreloaded: snapshot.hstsPreloaded,
2561 availabilityResult: snapshot.availabilityResult,
2562 suggestions: snapshot.suggestions,
2563 appVersion: snapshot.appVersion,
2564 resultSource: snapshot.resultSource,
2565 dataSources: snapshot.dataSources,
2566 provenanceBySection: snapshot.provenanceBySection,
2567 availabilityConfidence: snapshot.availabilityConfidence,
2568 ownershipConfidence: snapshot.ownershipConfidence,
2569 subdomainConfidence: snapshot.subdomainConfidence,
2570 emailSecurityConfidence: snapshot.emailSecurityConfidence,
2571 geolocationConfidence: snapshot.geolocationConfidence,
2572 errorDetails: snapshot.errorDetails,
2573 isPartialSnapshot: snapshot.isPartialSnapshot,
2574 validationIssues: snapshot.validationIssues,
2575 resolverDisplayName: snapshot.resolverDisplayName,
2576 resolverURLString: snapshot.resolverURLString,
2577 totalLookupDurationMs: snapshot.totalLookupDurationMs,
2578 primaryIP: Self.primaryIPAddress(from: snapshot),
2579 finalRedirectURL: Self.finalRedirectTarget(from: snapshot),
2580 tlsStatusSummary: Self.httpsSummary(from: snapshot),
2581 emailSecuritySummary: Self.emailSummary(from: snapshot),
2582 httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
2583 changeSummary: changeSummary,
2584 snapshotIndex: nextSnapshotIndex,
2585 previousSnapshotID: previousSnapshotID,
2586 changeCount: domainDiff?.changeCount ?? changeSummary?.changedSections.count ?? 0,
2587 severitySummary: changeSummary?.severity,
2588 sslError: snapshot.sslError,
2589 httpHeadersError: snapshot.httpHeadersError,
2590 reachabilityError: snapshot.reachabilityError,
2591 ipGeolocationError: snapshot.ipGeolocationError,
2592 emailSecurityError: snapshot.emailSecurityError,
2593 ownershipError: snapshot.ownershipError,
2594 ownershipHistoryError: snapshot.ownershipHistoryError,
2595 ptrError: snapshot.ptrError,
2596 redirectChainError: snapshot.redirectChainError,
2597 subdomainsError: snapshot.subdomainsError,
2598 extendedSubdomainsError: snapshot.extendedSubdomainsError,
2599 dnsHistoryError: snapshot.dnsHistoryError,
2600 domainPricingError: snapshot.domainPricingError,
2601 reputationError: snapshot.reputationError,
2602 portScanError: snapshot.portScanError
2603 )
2604
2605 if updateCurrentState {
2606 currentHistoryEntryID = entry.id
2607 }
2608
2609 if replaceLatest, !history.isEmpty, history[0].domain.caseInsensitiveCompare(snapshot.domain) == .orderedSame {
2610 history[0] = entry
2611 } else {
2612 history.insert(entry, at: 0)
2613 trimHistoryToLimit()
2614 }
2615
2616 updateTrackedDomainSnapshotMetadata(
2617 domain: snapshot.domain,
2618 snapshotID: entry.id,
2619 availabilityStatus: snapshot.availabilityResult?.status,
2620 updatedAt: snapshot.timestamp,
2621 changeSummary: changeSummary,
2622 changeSeverity: changeSummary?.severity,
2623 certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot),
2624 certificateDaysRemaining: snapshot.sslInfo?.daysUntilExpiry
2625 )
2626 persistHistory()
2627 notifyIfNeeded(for: entry, snapshot: snapshot, previousSnapshot: previousSnapshot)
2628 return entry
2629 }
2630
2631 private func persistHistory() {
2632 if historyPersistenceSuspended {
2633 historyPersistenceDirty = true
2634 return
2635 }
2636 let persistStartedAt = DomainDebugLog.signpostStart("DomainViewModel.persistHistory")
2637 DomainDataPortabilityService.saveHistoryEntries(history)
2638 refreshDataLifecycleSummary()
2639 DomainDebugLog.signpostEnd("DomainViewModel.persistHistory", start: persistStartedAt, extra: "count=\(history.count)")
2640 }
2641
2642 private func persistAuditSessions() {
2643 DomainDataPortabilityService.saveAuditSessions(auditSessions)
2644 refreshDataLifecycleSummary()
2645 }
2646
2647 func setHistoryAutoPruneOption(_ option: HistoryAutoPruneOption) {
2648 historyAutoPruneOption = option
2649 UserDefaults.standard.set(option.rawValue, forKey: Self.historyAutoPruneKey)
2650 trimHistoryToLimit()
2651 persistHistory()
2652 }
2653
2654 func updateHistoryNote(_ note: String, for entry: HistoryEntry) {
2655 guard let index = history.firstIndex(where: { $0.id == entry.id }) else { return }
2656 history[index].note = note.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
2657 persistHistory()
2658 }
2659
2660 private func persistTrackedDomains() {
2661 if trackedDomainsPersistenceSuspended {
2662 trackedDomainsPersistenceDirty = true
2663 return
2664 }
2665 DomainDataPortabilityService.saveTrackedDomains(trackedDomains)
2666 CloudSyncService.shared.scheduleSyncIfNeeded()
2667 refreshDataLifecycleSummary()
2668 refreshWidgetData()
2669 }
2670
2671 private func beginBulkPersistenceDeferral() {
2672 historyPersistenceSuspended = true
2673 trackedDomainsPersistenceSuspended = true
2674 historyPersistenceDirty = false
2675 trackedDomainsPersistenceDirty = false
2676 }
2677
2678 private func endBulkPersistenceDeferral() {
2679 historyPersistenceSuspended = false
2680 trackedDomainsPersistenceSuspended = false
2681
2682 if trackedDomainsPersistenceDirty {
2683 trackedDomainsPersistenceDirty = false
2684 DomainDataPortabilityService.saveTrackedDomains(trackedDomains)
2685 CloudSyncService.shared.scheduleSyncIfNeeded()
2686 }
2687
2688 if historyPersistenceDirty {
2689 historyPersistenceDirty = false
2690 DomainDataPortabilityService.saveHistoryEntries(history)
2691 }
2692
2693 refreshDataLifecycleSummary()
2694 }
2695
2696 private func trimHistoryToLimit() {
2697 let hardLimit = historyAutoPruneOption.keepCount ?? Self.maxHistory
2698 history = Array(history.prefix(min(hardLimit, Self.maxHistory)))
2699 }
2700
2701 private func nextSnapshotIndex(for domain: String, trackedDomainID: UUID?) -> Int {
2702 let siblings = history.filter { entry in
2703 if let trackedDomainID {
2704 return entry.trackedDomainID == trackedDomainID
2705 }
2706 return entry.domain.caseInsensitiveCompare(domain) == .orderedSame
2707 }
2708 let existingMax = siblings.compactMap(\.snapshotIndex).max() ?? siblings.count
2709 return existingMax + 1
2710 }
2711
2712 private func persistMonitoringSettings(localActivationConfirmed: Bool = false) {
2713 monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
2714 MonitoringStorage.saveSettings(monitoringSettings)
2715 CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: localActivationConfirmed)
2716 }
2717
2718 private func sanitizeMonitoringSelection() {
2719 monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
2720 MonitoringStorage.saveSettings(monitoringSettings)
2721 CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: false)
2722 }
2723
2724 private func clearMonitoringLogs() {
2725 monitoringLogs.removeAll()
2726 monitoringStatusMessage = nil
2727 MonitoringStorage.saveLogs([])
2728 }
2729
2730 private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) {
2731 guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
2732 return
2733 }
2734 trackedDomains[index].lastKnownAvailability = status
2735 persistTrackedDomains()
2736 }
2737
2738 private func updateTrackedDomainSnapshotMetadata(
2739 domain: String,
2740 snapshotID: UUID,
2741 availabilityStatus: DomainAvailabilityStatus?,
2742 updatedAt: Date,
2743 changeSummary: DomainChangeSummary?,
2744 changeSeverity: ChangeSeverity?,
2745 certificateWarningLevel: CertificateWarningLevel,
2746 certificateDaysRemaining: Int?
2747 ) {
2748 guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
2749 return
2750 }
2751 trackedDomains[index].lastSnapshotID = snapshotID
2752 trackedDomains[index].lastKnownAvailability = availabilityStatus
2753 trackedDomains[index].updatedAt = updatedAt
2754 trackedDomains[index].lastChangeSummary = changeSummary
2755 trackedDomains[index].lastChangeSeverity = changeSeverity
2756 trackedDomains[index].certificateWarningLevel = certificateWarningLevel
2757 trackedDomains[index].certificateDaysRemaining = certificateDaysRemaining
2758 persistTrackedDomains()
2759 }
2760
2761 private func notifyIfNeeded(for entry: HistoryEntry, snapshot: LookupSnapshot, previousSnapshot: LookupSnapshot?) {
2762 guard notificationsAuthorized, entry.trackedDomainID != nil else { return }
2763
2764 Task {
2765 if let summary = entry.changeSummary, summary.hasChanges {
2766 await LocalNotificationService.shared.notifyDomainEvent(
2767 domain: entry.domain,
2768 message: summary.message,
2769 severity: summary.severity
2770 )
2771 }
2772
2773 let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: snapshot)
2774 if certificateWarningLevel == .critical, let daysRemaining = snapshot.sslInfo?.daysUntilExpiry {
2775 await LocalNotificationService.shared.notifyCertificateWarning(
2776 domain: entry.domain,
2777 daysRemaining: daysRemaining
2778 )
2779 }
2780
2781 if let previousStatus = previousSnapshot?.availabilityResult?.status,
2782 let newStatus = snapshot.availabilityResult?.status,
2783 previousStatus != newStatus {
2784 await LocalNotificationService.shared.notifyDomainEvent(
2785 domain: entry.domain,
2786 message: "Availability changed",
2787 severity: .high
2788 )
2789 }
2790 }
2791 }
2792
2793 private func previousSnapshot(for domain: String, trackedDomainID: UUID?, replacingLatest: Bool) -> LookupSnapshot? {
2794 let matchingEntries = history.filter { entry in
2795 if let trackedDomainID {
2796 return entry.trackedDomainID == trackedDomainID
2797 }
2798 return entry.domain.caseInsensitiveCompare(domain) == .orderedSame
2799 }
2800
2801 if replacingLatest {
2802 return matchingEntries.dropFirst().first?.snapshot
2803 }
2804 return matchingEntries.first?.snapshot
2805 }
2806
2807 private func trackedDomain(for domain: String) -> TrackedDomain? {
2808 trackedDomains.first { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
2809 }
2810
2811 private func normalizedDomain(_ domain: String) -> String {
2812 domain
2813 .trimmingCharacters(in: .whitespacesAndNewlines)
2814 .replacingOccurrences(of: "https://", with: "")
2815 .replacingOccurrences(of: "http://", with: "")
2816 .components(separatedBy: "/")
2817 .first?
2818 .lowercased() ?? ""
2819 }
2820
2821 private func linkTrackedDomainHistory(for domain: String) {
2822 guard let trackedDomain = trackedDomain(for: domain) else { return }
2823 var didChange = false
2824
2825 for index in history.indices where history[index].domain.caseInsensitiveCompare(domain) == .orderedSame {
2826 if history[index].trackedDomainID != trackedDomain.id {
2827 history[index].trackedDomainID = trackedDomain.id
2828 didChange = true
2829 }
2830 }
2831
2832 if didChange {
2833 persistHistory()
2834 }
2835
2836 if let latestEntry = history.first(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }),
2837 let trackedIndex = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) {
2838 trackedDomains[trackedIndex].lastSnapshotID = latestEntry.id
2839 trackedDomains[trackedIndex].lastChangeSummary = latestEntry.changeSummary
2840 trackedDomains[trackedIndex].lastChangeSeverity = latestEntry.changeSummary?.severity
2841 trackedDomains[trackedIndex].lastKnownAvailability = latestEntry.availabilityResult?.status
2842 trackedDomains[trackedIndex].certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: latestEntry.snapshot)
2843 trackedDomains[trackedIndex].certificateDaysRemaining = latestEntry.sslInfo?.daysUntilExpiry
2844 trackedDomains[trackedIndex].updatedAt = latestEntry.timestamp
2845 persistTrackedDomains()
2846 }
2847 }
2848
2849 private func addRecentSearch(_ domain: String) {
2850 recentSearches.removeAll { $0.lowercased() == domain.lowercased() }
2851 recentSearches.insert(domain, at: 0)
2852 if recentSearches.count > Self.maxRecent {
2853 recentSearches = Array(recentSearches.prefix(Self.maxRecent))
2854 }
2855 DomainDataPortabilityService.saveRecentSearches(recentSearches)
2856 CloudSyncService.shared.markAppSettingsChanged()
2857 refreshDataLifecycleSummary()
2858 }
2859
2860 private func beginLookup(for target: String, cancelExistingTask: Bool = true) -> UUID {
2861 if cancelExistingTask {
2862 lookupTask?.cancel()
2863 }
2864 customPortScanTask?.cancel()
2865
2866 let lookupID = UUID()
2867 activeLookupID = lookupID
2868 lookupStartedAt = Date()
2869 lastLookupDurationMs = nil
2870 addRecentSearch(target)
2871 searchedDomain = target
2872 hasRun = true
2873 currentHistoryEntryID = nil
2874 currentSnapshotTimestamp = Date()
2875 currentResultSource = .live
2876 currentCachedSections = []
2877 currentStatusMessage = nil
2878 currentDiffSections = []
2879 currentChangeSummary = nil
2880 currentReport = nil
2881 ownershipDiff = []
2882 clearLookupState()
2883 setAllLoadingStates(true)
2884 customPortScanLoading = false
2885 return lookupID
2886 }
2887
2888 private func startBatchLookup(domains: [String], source: BatchLookupSource, workflow: DomainWorkflow? = nil) {
2889 guard !domains.isEmpty else { return }
2890 guard !batchLookupRunning else { return }
2891
2892 let now = Date()
2893 if let lastBatchStartedAt, now.timeIntervalSince(lastBatchStartedAt) < 1 {
2894 return
2895 }
2896
2897 lastBatchStartedAt = now
2898 clearBatchState()
2899 batchLookupSource = source
2900 activeWorkflowRunID = workflow?.id
2901 activeWorkflowRunName = workflow?.name
2902 batchTotalCount = domains.count
2903 batchLookupRunning = true
2904 batchResults = domains.map {
2905 BatchLookupResult(
2906 domain: $0,
2907 historyEntryID: nil,
2908 availability: nil,
2909 primaryIP: nil,
2910 quickStatus: "Pending",
2911 timestamp: Date(),
2912 status: .pending
2913 )
2914 }
2915
2916 lookupTask?.cancel()
2917 customPortScanTask?.cancel()
2918 batchTask?.cancel()
2919
2920 SweepActivityController.shared.begin(
2921 title: source == .watchlistRefresh ? "Watchlist Sweep" : "Batch Lookup",
2922 total: domains.count
2923 )
2924
2925 batchTask = Task { [weak self] in
2926 guard let self else { return }
2927 self.notificationsAuthorized = await LocalNotificationService.shared.requestAuthorizationIfNeeded()
2928 await self.runBatchLookup(domains: domains, source: source)
2929 }
2930 }
2931
2932 private func clearBatchState() {
2933 batchResults = []
2934 batchLookupSource = .manual
2935 batchCurrentDomain = nil
2936 batchCompletedCount = 0
2937 batchTotalCount = 0
2938 batchLookupRunning = false
2939 latestBatchSweepSummary = nil
2940 latestWorkflowRunSummary = nil
2941 activeBatchDomains = []
2942 activeWorkflowRunID = nil
2943 activeWorkflowRunName = nil
2944 batchTask = nil
2945 }
2946
2947 private func parsedDomains(from input: String) -> [String] {
2948 let separators = CharacterSet(charactersIn: ",\n")
2949 var seen = Set<String>()
2950
2951 return input
2952 .components(separatedBy: separators)
2953 .map(normalizedDomain)
2954 .filter { !$0.isEmpty }
2955 .filter { seen.insert($0).inserted }
2956 }
2957
2958 private func runBatchLookup(domains: [String], source: BatchLookupSource) async {
2959 let concurrencyLimit = min(source == .watchlistRefresh ? 4 : 3, max(domains.count, 1))
2960 var nextIndex = 0
2961 beginBulkPersistenceDeferral()
2962
2963 await withTaskGroup(of: (String, BatchLookupPayload?).self) { group in
2964 for _ in 0..<concurrencyLimit {
2965 guard nextIndex < domains.count else { break }
2966 let domain = domains[nextIndex]
2967 nextIndex += 1
2968 enqueueBatchLookup(domain: domain, source: source, group: &group)
2969 }
2970
2971 while let (domain, payload) = await group.next() {
2972 completeBatchLookup(domain: domain, payload: payload)
2973
2974 if nextIndex < domains.count, !Task.isCancelled {
2975 let nextDomain = domains[nextIndex]
2976 nextIndex += 1
2977 enqueueBatchLookup(domain: nextDomain, source: source, group: &group)
2978 }
2979 }
2980 }
2981
2982 endBulkPersistenceDeferral()
2983 finishBatchLookup(source: source)
2984 }
2985
2986 private func enqueueBatchLookup(
2987 domain: String,
2988 source: BatchLookupSource,
2989 group: inout TaskGroup<(String, BatchLookupPayload?)>
2990 ) {
2991 activeBatchDomains.append(domain)
2992 batchCurrentDomain = activeBatchDomains.first
2993 if source == .watchlistRefresh {
2994 refreshingTrackedDomainID = trackedDomain(for: domain)?.id
2995 }
2996 updateBatchResult(domain: domain, status: .running, quickStatus: "Running", entry: nil, errorMessage: nil)
2997 let previousSnapshot = previousSnapshot(for: domain, trackedDomainID: trackedDomain(for: domain)?.id, replacingLatest: false)
2998
2999 group.addTask { [domain, previousSnapshot] in
3000 let payload = await Self.performBatchLookup(domain: domain, previousSnapshot: previousSnapshot)
3001 return (domain, payload)
3002 }
3003 }
3004
3005 private func completeBatchLookup(domain: String, payload: BatchLookupPayload?) {
3006 activeBatchDomains.removeAll { $0.caseInsensitiveCompare(domain) == .orderedSame }
3007 batchCurrentDomain = activeBatchDomains.first
3008
3009 guard let payload else {
3010 updateBatchResult(
3011 domain: domain,
3012 status: .failed,
3013 quickStatus: "Failed",
3014 entry: nil,
3015 resultSource: .live,
3016 errorMessage: "Lookup cancelled"
3017 )
3018 batchCompletedCount += 1
3019 SweepActivityController.shared.update(
3020 completed: batchCompletedCount,
3021 total: batchTotalCount,
3022 currentDomain: batchCurrentDomain
3023 )
3024 return
3025 }
3026
3027 let entry = payload.snapshot.historyEntryID.flatMap { id in
3028 history.first(where: { $0.id == id })
3029 } ?? saveHistoryEntry(from: payload.snapshot, replaceLatest: false, updateCurrentState: false)
3030 let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot)
3031 let riskAssessment = entry?.changeSummary?.riskAssessment ?? DomainInsightEngine.analyze(snapshot: payload.snapshot).riskAssessment
3032 let quickStatus: String
3033 if entry?.changeSummary?.hasChanges == true {
3034 quickStatus = entry?.changeSummary?.impactClassification == .critical ? "Critical" : (entry?.changeSummary?.severity == .high ? "High" : "Changed")
3035 } else if certificateWarningLevel != .none {
3036 quickStatus = certificateWarningLevel == .critical ? "Critical" : "Warning"
3037 } else if riskAssessment.level == .high {
3038 quickStatus = "High"
3039 } else {
3040 quickStatus = "Unchanged"
3041 }
3042
3043 updateBatchResult(
3044 domain: domain,
3045 status: .completed,
3046 quickStatus: quickStatus,
3047 entry: entry,
3048 resultSource: payload.snapshot.resultSource,
3049 errorMessage: payload.snapshot.statusMessage
3050 )
3051 batchCompletedCount += 1
3052 SweepActivityController.shared.update(
3053 completed: batchCompletedCount,
3054 total: batchTotalCount,
3055 currentDomain: batchCurrentDomain
3056 )
3057 }
3058
3059 private func finishBatchLookup(source: BatchLookupSource) {
3060 batchLookupRunning = false
3061 batchCurrentDomain = nil
3062 activeBatchDomains = []
3063 refreshingTrackedDomainID = nil
3064 batchTask = nil
3065
3066 let changedCount = batchResults.filter { $0.quickStatus == "Changed" || $0.quickStatus == "High" || $0.quickStatus == "Critical" }.count
3067 let unchangedCount = batchResults.filter { $0.quickStatus == "Unchanged" && $0.status == .completed }.count
3068 let warningCount = batchResults.filter {
3069 $0.certificateWarningLevel != .none
3070 || $0.changeClassification == .warning
3071 || $0.changeClassification == .critical
3072 || $0.riskLevel == .high
3073 }.count
3074
3075 let summary = BatchSweepSummary(
3076 source: source,
3077 totalDomains: batchResults.count,
3078 changedDomains: changedCount,
3079 unchangedDomains: unchangedCount,
3080 warningDomains: warningCount,
3081 results: batchResults.sorted { lhs, rhs in
3082 if lhs.status != rhs.status {
3083 return lhs.status.rawValue < rhs.status.rawValue
3084 }
3085 return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending
3086 },
3087 generatedAt: Date()
3088 )
3089 latestBatchSweepSummary = summary
3090 SweepActivityController.shared.end(changed: changedCount, warnings: warningCount)
3091
3092 if source == .workflow, let activeWorkflowRunID, let activeWorkflowRunName {
3093 let workflowReports: [DomainReport] = summary.results.compactMap { result in
3094 guard let entry = historyEntry(for: result) else { return nil }
3095 return report(for: entry)
3096 }
3097 latestWorkflowRunSummary = WorkflowRunSummary(
3098 workflowID: activeWorkflowRunID,
3099 workflowName: activeWorkflowRunName,
3100 totalDomains: batchResults.count,
3101 changedDomains: changedCount,
3102 unchangedDomains: unchangedCount,
3103 warningDomains: warningCount,
3104 results: summary.results,
3105 workflowInsights: DomainInsightEngine.workflowInsights(for: workflowReports),
3106 generatedAt: summary.generatedAt
3107 )
3108 }
3109
3110 if notificationsAuthorized, source != .workflow {
3111 Task {
3112 await LocalNotificationService.shared.notifySweepComplete(summary: summary)
3113 }
3114 }
3115 }
3116
3117 private func updateBatchResult(
3118 domain: String,
3119 status: BatchLookupStatus,
3120 quickStatus: String,
3121 entry: HistoryEntry?,
3122 resultSource: LookupResultSource = .live,
3123 errorMessage: String?
3124 ) {
3125 guard let index = batchResults.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
3126 return
3127 }
3128
3129 batchResults[index] = BatchLookupResult(
3130 id: batchResults[index].id,
3131 domain: domain,
3132 historyEntryID: entry?.id,
3133 resultSource: resultSource,
3134 availability: entry?.availabilityResult?.status,
3135 primaryIP: entry?.primaryIP,
3136 quickStatus: quickStatus,
3137 summaryMessage: entry?.changeSummary?.message,
3138 changeSeverity: entry?.changeSummary?.severity,
3139 changeClassification: entry?.changeSummary?.impactClassification,
3140 certificateWarningLevel: entry.map { DomainDiffService.certificateWarningLevel(for: $0.snapshot) } ?? batchResults[index].certificateWarningLevel,
3141 riskScore: entry.map { $0.changeSummary?.riskAssessment?.score ?? report(for: $0).riskAssessment.score },
3142 riskLevel: entry.map { $0.changeSummary?.riskAssessment?.level ?? report(for: $0).riskAssessment.level },
3143 timestamp: entry?.timestamp ?? Date(),
3144 status: status,
3145 errorMessage: errorMessage
3146 )
3147 }
3148
3149 private func clearLookupState() {
3150 dnsSections = []
3151 dnsError = nil
3152 dnsLoading = false
3153 availabilityResult = nil
3154 availabilityLoading = false
3155 suggestions = []
3156 suggestionsLoading = false
3157 sslInfo = nil
3158 sslError = nil
3159 sslLoading = false
3160 hstsPreloaded = nil
3161 hstsLoading = false
3162 httpHeaders = []
3163 httpSecurityGrade = nil
3164 httpStatusCode = nil
3165 httpResponseTimeMs = nil
3166 httpProtocol = nil
3167 http3Advertised = false
3168 httpHeadersError = nil
3169 httpHeadersLoading = false
3170 reachabilityResults = []
3171 reachabilityError = nil
3172 reachabilityLoading = false
3173 ipGeolocation = nil
3174 ipGeolocationError = nil
3175 ipGeolocationLoading = false
3176 emailSecurity = nil
3177 emailSecurityError = nil
3178 emailSecurityLoading = false
3179 ownershipResult = nil
3180 ownershipError = nil
3181 ownershipLoading = false
3182 ownershipHistory = []
3183 ownershipHistoryError = nil
3184 ownershipHistoryLoading = false
3185 ptrRecord = nil
3186 ptrError = nil
3187 ptrLoading = false
3188 redirectChain = []
3189 redirectChainError = nil
3190 redirectChainLoading = false
3191 subdomains = []
3192 subdomainsError = nil
3193 subdomainsLoading = false
3194 extendedSubdomains = []
3195 extendedSubdomainsError = nil
3196 extendedSubdomainsLoading = false
3197 dnsHistory = []
3198 dnsHistoryError = nil
3199 dnsHistoryLoading = false
3200 domainPricing = nil
3201 domainPricingError = nil
3202 domainPricingLoading = false
3203 portScanResults = []
3204 portScanError = nil
3205 portScanLoading = false
3206 customPortResults = []
3207 customPortScanError = nil
3208 customPortScanLoading = false
3209 currentHistoryEntryID = nil
3210 currentSnapshotTimestamp = Date()
3211 currentResultSource = .live
3212 currentCachedSections = []
3213 currentStatusMessage = nil
3214 currentReport = nil
3215 }
3216
3217 private func setAllLoadingStates(_ loading: Bool) {
3218 dnsLoading = loading
3219 availabilityLoading = loading
3220 suggestionsLoading = loading
3221 sslLoading = loading
3222 hstsLoading = loading
3223 httpHeadersLoading = loading
3224 reachabilityLoading = loading
3225 ipGeolocationLoading = loading
3226 emailSecurityLoading = loading
3227 ownershipLoading = loading
3228 ownershipHistoryLoading = false
3229 ptrLoading = loading
3230 redirectChainLoading = loading
3231 subdomainsLoading = loading
3232 extendedSubdomainsLoading = false
3233 dnsHistoryLoading = false
3234 domainPricingLoading = false
3235 portScanLoading = loading
3236 }
3237
3238 private func primaryIPAddress(from sections: [DNSSection]) -> String? {
3239 sections.first(where: { $0.recordType == .A })?.records.first?.value
3240 }
3241
3242 private func isCurrentLookup(_ lookupID: UUID) -> Bool {
3243 activeLookupID == lookupID
3244 }
3245
3246 func recentSnapshots(for trackedDomain: TrackedDomain, limit: Int = 6) -> [HistoryEntry] {
3247 history
3248 .filter { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }
3249 .sorted { $0.timestamp > $1.timestamp }
3250 .prefix(limit)
3251 .map { $0 }
3252 }
3253
3254 func latestSnapshots(for domains: [TrackedDomain]) -> [HistoryEntry] {
3255 domains.compactMap { trackedDomain in
3256 recentSnapshots(for: trackedDomain, limit: 1).first
3257 }
3258 }
3259
3260 func diffSectionsForLatestSnapshots(of trackedDomain: TrackedDomain) -> [DomainDiffSection] {
3261 let snapshots = recentSnapshots(for: trackedDomain, limit: 2)
3262 guard snapshots.count == 2 else { return [] }
3263 return DomainDiffService.diff(from: snapshots[1].snapshot, to: snapshots[0].snapshot)
3264 }
3265
3266 func latestChangeSummary(for trackedDomain: TrackedDomain) -> DomainChangeSummary? {
3267 trackedDomain.lastChangeSummary ?? recentSnapshots(for: trackedDomain, limit: 1).first?.changeSummary
3268 }
3269
3270 func latestSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot? {
3271 recentSnapshots(for: trackedDomain, limit: 1).first?.snapshot
3272 }
3273
3274 func trackedDomain(withID id: UUID) -> TrackedDomain? {
3275 trackedDomains.first(where: { $0.id == id })
3276 }
3277
3278 private func portfolioDashboardStamp() -> PortfolioDashboardStamp {
3279 PortfolioDashboardStamp(
3280 trackedDomainSignature: trackedDomains
3281 .sorted { $0.id.uuidString < $1.id.uuidString }
3282 .map {
3283 [
3284 $0.id.uuidString,
3285 $0.updatedAt.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))),
3286 String($0.pendingMonitoringAlerts.count),
3287 $0.lastMonitoredAt?.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))) ?? "0",
3288 $0.lastAlertAt?.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))) ?? "0"
3289 ].joined(separator: "|")
3290 },
3291 historySignature: history
3292 .prefix(250)
3293 .map {
3294 [
3295 $0.id.uuidString,
3296 $0.timestamp.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))),
3297 String($0.changeCount),
3298 $0.severitySummary?.rawValue.description ?? "-"
3299 ].joined(separator: "|")
3300 },
3301 monitoringSignature: monitoringLogs
3302 .prefix(100)
3303 .map {
3304 [
3305 $0.id.uuidString,
3306 $0.timestamp.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))),
3307 String($0.alertsTriggered),
3308 String($0.changesFound)
3309 ].joined(separator: "|")
3310 }
3311 )
3312 }
3313
3314 private func buildPortfolioDomainStates() -> [PortfolioDomainStatus] {
3315 let now = Date()
3316 return sortedTrackedDomains(from: trackedDomains, using: .pinned).map { trackedDomain in
3317 let recentEntries = recentSnapshots(for: trackedDomain, limit: 8)
3318 let latestEntry = recentEntries.first
3319 let report = latestEntry.map { self.report(for: $0) } ?? reportBuilder.build(from: placeholderSnapshot(for: trackedDomain))
3320 let recentMonitoringResults = monitoringLogs
3321 .flatMap(\.checkedDomains)
3322 .filter { $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }
3323 .sorted { $0.checkedAt > $1.checkedAt }
3324 let recentFailureResults = recentMonitoringResults.filter {
3325 let isFailure = $0.errorMessage != nil || ($0.alertSeverity ?? .info) >= .warning
3326 return isFailure && now.timeIntervalSince($0.checkedAt) <= 7 * 24 * 60 * 60
3327 }
3328 let recentChangeCount = recentEntries.filter {
3329 guard let changeSummary = $0.changeSummary, changeSummary.hasChanges else { return false }
3330 return now.timeIntervalSince($0.timestamp) <= 7 * 24 * 60 * 60
3331 }.count
3332 let recentDNSChange = recentEntries.contains {
3333 guard let changeSummary = $0.changeSummary else { return false }
3334 return changeSummary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") })
3335 && now.timeIntervalSince($0.timestamp) <= 7 * 24 * 60 * 60
3336 }
3337 let recentCriticalChange = recentEntries.contains {
3338 guard let changeSummary = $0.changeSummary else { return false }
3339 return changeSummary.impactClassification == .critical
3340 && now.timeIntervalSince($0.timestamp) <= 7 * 24 * 60 * 60
3341 }
3342 let certificateExpiryState = trackedDomain.certificateWarningLevel == .none
3343 ? report.certificateExpiryState
3344 : trackedDomain.certificateWarningLevel
3345 let isUnreachable: Bool = {
3346 if let latestEntry {
3347 if !latestEntry.reachabilityResults.isEmpty {
3348 return !latestEntry.reachabilityResults.contains(where: \.reachable)
3349 }
3350 if latestEntry.reachabilityError != nil {
3351 return true
3352 }
3353 }
3354 return recentFailureResults.contains { ($0.alertSeverity ?? .info) == .critical && $0.errorMessage != nil }
3355 }()
3356 let hasInvalidTLS = latestEntry?.sslInfo == nil && latestEntry?.sslError != nil
3357 let instabilityScore = DomainHealth.instabilityScore(
3358 recentChangeCount: recentChangeCount,
3359 recentFailureCount: recentFailureResults.count,
3360 pendingAlertCount: trackedDomain.pendingMonitoringAlerts.count,
3361 hasRecentDNSChange: recentDNSChange
3362 )
3363 let health = DomainHealth.classify(
3364 certificateExpiryState: certificateExpiryState,
3365 isReachable: !isUnreachable,
3366 recentMonitoringFailureCount: recentFailureResults.count,
3367 hasRecentDNSChange: recentDNSChange,
3368 instabilityScore: instabilityScore,
3369 hasRecentCriticalChange: recentCriticalChange,
3370 hasInvalidTLS: hasInvalidTLS
3371 )
3372
3373 return PortfolioDomainStatus(
3374 trackedDomain: trackedDomain,
3375 latestEntry: latestEntry,
3376 report: report,
3377 apexDomain: apexDomain(for: trackedDomain.domain),
3378 health: health,
3379 lastChangeDate: latestEntry?.changeSummary?.hasChanges == true
3380 ? latestEntry?.timestamp
3381 : trackedDomain.monitoringState.lastChangeDate ?? report.lastChangeDate,
3382 lastMonitoringFailure: recentFailureResults.first?.checkedAt,
3383 instabilityScore: instabilityScore,
3384 certificateExpiryState: certificateExpiryState,
3385 certificateDaysRemaining: trackedDomain.certificateDaysRemaining ?? latestEntry?.sslInfo?.daysUntilExpiry,
3386 isUnreachable: isUnreachable,
3387 recentDNSChange: recentDNSChange,
3388 recentCriticalChange: recentCriticalChange,
3389 recentFailureCount: recentFailureResults.count
3390 )
3391 }
3392 }
3393
3394 private func buildPortfolioActivity(from domainStates: [PortfolioDomainStatus]) -> [PortfolioActivityItem] {
3395 var items: [PortfolioActivityItem] = []
3396 let healthByDomainID = Dictionary(uniqueKeysWithValues: domainStates.map { ($0.trackedDomain.id, $0.health) })
3397
3398 for state in domainStates {
3399 if let latestEntry = state.latestEntry,
3400 let changeSummary = latestEntry.changeSummary,
3401 changeSummary.hasChanges {
3402 items.append(
3403 PortfolioActivityItem(
3404 id: "history|\(latestEntry.id.uuidString)",
3405 trackedDomainID: state.trackedDomain.id,
3406 domain: state.trackedDomain.domain,
3407 message: portfolioHistoryMessage(for: latestEntry),
3408 timestamp: latestEntry.timestamp,
3409 health: state.health,
3410 systemImage: portfolioHistoryIcon(for: latestEntry)
3411 )
3412 )
3413 }
3414 }
3415
3416 for log in monitoringLogs.prefix(25) {
3417 for result in log.checkedDomains where result.didChange || result.errorMessage != nil || result.certificateWarningLevel != .none {
3418 guard let trackedDomain = trackedDomain(for: result.domain) else { continue }
3419 items.append(
3420 PortfolioActivityItem(
3421 id: "monitoring|\(log.id.uuidString)|\(result.id.uuidString)",
3422 trackedDomainID: trackedDomain.id,
3423 domain: trackedDomain.domain,
3424 message: portfolioMonitoringMessage(for: result),
3425 timestamp: result.checkedAt,
3426 health: healthByDomainID[trackedDomain.id] ?? (result.alertSeverity == .critical ? .critical : .warning),
3427 systemImage: portfolioMonitoringIcon(for: result)
3428 )
3429 )
3430 }
3431 }
3432
3433 var seen = Set<String>()
3434 return items
3435 .sorted { $0.timestamp > $1.timestamp }
3436 .filter { item in
3437 let key = "\(item.domain.lowercased())|\(item.message.lowercased())"
3438 return seen.insert(key).inserted
3439 }
3440 }
3441
3442 private func buildAttentionQueue(from domainStates: [PortfolioDomainStatus]) -> [PortfolioAttentionItem] {
3443 domainStates.compactMap { state in
3444 guard state.health != .healthy else { return nil }
3445 let reason: String
3446 let timestamp: Date
3447
3448 if state.isUnreachable {
3449 reason = "Endpoint is unreachable"
3450 timestamp = state.lastMonitoringFailure ?? state.trackedDomain.updatedAt
3451 } else if state.certificateExpiryState == .critical {
3452 reason = "Certificate is expiring in under 14 days"
3453 timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
3454 } else if state.certificateExpiryState == .warning {
3455 reason = "Certificate expires within 30 days"
3456 timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
3457 } else if state.recentFailureCount >= 2 || state.instabilityScore >= 70 {
3458 reason = "Repeated instability detected"
3459 timestamp = state.lastMonitoringFailure ?? state.trackedDomain.updatedAt
3460 } else if state.recentDNSChange {
3461 reason = "DNS changed recently"
3462 timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
3463 } else if state.recentCriticalChange {
3464 reason = "Recent critical change detected"
3465 timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
3466 } else {
3467 reason = "Needs attention"
3468 timestamp = state.trackedDomain.updatedAt
3469 }
3470
3471 return PortfolioAttentionItem(
3472 id: "\(state.trackedDomain.id.uuidString)|\(reason)",
3473 trackedDomainID: state.trackedDomain.id,
3474 domain: state.trackedDomain.domain,
3475 reason: reason,
3476 timestamp: timestamp,
3477 health: state.health
3478 )
3479 }
3480 .sorted { lhs, rhs in
3481 if lhs.health != rhs.health {
3482 return healthRank(lhs.health) > healthRank(rhs.health)
3483 }
3484 return lhs.timestamp > rhs.timestamp
3485 }
3486 }
3487
3488 private func buildPortfolioGroups(from domainStates: [PortfolioDomainStatus]) -> [PortfolioGroup] {
3489 Dictionary(grouping: domainStates, by: \.apexDomain)
3490 .map { apexDomain, domains in
3491 PortfolioGroup(
3492 apexDomain: apexDomain,
3493 domains: domains.sorted { lhs, rhs in
3494 if lhs.health != rhs.health {
3495 return healthRank(lhs.health) > healthRank(rhs.health)
3496 }
3497 return lhs.trackedDomain.domain.localizedCaseInsensitiveCompare(rhs.trackedDomain.domain) == .orderedAscending
3498 }
3499 )
3500 }
3501 .sorted { lhs, rhs in
3502 if let lhsMostSevere = lhs.domains.map(\.health).map(healthRank).max(),
3503 let rhsMostSevere = rhs.domains.map(\.health).map(healthRank).max(),
3504 lhsMostSevere != rhsMostSevere {
3505 return lhsMostSevere > rhsMostSevere
3506 }
3507 return lhs.apexDomain.localizedCaseInsensitiveCompare(rhs.apexDomain) == .orderedAscending
3508 }
3509 }
3510
3511 private func matchesPortfolioFilter(_ state: PortfolioDomainStatus) -> Bool {
3512 switch dashboardFilter {
3513 case .all:
3514 return true
3515 case .healthy:
3516 return state.health == .healthy
3517 case .warning:
3518 return state.health == .warning
3519 case .critical:
3520 return state.health == .critical
3521 case .changed:
3522 guard let lastChangeDate = state.lastChangeDate else { return false }
3523 return Date().timeIntervalSince(lastChangeDate) <= 24 * 60 * 60
3524 case .expiring:
3525 return state.certificateExpiryState != .none
3526 case .unreachable:
3527 return state.isUnreachable
3528 }
3529 }
3530
3531 private func matchesDashboardSearch(_ state: PortfolioDomainStatus, query: String) -> Bool {
3532 guard !query.isEmpty else { return true }
3533 let normalizedQuery = query.lowercased()
3534 return state.trackedDomain.domain.lowercased().contains(normalizedQuery)
3535 || state.apexDomain.lowercased().contains(normalizedQuery)
3536 }
3537
3538 private func portfolioHistoryMessage(for entry: HistoryEntry) -> String {
3539 guard let summary = entry.changeSummary else {
3540 return "Configuration changed for \(entry.domain)"
3541 }
3542 if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") }) {
3543 return "DNS changed for \(entry.domain)"
3544 }
3545 if summary.changedSections.contains(where: {
3546 $0.localizedCaseInsensitiveContains("certificate")
3547 || $0.localizedCaseInsensitiveContains("tls")
3548 }) {
3549 return "Certificate updated for \(entry.domain)"
3550 }
3551 if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("redirect") }) {
3552 return "Redirect chain changed for \(entry.domain)"
3553 }
3554 return summary.message
3555 }
3556
3557 private func portfolioHistoryIcon(for entry: HistoryEntry) -> String {
3558 guard let summary = entry.changeSummary else { return "clock.arrow.trianglehead.counterclockwise.rotate.90" }
3559 if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") }) {
3560 return "point.3.connected.trianglepath.dotted"
3561 }
3562 if summary.changedSections.contains(where: {
3563 $0.localizedCaseInsensitiveContains("certificate")
3564 || $0.localizedCaseInsensitiveContains("tls")
3565 }) {
3566 return "lock.rotation"
3567 }
3568 if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("redirect") }) {
3569 return "arrow.triangle.branch"
3570 }
3571 return "clock.arrow.trianglehead.counterclockwise.rotate.90"
3572 }
3573
3574 private func portfolioMonitoringMessage(for result: MonitoringDomainResult) -> String {
3575 if result.errorMessage != nil {
3576 return "Monitoring failed for \(result.domain)"
3577 }
3578 if result.certificateWarningLevel != .none {
3579 return "Certificate needs attention for \(result.domain)"
3580 }
3581 if result.didChange {
3582 return result.summaryMessage.isEmpty ? "Monitoring detected a change for \(result.domain)" : result.summaryMessage
3583 }
3584 return "Monitoring updated \(result.domain)"
3585 }
3586
3587 private func portfolioMonitoringIcon(for result: MonitoringDomainResult) -> String {
3588 if result.errorMessage != nil {
3589 return "xmark.octagon.fill"
3590 }
3591 if result.certificateWarningLevel != .none {
3592 return "exclamationmark.triangle.fill"
3593 }
3594 return "waveform.path.ecg"
3595 }
3596
3597 private func apexDomain(for domain: String) -> String {
3598 let parts = domain
3599 .lowercased()
3600 .split(separator: ".")
3601 .map(String.init)
3602 guard parts.count > 2 else { return domain.lowercased() }
3603 return parts.suffix(2).joined(separator: ".")
3604 }
3605
3606 private func healthRank(_ health: DomainHealth) -> Int {
3607 switch health {
3608 case .healthy:
3609 return 0
3610 case .warning:
3611 return 1
3612 case .critical:
3613 return 2
3614 }
3615 }
3616
3617 func resolverMismatchNote(for entry: HistoryEntry) -> String? {
3618 guard entry.resolverURLString != resolverURLString else { return nil }
3619 return "Current resolver differs from this snapshot. Re-running may produce different evidence."
3620 }
3621
3622 func resolverMismatchNote(for trackedDomain: TrackedDomain) -> String? {
3623 guard let snapshot = latestSnapshot(for: trackedDomain), snapshot.resolverURLString != resolverURLString else {
3624 return nil
3625 }
3626 return "Current resolver differs from the latest snapshot for this tracked domain."
3627 }
3628
3629 func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? {
3630 previousHistoryEntry(for: entry)?.snapshot
3631 }
3632
3633 func timelineEntries(for domain: String) -> [SnapshotSummary] {
3634 historyEntries(for: domain).map(\.snapshotSummary)
3635 }
3636
3637 func timelineSections(for domain: String, grouping: TimelineGroupingOption? = nil) -> [TimelineSection] {
3638 let entries = timelineEntries(for: domain)
3639 let grouping = grouping ?? timelineGrouping
3640
3641 guard grouping == .relativeDay else {
3642 return entries.isEmpty ? [] : [TimelineSection(id: "all", title: "All Snapshots", entries: entries)]
3643 }
3644
3645 let calendar = Calendar.current
3646 let today = Date()
3647 let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today
3648 let grouped = Dictionary(grouping: entries) { entry -> String in
3649 if calendar.isDate(entry.timestamp, inSameDayAs: today) {
3650 return "Today"
3651 }
3652 if calendar.isDate(entry.timestamp, inSameDayAs: yesterday) {
3653 return "Yesterday"
3654 }
3655 return "Older"
3656 }
3657
3658 return ["Today", "Yesterday", "Older"].compactMap { title in
3659 guard let items = grouped[title], !items.isEmpty else { return nil }
3660 return TimelineSection(id: title.lowercased(), title: title, entries: items)
3661 }
3662 }
3663
3664 func historyEntries(for domain: String) -> [HistoryEntry] {
3665 history
3666 .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
3667 .sorted { $0.timestamp > $1.timestamp }
3668 }
3669
3670 func historyEntry(withID id: UUID) -> HistoryEntry? {
3671 history.first(where: { $0.id == id })
3672 }
3673
3674 func previousHistoryEntry(for entry: HistoryEntry) -> HistoryEntry? {
3675 let siblings = historyEntries(for: entry.domain)
3676 guard let index = siblings.firstIndex(where: { $0.id == entry.id }) else { return nil }
3677 let nextIndex = index + 1
3678 guard siblings.indices.contains(nextIndex) else { return nil }
3679 return siblings[nextIndex]
3680 }
3681
3682 func toggleSnapshotSelection(_ entry: HistoryEntry) {
3683 if selectedSnapshotIDs.contains(entry.id) {
3684 selectedSnapshotIDs.remove(entry.id)
3685 } else if selectedSnapshotIDs.count < 2 {
3686 selectedSnapshotIDs.insert(entry.id)
3687 } else if let oldest = selectedSnapshotIDs.first {
3688 selectedSnapshotIDs.remove(oldest)
3689 selectedSnapshotIDs.insert(entry.id)
3690 }
3691 }
3692
3693 func clearSnapshotSelection() {
3694 selectedSnapshotIDs.removeAll()
3695 }
3696
3697 var selectedSnapshots: [HistoryEntry] {
3698 selectedSnapshotIDs.compactMap(historyEntry(withID:)).sorted { $0.timestamp < $1.timestamp }
3699 }
3700
3701 @discardableResult
3702 func generateDiffForSelectedSnapshots(focusSectionID: String? = nil) -> DomainDiff? {
3703 guard selectedSnapshots.count == 2 else {
3704 activeDomainDiff = nil
3705 activeDiffChangeIndex = 0
3706 return nil
3707 }
3708
3709 let diff = DiffService.compare(from: selectedSnapshots[0].snapshot, to: selectedSnapshots[1].snapshot)
3710 activeDomainDiff = diff
3711 if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) {
3712 activeDiffChangeIndex = index
3713 } else {
3714 activeDiffChangeIndex = 0
3715 }
3716 return diff
3717 }
3718
3719 func generateDiff(from olderEntry: HistoryEntry, to newerEntry: HistoryEntry, focusSectionID: String? = nil) -> DomainDiff {
3720 selectedSnapshotIDs = [olderEntry.id, newerEntry.id]
3721 let diff = DiffService.compare(from: olderEntry.snapshot, to: newerEntry.snapshot)
3722 activeDomainDiff = diff
3723 if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) {
3724 activeDiffChangeIndex = index
3725 } else {
3726 activeDiffChangeIndex = 0
3727 }
3728 return diff
3729 }
3730
3731 func historyEntry(for batchResult: BatchLookupResult) -> HistoryEntry? {
3732 guard let historyEntryID = batchResult.historyEntryID else { return nil }
3733 return history.first(where: { $0.id == historyEntryID })
3734 }
3735
3736 var currentDiffTargetSectionID: String? {
3737 guard let activeDomainDiff, activeDomainDiff.changedSectionIDs.indices.contains(activeDiffChangeIndex) else {
3738 return nil
3739 }
3740 return activeDomainDiff.changedSectionIDs[activeDiffChangeIndex]
3741 }
3742
3743 func moveToNextDiffChange() {
3744 guard let activeDomainDiff, !activeDomainDiff.changedSectionIDs.isEmpty else { return }
3745 activeDiffChangeIndex = min(activeDiffChangeIndex + 1, activeDomainDiff.changedSectionIDs.count - 1)
3746 }
3747
3748 func moveToPreviousDiffChange() {
3749 guard activeDomainDiff != nil else { return }
3750 activeDiffChangeIndex = max(activeDiffChangeIndex - 1, 0)
3751 }
3752
3753 private func exportSnapshots(for domains: [TrackedDomain]) -> [LookupSnapshot] {
3754 let latestEntries = latestSnapshots(for: domains)
3755
3756 return domains.map { trackedDomain in
3757 if let entry = latestEntries.first(where: { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }) {
3758 return entry.snapshot
3759 }
3760 return placeholderSnapshot(for: trackedDomain)
3761 }
3762 }
3763
3764 private func currentBatchReports() -> [DomainReport] {
3765 currentBatchResultEntries.map { entry in
3766 report(for: entry, workflowContext: activeWorkflowContext)
3767 }
3768 }
3769
3770 private func workflowReports(from summary: WorkflowRunSummary, changedOnly: Bool) -> [DomainReport] {
3771 let filteredResults = changedOnly ? summary.results.filter(\.hasMeaningfulChange) : summary.results
3772 return filteredResults.compactMap { result in
3773 guard let entry = historyEntry(for: result) else { return nil }
3774 return report(
3775 for: entry,
3776 workflowContext: DomainWorkflowContext(
3777 workflowID: summary.workflowID,
3778 workflowName: summary.workflowName,
3779 source: "workflow"
3780 )
3781 )
3782 }
3783 }
3784
3785 private func reports(for domains: [TrackedDomain]) -> [DomainReport] {
3786 let latestEntries = latestSnapshots(for: domains)
3787
3788 return domains.map { trackedDomain in
3789 if let entry = latestEntries.first(where: {
3790 $0.trackedDomainID == trackedDomain.id ||
3791 $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame
3792 }) {
3793 return report(for: entry)
3794 }
3795
3796 return reportBuilder.build(from: placeholderSnapshot(for: trackedDomain))
3797 }
3798 }
3799
3800 private func timelineReports(for domain: String) -> [DomainReport] {
3801 historyEntries(for: domain).map { report(for: $0) }
3802 }
3803
3804 private func report(for entry: HistoryEntry, workflowContext: DomainWorkflowContext? = nil) -> DomainReport {
3805 reportBuilder.build(
3806 from: entry,
3807 previousSnapshot: comparisonSnapshot(for: entry),
3808 workflowContext: workflowContext,
3809 historyEntries: historyEntries(for: entry.domain)
3810 )
3811 }
3812
3813 private var activeWorkflowContext: DomainWorkflowContext? {
3814 guard batchLookupSource == .workflow, let activeWorkflowRunID, let activeWorkflowRunName else {
3815 return nil
3816 }
3817 return DomainWorkflowContext(
3818 workflowID: activeWorkflowRunID,
3819 workflowName: activeWorkflowRunName,
3820 source: "workflow"
3821 )
3822 }
3823
3824 private static var defaultAuditReviewer: String {
3825 let reviewer = NSFullUserName().trimmingCharacters(in: .whitespacesAndNewlines)
3826 return reviewer.isEmpty ? "Local Reviewer" : reviewer
3827 }
3828
3829 private func snapshotEvidenceAssets(from report: DomainReport) -> [AuditEvidenceAsset] {
3830 var assets: [AuditEvidenceAsset] = []
3831 if let finalURL = report.web.finalURL {
3832 assets.append(AuditEvidenceAsset(title: "Final URL", kind: .document, reference: finalURL))
3833 }
3834 if let registrar = report.ownership?.registrar {
3835 assets.append(AuditEvidenceAsset(title: "Registrar", kind: .document, reference: registrar))
3836 }
3837 if let primaryIP = report.dns.primaryIP {
3838 assets.append(AuditEvidenceAsset(title: "Primary IP", kind: .document, reference: primaryIP))
3839 }
3840 if !report.web.redirectChain.isEmpty {
3841 assets.append(
3842 AuditEvidenceAsset(
3843 title: "Redirect Chain",
3844 kind: .document,
3845 reference: report.web.redirectChain.map { "\($0.statusCode) \($0.url)" }.joined(separator: " | ")
3846 )
3847 )
3848 }
3849 if !report.web.headers.isEmpty {
3850 assets.append(
3851 AuditEvidenceAsset(
3852 title: "Observed Headers",
3853 kind: .document,
3854 reference: report.web.headers.prefix(6).map { "\($0.name): \($0.value)" }.joined(separator: " | ")
3855 )
3856 )
3857 }
3858 return assets
3859 }
3860
3861 private func placeholderSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot {
3862 LookupSnapshot(
3863 historyEntryID: trackedDomain.lastSnapshotID,
3864 domain: trackedDomain.domain,
3865 timestamp: trackedDomain.updatedAt,
3866 trackedDomainID: trackedDomain.id,
3867 note: trackedDomain.note,
3868 appVersion: AppVersion.current,
3869 resolverDisplayName: resolverDisplayName,
3870 resolverURLString: resolverURLString,
3871 dataSources: [],
3872 provenanceBySection: [:],
3873 availabilityConfidence: nil,
3874 ownershipConfidence: nil,
3875 subdomainConfidence: nil,
3876 emailSecurityConfidence: nil,
3877 geolocationConfidence: nil,
3878 errorDetails: [:],
3879 isPartialSnapshot: true,
3880 validationIssues: ["No stored snapshot data available"],
3881 totalLookupDurationMs: nil,
3882 snapshotIndex: nil,
3883 previousSnapshotID: nil,
3884 changeCount: 0,
3885 severitySummary: trackedDomain.lastChangeSeverity,
3886 dnsSections: [],
3887 dnsError: nil,
3888 availabilityResult: DomainAvailabilityResult(domain: trackedDomain.domain, status: trackedDomain.lastKnownAvailability ?? .unknown),
3889 suggestions: [],
3890 sslInfo: nil,
3891 sslError: nil,
3892 hstsPreloaded: nil,
3893 httpHeaders: [],
3894 httpSecurityGrade: nil,
3895 httpStatusCode: nil,
3896 httpResponseTimeMs: nil,
3897 httpProtocol: nil,
3898 http3Advertised: false,
3899 httpHeadersError: nil,
3900 reachabilityResults: [],
3901 reachabilityError: nil,
3902 ipGeolocation: nil,
3903 ipGeolocationError: nil,
3904 emailSecurity: nil,
3905 emailSecurityError: nil,
3906 ownership: nil,
3907 ownershipError: nil,
3908 ownershipHistory: [],
3909 ownershipHistoryError: nil,
3910 inferredProvider: nil,
3911 priorProviders: [],
3912 domainClassification: nil,
3913 ownershipTransitions: [],
3914 hostingTransitions: [],
3915 subdomainHistory: [],
3916 riskSignals: [],
3917 intelligenceTimeline: [],
3918 ptrRecord: nil,
3919 ptrError: nil,
3920 redirectChain: [],
3921 redirectChainError: nil,
3922 subdomains: [],
3923 subdomainsError: nil,
3924 extendedSubdomains: [],
3925 extendedSubdomainsError: nil,
3926 dnsHistory: [],
3927 dnsHistoryError: nil,
3928 domainPricing: nil,
3929 domainPricingError: nil,
3930 reputation: nil,
3931 reputationError: nil,
3932 portScanResults: [],
3933 portScanError: nil,
3934 changeSummary: trackedDomain.lastChangeSummary,
3935 resultSource: .snapshot,
3936 cachedSections: [],
3937 statusMessage: nil
3938 )
3939 }
3940
3941 private static func loadHistoryEntries() -> [HistoryEntry] {
3942 DataMigrationService.migrateIfNeeded()
3943 return DomainDataPortabilityService.loadHistoryEntries()
3944 }
3945
3946 private static func loadHistoryAutoPruneOption() -> HistoryAutoPruneOption {
3947 guard let rawValue = UserDefaults.standard.string(forKey: historyAutoPruneKey),
3948 let option = HistoryAutoPruneOption(rawValue: rawValue) else {
3949 return .unlimited
3950 }
3951 return option
3952 }
3953
3954 private static func loadTrackedDomains() -> [TrackedDomain] {
3955 DataMigrationService.migrateIfNeeded()
3956 return DomainDataPortabilityService.loadTrackedDomains()
3957 }
3958
3959 private func persistWorkflows() {
3960 DomainDataPortabilityService.saveWorkflows(workflows)
3961 CloudSyncService.shared.scheduleSyncIfNeeded()
3962 refreshDataLifecycleSummary()
3963 }
3964
3965 private static func loadWorkflows() -> [DomainWorkflow] {
3966 DataMigrationService.migrateIfNeeded()
3967 return DomainDataPortabilityService.loadWorkflows()
3968 }
3969
3970 private static func deduplicatedTrackedDomains(_ domains: [TrackedDomain]) -> [TrackedDomain] {
3971 var seen = Set<String>()
3972 return domains.filter { domain in
3973 let key = domain.domain.lowercased()
3974 return seen.insert(key).inserted
3975 }
3976 }
3977
3978 private func normalizedDomains(_ domains: [String]) -> [String] {
3979 var seen = Set<String>()
3980 return domains
3981 .map(normalizedDomain)
3982 .filter { !$0.isEmpty }
3983 .filter { seen.insert($0).inserted }
3984 }
3985
3986 private func historySortPredicate(lhs: HistoryEntry, rhs: HistoryEntry) -> Bool {
3987 switch historySortOption {
3988 case .newest:
3989 return lhs.timestamp > rhs.timestamp
3990 case .oldest:
3991 return lhs.timestamp < rhs.timestamp
3992 case .domain:
3993 let domainOrder = lhs.domain.localizedCaseInsensitiveCompare(rhs.domain)
3994 if domainOrder != .orderedSame {
3995 return domainOrder == .orderedAscending
3996 }
3997 return lhs.timestamp > rhs.timestamp
3998 }
3999 }
4000
4001 private func sortedTrackedDomains(from domains: [TrackedDomain], using sortOption: WatchlistSortOption) -> [TrackedDomain] {
4002 domains.sorted { lhs, rhs in
4003 switch sortOption {
4004 case .pinned:
4005 if lhs.isPinned != rhs.isPinned {
4006 return lhs.isPinned && !rhs.isPinned
4007 }
4008 if lhs.updatedAt != rhs.updatedAt {
4009 return lhs.updatedAt > rhs.updatedAt
4010 }
4011 return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending
4012 case .recentlyUpdated:
4013 if lhs.updatedAt != rhs.updatedAt {
4014 return lhs.updatedAt > rhs.updatedAt
4015 }
4016 return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending
4017 case .alphabetical:
4018 let domainOrder = lhs.domain.localizedCaseInsensitiveCompare(rhs.domain)
4019 if domainOrder != .orderedSame {
4020 return domainOrder == .orderedAscending
4021 }
4022 return lhs.updatedAt > rhs.updatedAt
4023 }
4024 }
4025 }
4026
4027 static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] {
4028 [
4029 SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary),
4030 SummaryFieldViewData(label: "Observed IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary),
4031 SummaryFieldViewData(label: "Observed Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary),
4032 SummaryFieldViewData(label: "Inference", value: availabilityInference(from: snapshot), tone: availabilityTone(snapshot.availabilityResult?.status)),
4033 SummaryFieldViewData(label: "Observed TLS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)),
4034 SummaryFieldViewData(label: "Certificate", value: certificateStatusLabel(from: snapshot), tone: certificateStatusTone(from: snapshot)),
4035 SummaryFieldViewData(label: "Source", value: snapshot.statusMessage ?? snapshot.resultSource.label, tone: sourceTone(for: snapshot))
4036 ]
4037 }
4038
4039 static func domainRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
4040 var rows = [
4041 InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary),
4042 InfoRowViewData(label: "Resolver", value: snapshot.resolverDisplayName, tone: .secondary),
4043 InfoRowViewData(label: "Collected", value: snapshot.timestamp.formatted(date: .abbreviated, time: .shortened), tone: .secondary),
4044 InfoRowViewData(label: snapshot.statusMessage == nil ? "Result" : "Snapshot", value: snapshot.statusMessage ?? snapshot.resultSource.label, tone: sourceTone(for: snapshot)),
4045 InfoRowViewData(label: "Lookup Duration", value: durationLabel(snapshot.totalLookupDurationMs), tone: .secondary)
4046 ]
4047 rows.insert(
4048 InfoRowViewData(
4049 label: "Observed Availability",
4050 value: snapshot.availabilityResult?.status == .unknown ? "No direct registration proof" : "Status collected",
4051 tone: .secondary
4052 ),
4053 at: 1
4054 )
4055 rows.insert(
4056 InfoRowViewData(
4057 label: "Inference",
4058 value: availabilityInference(from: snapshot),
4059 tone: availabilityTone(snapshot.availabilityResult?.status)
4060 ),
4061 at: 2
4062 )
4063 if let confidence = snapshot.availabilityConfidence {
4064 rows.insert(
4065 InfoRowViewData(label: "Confidence", value: confidence.title, tone: .secondary),
4066 at: 3
4067 )
4068 }
4069 if let pricing = snapshot.domainPricing {
4070 rows.append(
4071 InfoRowViewData(
4072 label: "External Price",
4073 value: pricing.estimatedPrice ?? "Unavailable",
4074 tone: .secondary
4075 )
4076 )
4077 if let premiumIndicator = pricing.premiumIndicator {
4078 rows.append(
4079 InfoRowViewData(
4080 label: "Premium",
4081 value: premiumIndicator ? "Yes" : "No",
4082 tone: premiumIndicator ? .warning : .secondary
4083 )
4084 )
4085 }
4086 if let resaleSignal = pricing.resaleSignal {
4087 rows.append(InfoRowViewData(label: "Resale", value: resaleSignal, tone: .secondary))
4088 }
4089 if let auctionSignal = pricing.auctionSignal {
4090 rows.append(InfoRowViewData(label: "Auction", value: auctionSignal, tone: .secondary))
4091 }
4092 }
4093 if let reputation = snapshot.reputation {
4094 let tone: ResultTone
4095 switch reputation.status {
4096 case .clean: tone = .success
4097 case .listed: tone = .failure
4098 case .unknown: tone = .secondary
4099 }
4100 let value = reputation.status == .listed && !reputation.listedSources.isEmpty
4101 ? "\(reputation.status.title) (\(reputation.listedSources.joined(separator: ", ")))"
4102 : reputation.status.title
4103 rows.append(InfoRowViewData(label: "Reputation", value: value, tone: tone))
4104 }
4105 if let certificateStatus = certificateBadgeLabel(from: snapshot) {
4106 rows.insert(
4107 InfoRowViewData(
4108 label: "Certificate",
4109 value: certificateStatus,
4110 tone: certificateStatusTone(from: snapshot)
4111 ),
4112 at: 2
4113 )
4114 }
4115 return rows
4116 }
4117
4118 static func suggestionRows(from snapshot: LookupSnapshot) -> [DomainSuggestionViewData] {
4119 snapshot.suggestions.map {
4120 DomainSuggestionViewData(
4121 id: $0.id,
4122 domain: $0.domain,
4123 availabilityStatus: $0.status,
4124 status: availabilityLabel($0.status),
4125 tone: availabilityTone($0.status)
4126 )
4127 }
4128 }
4129
4130 static func subdomainRows(from subdomains: [DiscoveredSubdomain]) -> [SubdomainRowViewData] {
4131 subdomains.map { subdomain in
4132 SubdomainRowViewData(
4133 hostname: subdomain.hostname,
4134 isInteresting: subdomain.isExtended || isInterestingSubdomain(subdomain.hostname)
4135 )
4136 }
4137 }
4138
4139 static func dnsRows(from snapshot: LookupSnapshot) -> [DNSRecordSectionViewData] {
4140 snapshot.dnsSections.map { section in
4141 DNSRecordSectionViewData(
4142 title: section.recordType.rawValue,
4143 rows: section.records.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary) },
4144 wildcardRows: section.wildcardRecords.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary) },
4145 wildcardTitle: section.wildcardRecords.isEmpty ? nil : "*.\(snapshot.domain)",
4146 message: section.error.map { SectionMessageViewData(text: $0, isError: true) } ??
4147 ((section.records.isEmpty && section.wildcardRecords.isEmpty) ? SectionMessageViewData(text: "No records found", isError: false) : nil)
4148 )
4149 }
4150 }
4151
4152 static func dnssecLabel(from snapshot: LookupSnapshot) -> String? {
4153 guard let signed = snapshot.dnsSections.compactMap(\.dnssecSigned).first else { return nil }
4154 return "Resolver-reported DNSSEC (not full validation): \(signed ? "Yes" : "No")"
4155 }
4156
4157 static func ptrMessage(from snapshot: LookupSnapshot) -> SectionMessageViewData? {
4158 if let ptrRecord = snapshot.ptrRecord {
4159 return SectionMessageViewData(text: ptrRecord, isError: false)
4160 }
4161 if let ptrError = snapshot.ptrError {
4162 return SectionMessageViewData(text: ptrError, isError: ptrError != "No A record available" && ptrError != "No PTR record found")
4163 }
4164 return nil
4165 }
4166
4167 static func webCertificateRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
4168 guard let sslInfo = snapshot.sslInfo else { return [] }
4169 var rows = [
4170 InfoRowViewData(label: "Common Name", value: sslInfo.commonName, tone: .primary),
4171 InfoRowViewData(label: "Issuer", value: sslInfo.issuer, tone: .primary),
4172 InfoRowViewData(label: "Valid From", value: certificateDateFormatter.string(from: sslInfo.validFrom), tone: .secondary),
4173 InfoRowViewData(label: "Valid Until", value: certificateDateFormatter.string(from: sslInfo.validUntil), tone: .secondary),
4174 InfoRowViewData(label: "Days Until Expiry", value: "\(sslInfo.daysUntilExpiry)", tone: certificateTone(daysRemaining: sslInfo.daysUntilExpiry)),
4175 InfoRowViewData(label: "Chain Depth", value: "\(sslInfo.chainDepth)", tone: .secondary)
4176 ]
4177 if let tlsVersion = sslInfo.tlsVersion {
4178 rows.append(InfoRowViewData(label: "TLS Version", value: tlsVersion, tone: .secondary))
4179 }
4180 if let cipherSuite = sslInfo.cipherSuite {
4181 rows.append(InfoRowViewData(label: "Cipher Suite", value: cipherSuite, tone: .secondary))
4182 }
4183 if let hstsPreloaded = snapshot.hstsPreloaded {
4184 rows.append(InfoRowViewData(label: "HSTS Preload", value: hstsPreloaded ? "Preloaded" : "Not preloaded", tone: hstsPreloaded ? .success : .secondary))
4185 }
4186 return rows
4187 }
4188
4189 static func webResponseRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
4190 var rows: [InfoRowViewData] = []
4191 if let httpStatusCode = snapshot.httpStatusCode {
4192 rows.append(InfoRowViewData(label: "Status", value: "\(httpStatusCode)", tone: .primary))
4193 }
4194 if let httpResponseTimeMs = snapshot.httpResponseTimeMs {
4195 rows.append(InfoRowViewData(label: "Response Time", value: "\(httpResponseTimeMs) ms", tone: .secondary))
4196 }
4197 if let httpProtocol = snapshot.httpProtocol {
4198 rows.append(InfoRowViewData(label: "Protocol", value: httpProtocol, tone: .secondary))
4199 }
4200 if let httpSecurityGrade = snapshot.httpSecurityGrade {
4201 rows.append(InfoRowViewData(label: "Security Grade", value: httpSecurityGrade, tone: securityGradeTone(httpSecurityGrade)))
4202 }
4203 if snapshot.http3Advertised {
4204 rows.append(InfoRowViewData(label: "HTTP/3", value: "Advertised", tone: .secondary))
4205 }
4206 return rows
4207 }
4208
4209 static func redirectRows(from snapshot: LookupSnapshot) -> [RedirectHopViewData] {
4210 snapshot.redirectChain.map {
4211 RedirectHopViewData(
4212 stepLabel: "\($0.stepNumber)",
4213 statusCode: "\($0.statusCode)",
4214 url: $0.url,
4215 isFinal: $0.isFinal
4216 )
4217 }
4218 }
4219
4220 static func emailRows(from snapshot: LookupSnapshot) -> [EmailRowViewData] {
4221 guard let emailSecurity = snapshot.emailSecurity else { return [] }
4222 return [
4223 EmailRowViewData(label: "SPF", status: emailSecurity.spf.found ? "Present" : "Missing", statusTone: emailSecurity.spf.found ? .success : .warning, detail: emailSecurity.spf.value ?? "No record found", auxiliaryDetail: nil),
4224 EmailRowViewData(label: "DMARC", status: emailSecurity.dmarc.found ? "Present" : "Missing", statusTone: emailSecurity.dmarc.found ? .success : .warning, detail: emailSecurity.dmarc.value ?? "No record found", auxiliaryDetail: nil),
4225 EmailRowViewData(label: "DKIM", status: emailSecurity.dkim.found ? "Present" : "Missing", statusTone: emailSecurity.dkim.found ? .success : .warning, detail: emailSecurity.dkim.value ?? "No record found", auxiliaryDetail: emailSecurity.dkim.matchedSelector.map { "Selector: \($0)" }),
4226 EmailRowViewData(label: "MTA-STS", status: emailSecurity.mtaSts?.txtFound == true ? "Present" : "Missing", statusTone: emailSecurity.mtaSts?.txtFound == true ? .success : .warning, detail: emailSecurity.mtaSts?.policyMode ?? (emailSecurity.mtaSts?.txtFound == true ? "Policy unavailable" : "No record found"), auxiliaryDetail: nil),
4227 EmailRowViewData(label: "BIMI", status: emailSecurity.bimi.found ? "Present" : "Missing", statusTone: emailSecurity.bimi.found ? .success : .warning, detail: emailSecurity.bimi.value ?? "No record found", auxiliaryDetail: nil)
4228 ]
4229 }
4230
4231 static func ownershipRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
4232 let ownership = snapshot.ownership
4233
4234 return [
4235 InfoRowViewData(label: "Registrar", value: ownership?.registrar ?? "Unavailable", tone: ownership?.registrar == nil ? .secondary : .primary),
4236 InfoRowViewData(label: "Registered", value: ownership?.createdDate.map(ownershipDateFormatter.string(from:)) ?? "Unavailable", tone: ownership?.createdDate == nil ? .secondary : .primary),
4237 InfoRowViewData(label: "Expires", value: ownership?.expirationDate.map(ownershipDateFormatter.string(from:)) ?? "Unavailable", tone: ownership?.expirationDate == nil ? .secondary : .primary),
4238 InfoRowViewData(label: "Status", value: ownership?.status.nilIfEmpty?.joined(separator: ", ") ?? "Unavailable", tone: ownership?.status.isEmpty == false ? .primary : .secondary),
4239 InfoRowViewData(label: "Nameservers", value: ownership?.nameservers.nilIfEmpty?.joined(separator: ", ") ?? "Unavailable", tone: ownership?.nameservers.isEmpty == false ? .primary : .secondary),
4240 InfoRowViewData(label: "Abuse Contact", value: ownership?.abuseEmail ?? "Unavailable", tone: ownership?.abuseEmail == nil ? .secondary : .primary)
4241 ]
4242 }
4243
4244 static func subdomainRows(from snapshot: LookupSnapshot) -> [SubdomainRowViewData] {
4245 snapshot.subdomains.map { subdomain in
4246 SubdomainRowViewData(
4247 hostname: subdomain.hostname,
4248 isInteresting: isInterestingSubdomain(subdomain.hostname)
4249 )
4250 }
4251 }
4252
4253 static func reachabilityRows(from snapshot: LookupSnapshot) -> [ReachabilityRowViewData] {
4254 snapshot.reachabilityResults.map {
4255 ReachabilityRowViewData(
4256 portLabel: "Port \($0.port)",
4257 latencyLabel: $0.latencyMs.map { "\($0) ms" } ?? "—",
4258 statusLabel: $0.reachable ? "Reachable" : "Unreachable",
4259 statusTone: $0.reachable ? .success : .failure
4260 )
4261 }
4262 }
4263
4264 static func locationRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
4265 guard let ipGeolocation = snapshot.ipGeolocation else { return [] }
4266 var rows = [InfoRowViewData(label: "IP", value: ipGeolocation.ip, tone: .primary)]
4267 if let org = ipGeolocation.org {
4268 rows.append(InfoRowViewData(label: "Org / ISP", value: org, tone: .secondary))
4269 }
4270 let location = [ipGeolocation.city, ipGeolocation.region, ipGeolocation.country_name].compactMap { $0 }.joined(separator: ", ")
4271 if !location.isEmpty {
4272 rows.append(InfoRowViewData(label: "Location", value: location, tone: .secondary))
4273 }
4274 if let latitude = ipGeolocation.latitude, let longitude = ipGeolocation.longitude {
4275 rows.append(InfoRowViewData(label: "Coordinates", value: "\(latitude), \(longitude)", tone: .secondary))
4276 }
4277 return rows
4278 }
4279
4280 static func portRows(from snapshot: LookupSnapshot, kind: PortScanKind) -> [PortScanRowViewData] {
4281 snapshot.portScanResults
4282 .filter { $0.kind == kind }
4283 .map {
4284 PortScanRowViewData(
4285 portLabel: "\($0.port)",
4286 service: $0.service,
4287 statusLabel: $0.open ? "Open" : "Closed",
4288 statusTone: $0.open ? .success : .secondary,
4289 banner: $0.banner,
4290 durationLabel: $0.durationMs.map { "\($0) ms" }
4291 )
4292 }
4293 }
4294
4295 static func formatBatchExportText(
4296 title: String,
4297 entries: [(snapshot: LookupSnapshot, trackedDomain: TrackedDomain?, changeSummary: DomainChangeSummary?, diffSections: [DomainDiffSection])]
4298 ) -> String {
4299 guard !entries.isEmpty else {
4300 return "\(title)\nNo results available."
4301 }
4302
4303 var lines = [title, String(repeating: "=", count: title.count), ""]
4304 for (index, entry) in entries.enumerated() {
4305 if index > 0 {
4306 lines.append("")
4307 lines.append(String(repeating: "=", count: 48))
4308 lines.append("")
4309 }
4310
4311 lines.append(
4312 formatExportText(
4313 from: entry.snapshot,
4314 trackedDomain: entry.trackedDomain,
4315 changeSummary: entry.changeSummary,
4316 diffSections: entry.diffSections
4317 )
4318 )
4319 }
4320 return lines.joined(separator: "\n")
4321 }
4322
4323 static func formatCSV(from snapshots: [LookupSnapshot]) -> String {
4324 let headers = [
4325 "domain",
4326 "availability",
4327 "primary_ip",
4328 "redirect_target",
4329 "tls_status",
4330 "http_status_grade",
4331 "email_security_summary",
4332 "registrar",
4333 "ownership_expires",
4334 "ownership_status",
4335 "ownership_nameservers",
4336 "subdomain_count",
4337 "subdomains",
4338 "last_updated"
4339 ]
4340
4341 let rows = snapshots.map { snapshot in
4342 [
4343 snapshot.domain,
4344 availabilityLabel(snapshot.availabilityResult?.status),
4345 primaryIPAddress(from: snapshot) ?? "",
4346 finalRedirectTarget(from: snapshot) ?? "",
4347 httpsSummary(from: snapshot),
4348 httpStatusGradeSummary(from: snapshot),
4349 emailSummary(from: snapshot),
4350 snapshot.ownership?.registrar ?? "",
4351 snapshot.ownership?.expirationDate.map(csvDateFormatter.string(from:)) ?? "",
4352 snapshot.ownership?.status.joined(separator: " | ") ?? "",
4353 snapshot.ownership?.nameservers.joined(separator: " | ") ?? "",
4354 "\(snapshot.subdomains.count)",
4355 snapshot.subdomains.map(\.hostname).joined(separator: " | "),
4356 csvDateFormatter.string(from: snapshot.timestamp)
4357 ]
4358 }
4359
4360 return ([headers] + rows)
4361 .map { row in row.map(csvEscaped).joined(separator: ",") }
4362 .joined(separator: "\n")
4363 }
4364
4365 static func formatExportText(
4366 from snapshot: LookupSnapshot,
4367 trackedDomain: TrackedDomain?,
4368 changeSummary: DomainChangeSummary?,
4369 diffSections: [DomainDiffSection]
4370 ) -> String {
4371 let exportDateFormatter = DateFormatter()
4372 exportDateFormatter.dateFormat = "yyyy-MM-dd HH:mm"
4373
4374 var lines: [String] = [
4375 "DomainDig Export",
4376 "Domain: \(snapshot.domain)",
4377 "Date: \(exportDateFormatter.string(from: snapshot.timestamp))",
4378 "Mode: \(snapshot.statusMessage ?? snapshot.resultSource.label)",
4379 "Resolver: \(snapshot.resolverDisplayName)",
4380 "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))",
4381 "Tracked: \(trackedDomain == nil ? "No" : "Yes")"
4382 ]
4383
4384 if let note = trackedDomain?.note?.nilIfEmpty {
4385 lines.append("Tracking Note: \(note)")
4386 }
4387
4388 func appendSection(_ title: String, body: () -> Void) {
4389 lines.append("")
4390 lines.append(title)
4391 lines.append(String(repeating: "-", count: title.count))
4392 body()
4393 }
4394
4395 appendSection("Summary") {
4396 for item in summaryFields(from: snapshot) {
4397 lines.append(" \(item.label): \(item.value)")
4398 }
4399 if let changeSummary {
4400 lines.append(" Change Summary: \(changeSummary.message)")
4401 lines.append(" Severity: \(changeSummary.severity.title)")
4402 lines.append(" Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))")
4403 lines.append(" Compared At: \(exportDateFormatter.string(from: changeSummary.generatedAt))")
4404 }
4405 }
4406
4407 appendSection("Tracking") {
4408 if let trackedDomain {
4409 lines.append(" Pinned: \(trackedDomain.isPinned ? "Yes" : "No")")
4410 lines.append(" Last Refresh: \(exportDateFormatter.string(from: trackedDomain.updatedAt))")
4411 lines.append(" Last Known Availability: \(availabilityLabel(trackedDomain.lastKnownAvailability))")
4412 if let note = trackedDomain.note?.nilIfEmpty {
4413 lines.append(" Note: \(note)")
4414 }
4415 } else {
4416 lines.append(" This domain is not currently tracked.")
4417 }
4418 }
4419
4420 appendSection("Diff Summary") {
4421 if diffSections.isEmpty {
4422 lines.append(" No comparison available")
4423 } else {
4424 for section in diffSections where section.items.contains(where: { $0.changeType != .unchanged }) {
4425 lines.append(" \(section.title)")
4426 for item in section.items where item.changeType != .unchanged {
4427 lines.append(" [\(item.changeType.rawValue.capitalized)] \(item.label): \(item.oldValue ?? "None") -> \(item.newValue ?? "None")")
4428 }
4429 }
4430 }
4431 }
4432
4433 appendSection("Domain") {
4434 for row in domainRows(from: snapshot) {
4435 lines.append(" \(row.label): \(row.value)")
4436 }
4437 if snapshot.suggestions.isEmpty {
4438 lines.append(" Suggestions: None")
4439 } else {
4440 lines.append(" Suggestions:")
4441 for suggestion in snapshot.suggestions {
4442 lines.append(" \(suggestion.domain): \(availabilityLabel(suggestion.status))")
4443 }
4444 }
4445 }
4446
4447 appendSection("Ownership") {
4448 for row in ownershipRows(from: snapshot) {
4449 lines.append(" \(row.label): \(row.value)")
4450 }
4451 if let ownershipError = snapshot.ownershipError, snapshot.ownership == nil {
4452 lines.append(" Source: \(ownershipError)")
4453 }
4454 if !DataAccessService.hasAccess(to: .ownershipHistory) {
4455 lines.append(" Ownership history (coming soon)")
4456 }
4457 }
4458
4459 appendSection("Subdomains") {
4460 lines.append(" Count: \(snapshot.subdomains.count)")
4461 if snapshot.subdomains.isEmpty {
4462 lines.append(" \(snapshot.subdomainsError ?? "No passive subdomains found")")
4463 } else {
4464 for subdomain in subdomainRows(from: snapshot) {
4465 let marker = subdomain.isInteresting ? " [interesting]" : ""
4466 lines.append(" \(subdomain.hostname)\(marker)")
4467 }
4468 }
4469 if !DataAccessService.hasAccess(to: .extendedSubdomains) {
4470 lines.append(" Extended subdomain discovery (Pro+)")
4471 }
4472 }
4473
4474 appendSection("DNS") {
4475 if let dnsError = snapshot.dnsError {
4476 lines.append(" Error: \(dnsError)")
4477 }
4478 if let dnssecLabel = dnssecLabel(from: snapshot) {
4479 lines.append(" \(dnssecLabel)")
4480 }
4481 for section in dnsRows(from: snapshot) {
4482 lines.append(" \(section.title)")
4483 if let message = section.message {
4484 lines.append(" \(message.isError ? "Error" : "Info"): \(message.text)")
4485 }
4486 for row in section.rows {
4487 lines.append(" \(row.value) (\(row.label))")
4488 }
4489 if let wildcardTitle = section.wildcardTitle {
4490 lines.append(" \(wildcardTitle)")
4491 for row in section.wildcardRows {
4492 lines.append(" \(row.value) (\(row.label))")
4493 }
4494 }
4495 }
4496 if let ptrRecord = snapshot.ptrRecord {
4497 lines.append(" PTR: \(ptrRecord)")
4498 } else if let ptrError = snapshot.ptrError {
4499 lines.append(" PTR Error: \(ptrError)")
4500 }
4501 }
4502
4503 appendSection("Web") {
4504 if let sslError = snapshot.sslError {
4505 lines.append(" TLS Error: \(sslError)")
4506 } else {
4507 for row in webCertificateRows(from: snapshot) {
4508 lines.append(" \(row.label): \(row.value)")
4509 }
4510 }
4511
4512 if let httpHeadersError = snapshot.httpHeadersError {
4513 lines.append(" Headers Error: \(httpHeadersError)")
4514 } else {
4515 for row in webResponseRows(from: snapshot) {
4516 lines.append(" \(row.label): \(row.value)")
4517 }
4518 if snapshot.httpHeaders.isEmpty {
4519 lines.append(" Headers: No headers returned")
4520 } else {
4521 lines.append(" Headers:")
4522 for header in snapshot.httpHeaders {
4523 lines.append(" \(header.name): \(header.value)")
4524 }
4525 }
4526 }
4527
4528 if let redirectChainError = snapshot.redirectChainError {
4529 lines.append(" Redirect Error: \(redirectChainError)")
4530 } else if snapshot.redirectChain.isEmpty {
4531 lines.append(" Redirects: No redirect data available")
4532 } else {
4533 lines.append(" Redirects:")
4534 for hop in redirectRows(from: snapshot) {
4535 lines.append(" \(hop.stepLabel). \(hop.statusCode) \(hop.url)\(hop.isFinal ? " (final)" : "")")
4536 }
4537 }
4538 }
4539
4540 appendSection("Email") {
4541 if let emailSecurityError = snapshot.emailSecurityError {
4542 lines.append(" Error: \(emailSecurityError)")
4543 } else if emailRows(from: snapshot).isEmpty {
4544 lines.append(" No email security records found")
4545 } else {
4546 for row in emailRows(from: snapshot) {
4547 lines.append(" \(row.label): \(row.status)")
4548 lines.append(" \(row.detail)")
4549 if let auxiliaryDetail = row.auxiliaryDetail {
4550 lines.append(" \(auxiliaryDetail)")
4551 }
4552 }
4553 }
4554 }
4555
4556 appendSection("Network") {
4557 if let reachabilityError = snapshot.reachabilityError {
4558 lines.append(" Reachability Error: \(reachabilityError)")
4559 } else if reachabilityRows(from: snapshot).isEmpty {
4560 lines.append(" Reachability: No results")
4561 } else {
4562 lines.append(" Reachability:")
4563 for row in reachabilityRows(from: snapshot) {
4564 lines.append(" \(row.portLabel): \(row.statusLabel) \(row.latencyLabel)")
4565 }
4566 }
4567
4568 if let ipGeolocationError = snapshot.ipGeolocationError, snapshot.ipGeolocation == nil {
4569 lines.append(" Location Error: \(ipGeolocationError)")
4570 } else if locationRows(from: snapshot).isEmpty {
4571 lines.append(" Location: No data")
4572 } else {
4573 lines.append(" Location:")
4574 for row in locationRows(from: snapshot) {
4575 lines.append(" \(row.label): \(row.value)")
4576 }
4577 }
4578
4579 if let portScanError = snapshot.portScanError, snapshot.portScanResults.isEmpty {
4580 lines.append(" Port Scan Error: \(portScanError)")
4581 }
4582
4583 lines.append(" Standard Ports:")
4584 let standardRows = portRows(from: snapshot, kind: .standard)
4585 if standardRows.isEmpty {
4586 lines.append(" No results")
4587 } else {
4588 for row in standardRows {
4589 lines.append(" \(row.portLabel) \(row.service): \(row.statusLabel)\(row.durationLabel.map { " \($0)" } ?? "")")
4590 if let banner = row.banner {
4591 lines.append(" Banner: \(banner)")
4592 }
4593 }
4594 }
4595
4596 lines.append(" Custom Ports:")
4597 let customRows = portRows(from: snapshot, kind: .custom)
4598 if customRows.isEmpty {
4599 lines.append(" No results")
4600 } else {
4601 for row in customRows {
4602 lines.append(" \(row.portLabel) \(row.service): \(row.statusLabel)\(row.durationLabel.map { " \($0)" } ?? "")")
4603 if let banner = row.banner {
4604 lines.append(" Banner: \(banner)")
4605 }
4606 }
4607 }
4608 }
4609
4610 return lines.joined(separator: "\n")
4611 }
4612
4613 private static func primaryIPAddress(from snapshot: LookupSnapshot) -> String? {
4614 snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
4615 }
4616
4617 private static func finalRedirectTarget(from snapshot: LookupSnapshot) -> String? {
4618 snapshot.redirectChain.last?.url
4619 }
4620
4621 private static func httpStatusGradeSummary(from snapshot: LookupSnapshot) -> String {
4622 let parts = [snapshot.httpStatusCode.map(String.init), snapshot.httpSecurityGrade].compactMap { $0 }
4623 if !parts.isEmpty {
4624 return parts.joined(separator: " / ")
4625 }
4626 return snapshot.httpHeadersError ?? "Unavailable"
4627 }
4628
4629 private static func httpsSummary(from snapshot: LookupSnapshot) -> String {
4630 if snapshot.sslInfo != nil {
4631 return "Valid"
4632 }
4633 if let sslError = snapshot.sslError {
4634 return sslError.localizedCaseInsensitiveContains("certificate") ? "Invalid" : "Failed"
4635 }
4636 return "Unavailable"
4637 }
4638
4639 private static func httpsSummaryTone(from snapshot: LookupSnapshot) -> ResultTone {
4640 if snapshot.sslInfo != nil {
4641 return .success
4642 }
4643 return snapshot.sslError == nil ? .secondary : .failure
4644 }
4645
4646 private static func emailSummary(from snapshot: LookupSnapshot) -> String {
4647 guard let emailSecurity = snapshot.emailSecurity else {
4648 return snapshot.emailSecurityError ?? "Unavailable"
4649 }
4650 return "SPF \(emailSecurity.spf.found ? "Yes" : "No") / DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")"
4651 }
4652
4653 private static func certificateStatusLabel(from snapshot: LookupSnapshot) -> String {
4654 guard let sslInfo = snapshot.sslInfo else {
4655 return snapshot.sslError ?? "Unavailable"
4656 }
4657
4658 switch DomainDiffService.certificateWarningLevel(for: snapshot) {
4659 case .critical:
4660 return "Critical (\(sslInfo.daysUntilExpiry)d)"
4661 case .warning:
4662 return "Warning (\(sslInfo.daysUntilExpiry)d)"
4663 case .none:
4664 return "Healthy (\(sslInfo.daysUntilExpiry)d)"
4665 }
4666 }
4667
4668 private static func certificateBadgeLabel(from snapshot: LookupSnapshot) -> String? {
4669 guard snapshot.sslInfo != nil else { return nil }
4670 return certificateStatusLabel(from: snapshot)
4671 }
4672
4673 private static func certificateStatusTone(from snapshot: LookupSnapshot) -> ResultTone {
4674 guard let daysRemaining = snapshot.sslInfo?.daysUntilExpiry else {
4675 return snapshot.sslError == nil ? .secondary : .failure
4676 }
4677 return certificateTone(daysRemaining: daysRemaining)
4678 }
4679
4680 private static func certificateTone(daysRemaining: Int) -> ResultTone {
4681 if daysRemaining < 14 {
4682 return .failure
4683 }
4684 if daysRemaining < 30 {
4685 return .warning
4686 }
4687 return .success
4688 }
4689
4690 private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String {
4691 switch status {
4692 case .available:
4693 return "Available"
4694 case .registered:
4695 return "Registered"
4696 case .unknown, .none:
4697 return "Unknown"
4698 }
4699 }
4700
4701 private static func availabilityInference(from snapshot: LookupSnapshot) -> String {
4702 switch snapshot.availabilityResult?.status {
4703 case .registered:
4704 return "Likely registered"
4705 case .available:
4706 return "Possibly available"
4707 case .unknown, .none:
4708 return "Unclear"
4709 }
4710 }
4711
4712 private static func availabilityTone(_ status: DomainAvailabilityStatus?) -> ResultTone {
4713 switch status {
4714 case .available:
4715 return .success
4716 case .registered:
4717 return .warning
4718 case .unknown, .none:
4719 return .secondary
4720 }
4721 }
4722
4723 private static func sourceTone(for snapshot: LookupSnapshot) -> ResultTone {
4724 if snapshot.statusMessage != nil {
4725 return .warning
4726 }
4727
4728 switch snapshot.resultSource {
4729 case .live:
4730 return .success
4731 case .cached:
4732 return .secondary
4733 case .mixed:
4734 return .warning
4735 case .snapshot:
4736 return .warning
4737 }
4738 }
4739
4740 private static func securityGradeTone(_ grade: String) -> ResultTone {
4741 switch grade {
4742 case "A", "B":
4743 return .success
4744 case "C":
4745 return .warning
4746 case "D", "F":
4747 return .failure
4748 default:
4749 return .secondary
4750 }
4751 }
4752
4753 private static func durationLabel(_ durationMs: Int?) -> String {
4754 durationMs.map { "\($0) ms" } ?? "Unavailable"
4755 }
4756
4757 private static let certificateDateFormatter: DateFormatter = {
4758 let formatter = DateFormatter()
4759 formatter.dateStyle = .medium
4760 formatter.timeStyle = .short
4761 return formatter
4762 }()
4763
4764 private static let ownershipDateFormatter: DateFormatter = {
4765 let formatter = DateFormatter()
4766 formatter.dateStyle = .medium
4767 formatter.timeStyle = .none
4768 return formatter
4769 }()
4770
4771 private static let csvDateFormatter: ISO8601DateFormatter = {
4772 let formatter = ISO8601DateFormatter()
4773 formatter.formatOptions = [.withInternetDateTime]
4774 return formatter
4775 }()
4776
4777 private func refreshDomainPricing(for domain: String, persistAfterFetch: Bool) async {
4778 domainPricingLoading = true
4779 let outcome = await ExternalDataService.shared.pricing(domain: domain)
4780
4781 switch outcome.value {
4782 case let .success(pricing):
4783 domainPricing = pricing
4784 domainPricingError = nil
4785 case let .empty(message):
4786 domainPricing = nil
4787 domainPricingError = conciseExternalMessage(message, fallback: "External pricing unavailable")
4788 case let .error(message):
4789 domainPricing = nil
4790 domainPricingError = conciseExternalMessage(message, fallback: "External pricing unavailable")
4791 }
4792
4793 domainPricingLoading = false
4794
4795 if persistAfterFetch {
4796 _ = saveHistoryEntry(replaceLatest: true)
4797 }
4798 }
4799
4800 private func refreshReputation(for domain: String, persistAfterFetch: Bool) async {
4801 reputationLoading = true
4802 let outcome = await ExternalDataService.shared.reputation(domain: domain)
4803
4804 switch outcome.value {
4805 case let .success(result):
4806 reputation = result
4807 reputationError = nil
4808 case let .empty(message):
4809 reputation = nil
4810 reputationError = conciseExternalMessage(message, fallback: "Reputation check unavailable")
4811 case let .error(message):
4812 reputation = nil
4813 reputationError = conciseExternalMessage(message, fallback: "Reputation check unavailable")
4814 }
4815
4816 reputationLoading = false
4817
4818 if persistAfterFetch {
4819 _ = saveHistoryEntry(replaceLatest: true)
4820 }
4821 }
4822
4823 private func conciseExternalMessage(_ message: String, fallback: String) -> String {
4824 let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
4825 if trimmed.isEmpty {
4826 return fallback
4827 }
4828 if trimmed.localizedCaseInsensitiveContains("rate") {
4829 return "Rate limited. Try again later."
4830 }
4831 if trimmed.localizedCaseInsensitiveContains("invalid") {
4832 return "External data was invalid."
4833 }
4834 if trimmed.localizedCaseInsensitiveContains("network") {
4835 return "External data is offline."
4836 }
4837 return trimmed
4838 }
4839
4840 private static func defaultUsageCredits() -> [UsageCreditFeature: UsageCreditStatus] {
4841 Dictionary(uniqueKeysWithValues: UsageCreditFeature.allCases.map { feature in
4842 (feature, fallbackCreditStatus(for: feature))
4843 })
4844 }
4845
4846 private static func fallbackCreditStatus(for feature: UsageCreditFeature) -> UsageCreditStatus {
4847 UsageCreditStatus(
4848 feature: feature,
4849 remaining: feature.defaultAllowance,
4850 total: feature.defaultAllowance,
4851 resetContext: "Resets with app version \(AppVersion.current)"
4852 )
4853 }
4854
4855 private static func csvEscaped(_ value: String) -> String {
4856 let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
4857 return "\"\(escaped)\""
4858 }
4859
4860 private static func isInterestingSubdomain(_ hostname: String) -> Bool {
4861 let keywords = ["admin", "api", "dev", "staging", "test", "internal"]
4862 let labels = hostname.lowercased().split(separator: ".").map(String.init)
4863 return labels.contains { label in
4864 keywords.contains(where: { label.contains($0) })
4865 }
4866 }
4867}
4868
4869private extension String {
4870 var nonEmpty: String? {
4871 isEmpty ? nil : self
4872 }
4873
4874 var nilIfEmpty: String? {
4875 isEmpty ? nil : self
4876 }
4877}
4878
4879private extension Array where Element == String {
4880 var nilIfEmpty: [String]? {
4881 isEmpty ? nil : self
4882 }
4883}