krz/domain-dig

an ios app for DNS & SSL analysis

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

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