krz/domain-dig

an ios app for DNS & SSL analysis

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

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