krz/domain-dig

an ios app for DNS & SSL analysis

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

v5.0.2: 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            domain: snapshot.domain,
1940            timestamp: snapshot.timestamp,
1941            trackedDomainID: trackedDomainID,
1942            note: currentHistoryEntry?.note,
1943            dnsSections: snapshot.dnsSections,
1944            sslInfo: snapshot.sslInfo,
1945            httpHeaders: snapshot.httpHeaders,
1946            reachabilityResults: snapshot.reachabilityResults,
1947            ipGeolocation: snapshot.ipGeolocation,
1948            emailSecurity: snapshot.emailSecurity,
1949            mtaSts: snapshot.emailSecurity?.mtaSts,
1950            ownership: snapshot.ownership,
1951            ownershipHistory: snapshot.ownershipHistory,
1952            inferredProvider: intelligence.inferredProvider,
1953            priorProviders: intelligence.priorProviders,
1954            domainClassification: intelligence.domainClassification,
1955            ownershipTransitions: intelligence.ownershipTransitions,
1956            hostingTransitions: intelligence.hostingTransitions,
1957            subdomainHistory: intelligence.subdomainHistory,
1958            riskSignals: intelligence.riskSignals,
1959            intelligenceTimeline: intelligence.timelineEvents,
1960            ptrRecord: snapshot.ptrRecord,
1961            redirectChain: snapshot.redirectChain,
1962            subdomains: snapshot.subdomains,
1963            extendedSubdomains: snapshot.extendedSubdomains,
1964            dnsHistory: snapshot.dnsHistory,
1965            domainPricing: snapshot.domainPricing,
1966            reputation: snapshot.reputation,
1967            portScanResults: snapshot.portScanResults,
1968            hstsPreloaded: snapshot.hstsPreloaded,
1969            availabilityResult: snapshot.availabilityResult,
1970            suggestions: snapshot.suggestions,
1971            appVersion: snapshot.appVersion,
1972            resultSource: snapshot.resultSource,
1973            dataSources: snapshot.dataSources,
1974            provenanceBySection: snapshot.provenanceBySection,
1975            availabilityConfidence: snapshot.availabilityConfidence,
1976            ownershipConfidence: snapshot.ownershipConfidence,
1977            subdomainConfidence: snapshot.subdomainConfidence,
1978            emailSecurityConfidence: snapshot.emailSecurityConfidence,
1979            geolocationConfidence: snapshot.geolocationConfidence,
1980            errorDetails: snapshot.errorDetails,
1981            isPartialSnapshot: snapshot.isPartialSnapshot,
1982            validationIssues: snapshot.validationIssues,
1983            resolverDisplayName: snapshot.resolverDisplayName,
1984            resolverURLString: snapshot.resolverURLString,
1985            totalLookupDurationMs: snapshot.totalLookupDurationMs,
1986            primaryIP: Self.primaryIPAddress(from: snapshot),
1987            finalRedirectURL: Self.finalRedirectTarget(from: snapshot),
1988            tlsStatusSummary: Self.httpsSummary(from: snapshot),
1989            emailSecuritySummary: Self.emailSummary(from: snapshot),
1990            httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
1991            changeSummary: changeSummary,
1992            snapshotIndex: nextSnapshotIndex,
1993            previousSnapshotID: previousSnapshotID,
1994            changeCount: domainDiff?.changeCount ?? changeSummary?.changedSections.count ?? 0,
1995            severitySummary: changeSummary?.severity,
1996            sslError: snapshot.sslError,
1997            httpHeadersError: snapshot.httpHeadersError,
1998            reachabilityError: snapshot.reachabilityError,
1999            ipGeolocationError: snapshot.ipGeolocationError,
2000            emailSecurityError: snapshot.emailSecurityError,
2001            ownershipError: snapshot.ownershipError,
2002            ownershipHistoryError: snapshot.ownershipHistoryError,
2003            ptrError: snapshot.ptrError,
2004            redirectChainError: snapshot.redirectChainError,
2005            subdomainsError: snapshot.subdomainsError,
2006            extendedSubdomainsError: snapshot.extendedSubdomainsError,
2007            dnsHistoryError: snapshot.dnsHistoryError,
2008            domainPricingError: snapshot.domainPricingError,
2009            reputationError: snapshot.reputationError,
2010            portScanError: snapshot.portScanError
2011        )
2012
2013        if updateCurrentState {
2014            currentHistoryEntryID = entry.id
2015        }
2016
2017        if replaceLatest, !history.isEmpty, history[0].domain.caseInsensitiveCompare(snapshot.domain) == .orderedSame {
2018            history[0] = entry
2019        } else {
2020            history.insert(entry, at: 0)
2021            trimHistoryToLimit()
2022        }
2023
2024        updateTrackedDomainSnapshotMetadata(
2025            domain: snapshot.domain,
2026            snapshotID: entry.id,
2027            availabilityStatus: snapshot.availabilityResult?.status,
2028            updatedAt: snapshot.timestamp,
2029            changeSummary: changeSummary,
2030            changeSeverity: changeSummary?.severity,
2031            certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot),
2032            certificateDaysRemaining: snapshot.sslInfo?.daysUntilExpiry
2033        )
2034        persistHistory()
2035        notifyIfNeeded(for: entry, snapshot: snapshot, previousSnapshot: previousSnapshot)
2036        return entry
2037    }
2038
2039    private func persistHistory() {
2040        if historyPersistenceSuspended {
2041            historyPersistenceDirty = true
2042            return
2043        }
2044        let persistStartedAt = DomainDebugLog.signpostStart("DomainViewModel.persistHistory")
2045        DomainDataPortabilityService.saveHistoryEntries(history)
2046        refreshDataLifecycleSummary()
2047        DomainDebugLog.signpostEnd("DomainViewModel.persistHistory", start: persistStartedAt, extra: "count=\(history.count)")
2048    }
2049
2050    func persistAuditSessions() {
2051        DomainDataPortabilityService.saveAuditSessions(auditSessions)
2052        refreshDataLifecycleSummary()
2053    }
2054
2055    func setHistoryAutoPruneOption(_ option: HistoryAutoPruneOption) {
2056        historyAutoPruneOption = option
2057        UserDefaults.standard.set(option.rawValue, forKey: Self.historyAutoPruneKey)
2058        trimHistoryToLimit()
2059        persistHistory()
2060    }
2061
2062    func updateHistoryNote(_ note: String, for entry: HistoryEntry) {
2063        guard let index = history.firstIndex(where: { $0.id == entry.id }) else { return }
2064        history[index].note = note.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
2065        persistHistory()
2066    }
2067
2068    #if DEBUG
2069    /// True when this session was launched with `DOMAIN_DIG_SEED_FIXTURES`.
2070    /// Blocks tracked-domain persistence, widget-store writes, and persisted-data
2071    /// reloads so fixture data stays strictly in-memory  the audit suite relies
2072    /// on every launch starting from the same state.
2073    private(set) var auditFixturesActive = false
2074
2075    func seedAuditFixturesIfRequested() {
2076        guard AuditFixtures.requested, !auditFixturesActive else { return }
2077        auditFixturesActive = true
2078        trackedDomains = AuditFixtures.trackedDomains
2079        batchResults = AuditFixtures.batchResults
2080    }
2081    #endif
2082
2083    private func persistTrackedDomains() {
2084        #if DEBUG
2085        if auditFixturesActive { return }
2086        #endif
2087        if trackedDomainsPersistenceSuspended {
2088            trackedDomainsPersistenceDirty = true
2089            return
2090        }
2091        DomainDataPortabilityService.saveTrackedDomains(trackedDomains)
2092        CloudSyncService.shared.scheduleSyncIfNeeded()
2093        refreshDataLifecycleSummary()
2094        refreshWidgetData()
2095    }
2096
2097    private func beginBulkPersistenceDeferral() {
2098        historyPersistenceSuspended = true
2099        trackedDomainsPersistenceSuspended = true
2100        historyPersistenceDirty = false
2101        trackedDomainsPersistenceDirty = false
2102    }
2103
2104    private func endBulkPersistenceDeferral() {
2105        historyPersistenceSuspended = false
2106        trackedDomainsPersistenceSuspended = false
2107
2108        if trackedDomainsPersistenceDirty {
2109            trackedDomainsPersistenceDirty = false
2110            DomainDataPortabilityService.saveTrackedDomains(trackedDomains)
2111            CloudSyncService.shared.scheduleSyncIfNeeded()
2112        }
2113
2114        if historyPersistenceDirty {
2115            historyPersistenceDirty = false
2116            DomainDataPortabilityService.saveHistoryEntries(history)
2117        }
2118
2119        refreshDataLifecycleSummary()
2120    }
2121
2122    private func trimHistoryToLimit() {
2123        let hardLimit = historyAutoPruneOption.keepCount ?? Self.maxHistory
2124        history = Array(history.prefix(min(hardLimit, Self.maxHistory)))
2125    }
2126
2127    private func nextSnapshotIndex(for domain: String, trackedDomainID: UUID?) -> Int {
2128        let siblings = history.filter { entry in
2129            if let trackedDomainID {
2130                return entry.trackedDomainID == trackedDomainID
2131            }
2132            return entry.domain.caseInsensitiveCompare(domain) == .orderedSame
2133        }
2134        let existingMax = siblings.compactMap(\.snapshotIndex).max() ?? siblings.count
2135        return existingMax + 1
2136    }
2137
2138    func persistMonitoringSettings(localActivationConfirmed: Bool = false) {
2139        monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
2140        MonitoringStorage.saveSettings(monitoringSettings)
2141        CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: localActivationConfirmed)
2142    }
2143
2144    func sanitizeMonitoringSelection() {
2145        monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
2146        MonitoringStorage.saveSettings(monitoringSettings)
2147        CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: false)
2148    }
2149
2150    private func clearMonitoringLogs() {
2151        monitoringLogs.removeAll()
2152        monitoringStatusMessage = nil
2153        MonitoringStorage.saveLogs([])
2154    }
2155
2156    private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) {
2157        guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
2158            return
2159        }
2160        trackedDomains[index].lastKnownAvailability = status
2161        persistTrackedDomains()
2162    }
2163
2164    private func updateTrackedDomainSnapshotMetadata(
2165        domain: String,
2166        snapshotID: UUID,
2167        availabilityStatus: DomainAvailabilityStatus?,
2168        updatedAt: Date,
2169        changeSummary: DomainChangeSummary?,
2170        changeSeverity: ChangeSeverity?,
2171        certificateWarningLevel: CertificateWarningLevel,
2172        certificateDaysRemaining: Int?
2173    ) {
2174        guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
2175            return
2176        }
2177        trackedDomains[index].lastSnapshotID = snapshotID
2178        trackedDomains[index].lastKnownAvailability = availabilityStatus
2179        trackedDomains[index].updatedAt = updatedAt
2180        trackedDomains[index].lastChangeSummary = changeSummary
2181        trackedDomains[index].lastChangeSeverity = changeSeverity
2182        trackedDomains[index].certificateWarningLevel = certificateWarningLevel
2183        trackedDomains[index].certificateDaysRemaining = certificateDaysRemaining
2184        persistTrackedDomains()
2185    }
2186
2187    private func notifyIfNeeded(for entry: HistoryEntry, snapshot: LookupSnapshot, previousSnapshot: LookupSnapshot?) {
2188        guard notificationsAuthorized, entry.trackedDomainID != nil else { return }
2189
2190        Task {
2191            if let summary = entry.changeSummary, summary.hasChanges {
2192                await LocalNotificationService.shared.notifyDomainEvent(
2193                    domain: entry.domain,
2194                    message: summary.message,
2195                    severity: summary.severity
2196                )
2197            }
2198
2199            let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: snapshot)
2200            if certificateWarningLevel == .critical, let daysRemaining = snapshot.sslInfo?.daysUntilExpiry {
2201                await LocalNotificationService.shared.notifyCertificateWarning(
2202                    domain: entry.domain,
2203                    daysRemaining: daysRemaining
2204                )
2205            }
2206
2207            if let previousStatus = previousSnapshot?.availabilityResult?.status,
2208               let newStatus = snapshot.availabilityResult?.status,
2209               previousStatus != newStatus {
2210                await LocalNotificationService.shared.notifyDomainEvent(
2211                    domain: entry.domain,
2212                    message: "Availability changed",
2213                    severity: .high
2214                )
2215            }
2216        }
2217    }
2218
2219    private func previousSnapshot(for domain: String, trackedDomainID: UUID?, replacingLatest: Bool) -> LookupSnapshot? {
2220        let matchingEntries = history.filter { entry in
2221            if let trackedDomainID {
2222                return entry.trackedDomainID == trackedDomainID
2223            }
2224            return entry.domain.caseInsensitiveCompare(domain) == .orderedSame
2225        }
2226
2227        if replacingLatest {
2228            return matchingEntries.dropFirst().first?.snapshot
2229        }
2230        return matchingEntries.first?.snapshot
2231    }
2232
2233    private func trackedDomain(for domain: String) -> TrackedDomain? {
2234        trackedDomains.first { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
2235    }
2236
2237    func normalizedDomain(_ domain: String) -> String {
2238        domain
2239            .trimmingCharacters(in: .whitespacesAndNewlines)
2240            .replacingOccurrences(of: "https://", with: "")
2241            .replacingOccurrences(of: "http://", with: "")
2242            .components(separatedBy: "/")
2243            .first?
2244            .lowercased() ?? ""
2245    }
2246
2247    private func linkTrackedDomainHistory(for domain: String) {
2248        guard let trackedDomain = trackedDomain(for: domain) else { return }
2249        var didChange = false
2250
2251        for index in history.indices where history[index].domain.caseInsensitiveCompare(domain) == .orderedSame {
2252            if history[index].trackedDomainID != trackedDomain.id {
2253                history[index].trackedDomainID = trackedDomain.id
2254                didChange = true
2255            }
2256        }
2257
2258        if didChange {
2259            persistHistory()
2260        }
2261
2262        if let latestEntry = history.first(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }),
2263           let trackedIndex = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) {
2264            trackedDomains[trackedIndex].lastSnapshotID = latestEntry.id
2265            trackedDomains[trackedIndex].lastChangeSummary = latestEntry.changeSummary
2266            trackedDomains[trackedIndex].lastChangeSeverity = latestEntry.changeSummary?.severity
2267            trackedDomains[trackedIndex].lastKnownAvailability = latestEntry.availabilityResult?.status
2268            trackedDomains[trackedIndex].certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: latestEntry.snapshot)
2269            trackedDomains[trackedIndex].certificateDaysRemaining = latestEntry.sslInfo?.daysUntilExpiry
2270            trackedDomains[trackedIndex].updatedAt = latestEntry.timestamp
2271            persistTrackedDomains()
2272        }
2273    }
2274
2275    private func addRecentSearch(_ domain: String) {
2276        recentSearches.removeAll { $0.lowercased() == domain.lowercased() }
2277        recentSearches.insert(domain, at: 0)
2278        if recentSearches.count > Self.maxRecent {
2279            recentSearches = Array(recentSearches.prefix(Self.maxRecent))
2280        }
2281        DomainDataPortabilityService.saveRecentSearches(recentSearches)
2282        CloudSyncService.shared.markAppSettingsChanged()
2283        refreshDataLifecycleSummary()
2284    }
2285
2286    private func beginLookup(for target: String, cancelExistingTask: Bool = true) -> UUID {
2287        if cancelExistingTask {
2288            lookupTask?.cancel()
2289        }
2290        customPortScanTask?.cancel()
2291
2292        let lookupID = UUID()
2293        activeLookupID = lookupID
2294        lookupStartedAt = Date()
2295        lastLookupDurationMs = nil
2296        addRecentSearch(target)
2297        searchedDomain = target
2298        hasRun = true
2299        currentHistoryEntryID = nil
2300        currentSnapshotTimestamp = Date()
2301        currentResultSource = .live
2302        currentCachedSections = []
2303        currentStatusMessage = nil
2304        currentDiffSections = []
2305        currentChangeSummary = nil
2306        currentReport = nil
2307        ownershipDiff = []
2308        clearLookupState()
2309        setAllLoadingStates(true)
2310        customPortScanLoading = false
2311        return lookupID
2312    }
2313
2314    func startBatchLookup(domains: [String], source: BatchLookupSource, workflow: DomainWorkflow? = nil) {
2315        guard !domains.isEmpty else { return }
2316        guard !batchLookupRunning else { return }
2317
2318        let now = Date()
2319        if let lastBatchStartedAt, now.timeIntervalSince(lastBatchStartedAt) < 1 {
2320            return
2321        }
2322
2323        lastBatchStartedAt = now
2324        clearBatchState()
2325        batchLookupSource = source
2326        activeWorkflowRunID = workflow?.id
2327        activeWorkflowRunName = workflow?.name
2328        batchTotalCount = domains.count
2329        batchLookupRunning = true
2330        batchResults = domains.map {
2331            BatchLookupResult(
2332                domain: $0,
2333                historyEntryID: nil,
2334                availability: nil,
2335                primaryIP: nil,
2336                quickStatus: "Pending",
2337                timestamp: Date(),
2338                status: .pending
2339            )
2340        }
2341
2342        lookupTask?.cancel()
2343        customPortScanTask?.cancel()
2344        batchTask?.cancel()
2345
2346        SweepActivityController.shared.begin(
2347            title: source == .watchlistRefresh ? "Watchlist Sweep" : "Batch Lookup",
2348            total: domains.count
2349        )
2350
2351        batchTask = Task { [weak self] in
2352            guard let self else { return }
2353            self.notificationsAuthorized = await LocalNotificationService.shared.requestAuthorizationIfNeeded()
2354            await self.runBatchLookup(domains: domains, source: source)
2355        }
2356    }
2357
2358    private func clearBatchState() {
2359        batchResults = []
2360        batchLookupSource = .manual
2361        batchCurrentDomain = nil
2362        batchCompletedCount = 0
2363        batchTotalCount = 0
2364        batchLookupRunning = false
2365        latestBatchSweepSummary = nil
2366        latestWorkflowRunSummary = nil
2367        activeBatchDomains = []
2368        activeWorkflowRunID = nil
2369        activeWorkflowRunName = nil
2370        batchTask = nil
2371    }
2372
2373    private func parsedDomains(from input: String) -> [String] {
2374        let separators = CharacterSet(charactersIn: ",\n")
2375        var seen = Set<String>()
2376
2377        return input
2378            .components(separatedBy: separators)
2379            .map(normalizedDomain)
2380            .filter { !$0.isEmpty }
2381            .filter { seen.insert($0).inserted }
2382    }
2383
2384    private func runBatchLookup(domains: [String], source: BatchLookupSource) async {
2385        let concurrencyLimit = min(source == .watchlistRefresh ? 4 : 3, max(domains.count, 1))
2386        var nextIndex = 0
2387        beginBulkPersistenceDeferral()
2388
2389        await withTaskGroup(of: (String, BatchLookupPayload?).self) { group in
2390            for _ in 0..<concurrencyLimit {
2391                guard nextIndex < domains.count else { break }
2392                let domain = domains[nextIndex]
2393                nextIndex += 1
2394                enqueueBatchLookup(domain: domain, source: source, group: &group)
2395            }
2396
2397            while let (domain, payload) = await group.next() {
2398                completeBatchLookup(domain: domain, payload: payload)
2399
2400                if nextIndex < domains.count, !Task.isCancelled {
2401                    let nextDomain = domains[nextIndex]
2402                    nextIndex += 1
2403                    enqueueBatchLookup(domain: nextDomain, source: source, group: &group)
2404                }
2405            }
2406        }
2407
2408        endBulkPersistenceDeferral()
2409        finishBatchLookup(source: source)
2410    }
2411
2412    private func enqueueBatchLookup(
2413        domain: String,
2414        source: BatchLookupSource,
2415        group: inout TaskGroup<(String, BatchLookupPayload?)>
2416    ) {
2417        activeBatchDomains.append(domain)
2418        batchCurrentDomain = activeBatchDomains.first
2419        if source == .watchlistRefresh {
2420            refreshingTrackedDomainID = trackedDomain(for: domain)?.id
2421        }
2422        updateBatchResult(domain: domain, status: .running, quickStatus: "Running", entry: nil, errorMessage: nil)
2423        let previousSnapshot = previousSnapshot(for: domain, trackedDomainID: trackedDomain(for: domain)?.id, replacingLatest: false)
2424
2425        group.addTask { [domain, previousSnapshot] in
2426            let payload = await Self.performBatchLookup(domain: domain, previousSnapshot: previousSnapshot)
2427            return (domain, payload)
2428        }
2429    }
2430
2431    private func completeBatchLookup(domain: String, payload: BatchLookupPayload?) {
2432        activeBatchDomains.removeAll { $0.caseInsensitiveCompare(domain) == .orderedSame }
2433        batchCurrentDomain = activeBatchDomains.first
2434
2435        guard let payload else {
2436            updateBatchResult(
2437                domain: domain,
2438                status: .failed,
2439                quickStatus: "Failed",
2440                entry: nil,
2441                resultSource: .live,
2442                errorMessage: "Lookup cancelled"
2443            )
2444            batchCompletedCount += 1
2445            SweepActivityController.shared.update(
2446                completed: batchCompletedCount,
2447                total: batchTotalCount,
2448                currentDomain: batchCurrentDomain
2449            )
2450            return
2451        }
2452
2453        let entry = payload.snapshot.historyEntryID.flatMap { id in
2454            history.first(where: { $0.id == id })
2455        } ?? saveHistoryEntry(from: payload.snapshot, replaceLatest: false, updateCurrentState: false)
2456        let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot)
2457        let riskAssessment = entry?.changeSummary?.riskAssessment ?? DomainInsightEngine.analyze(snapshot: payload.snapshot).riskAssessment
2458        let quickStatus: String
2459        if entry?.changeSummary?.hasChanges == true {
2460            if entry?.changeSummary?.impactClassification == .critical {
2461                quickStatus = "Critical"
2462            } else {
2463                quickStatus = entry?.changeSummary?.severity == .high ? "High" : "Changed"
2464            }
2465        } else if certificateWarningLevel != .none {
2466            quickStatus = certificateWarningLevel == .critical ? "Critical" : "Warning"
2467        } else if riskAssessment.level == .high {
2468            quickStatus = "High"
2469        } else {
2470            quickStatus = "Unchanged"
2471        }
2472
2473        updateBatchResult(
2474            domain: domain,
2475            status: .completed,
2476            quickStatus: quickStatus,
2477            entry: entry,
2478            resultSource: payload.snapshot.resultSource,
2479            errorMessage: payload.snapshot.statusMessage
2480        )
2481        batchCompletedCount += 1
2482        SweepActivityController.shared.update(
2483            completed: batchCompletedCount,
2484            total: batchTotalCount,
2485            currentDomain: batchCurrentDomain
2486        )
2487    }
2488
2489    private func finishBatchLookup(source: BatchLookupSource) {
2490        batchLookupRunning = false
2491        batchCurrentDomain = nil
2492        activeBatchDomains = []
2493        refreshingTrackedDomainID = nil
2494        batchTask = nil
2495
2496        let changedCount = batchResults.filter { $0.quickStatus == "Changed" || $0.quickStatus == "High" || $0.quickStatus == "Critical" }.count
2497        let unchangedCount = batchResults.filter { $0.quickStatus == "Unchanged" && $0.status == .completed }.count
2498        let warningCount = batchResults.filter {
2499            $0.certificateWarningLevel != .none
2500                || $0.changeClassification == .warning
2501                || $0.changeClassification == .critical
2502                || $0.riskLevel == .high
2503        }.count
2504
2505        let summary = BatchSweepSummary(
2506            source: source,
2507            totalDomains: batchResults.count,
2508            changedDomains: changedCount,
2509            unchangedDomains: unchangedCount,
2510            warningDomains: warningCount,
2511            results: batchResults.sorted { lhs, rhs in
2512                if lhs.status != rhs.status {
2513                    return lhs.status.rawValue < rhs.status.rawValue
2514                }
2515                return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending
2516            },
2517            generatedAt: Date()
2518        )
2519        latestBatchSweepSummary = summary
2520        SweepActivityController.shared.end(changed: changedCount, warnings: warningCount)
2521        AppAccessibility.announce(
2522            "Sweep complete. \(summary.results.count) domains, \(changedCount) changed, \(warningCount) warnings."
2523        )
2524
2525        if source == .workflow, let activeWorkflowRunID, let activeWorkflowRunName {
2526            let workflowReports: [DomainReport] = summary.results.compactMap { result in
2527                guard let entry = historyEntry(for: result) else { return nil }
2528                return report(for: entry)
2529            }
2530            latestWorkflowRunSummary = WorkflowRunSummary(
2531                workflowID: activeWorkflowRunID,
2532                workflowName: activeWorkflowRunName,
2533                totalDomains: batchResults.count,
2534                changedDomains: changedCount,
2535                unchangedDomains: unchangedCount,
2536                warningDomains: warningCount,
2537                results: summary.results,
2538                workflowInsights: DomainInsightEngine.workflowInsights(for: workflowReports),
2539                generatedAt: summary.generatedAt
2540            )
2541        }
2542
2543        if notificationsAuthorized, source != .workflow {
2544            Task {
2545                await LocalNotificationService.shared.notifySweepComplete(summary: summary)
2546            }
2547        }
2548    }
2549
2550    private func updateBatchResult(
2551        domain: String,
2552        status: BatchLookupStatus,
2553        quickStatus: String,
2554        entry: HistoryEntry?,
2555        resultSource: LookupResultSource = .live,
2556        errorMessage: String?
2557    ) {
2558        guard let index = batchResults.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
2559            return
2560        }
2561
2562        batchResults[index] = BatchLookupResult(
2563            id: batchResults[index].id,
2564            domain: domain,
2565            historyEntryID: entry?.id,
2566            resultSource: resultSource,
2567            availability: entry?.availabilityResult?.status,
2568            primaryIP: entry?.primaryIP,
2569            quickStatus: quickStatus,
2570            summaryMessage: entry?.changeSummary?.message,
2571            changeSeverity: entry?.changeSummary?.severity,
2572            changeClassification: entry?.changeSummary?.impactClassification,
2573            certificateWarningLevel: entry.map { DomainDiffService.certificateWarningLevel(for: $0.snapshot) } ?? batchResults[index].certificateWarningLevel,
2574            riskScore: entry.map { $0.changeSummary?.riskAssessment?.score ?? report(for: $0).riskAssessment.score },
2575            riskLevel: entry.map { $0.changeSummary?.riskAssessment?.level ?? report(for: $0).riskAssessment.level },
2576            timestamp: entry?.timestamp ?? Date(),
2577            status: status,
2578            errorMessage: errorMessage
2579        )
2580    }
2581
2582    private func clearLookupState() {
2583        dnsSections = []
2584        dnsError = nil
2585        dnsLoading = false
2586        availabilityResult = nil
2587        availabilityLoading = false
2588        suggestions = []
2589        suggestionsLoading = false
2590        sslInfo = nil
2591        sslError = nil
2592        sslLoading = false
2593        hstsPreloaded = nil
2594        hstsLoading = false
2595        httpHeaders = []
2596        httpSecurityGrade = nil
2597        httpStatusCode = nil
2598        httpResponseTimeMs = nil
2599        httpProtocol = nil
2600        http3Advertised = false
2601        httpHeadersError = nil
2602        httpHeadersLoading = false
2603        reachabilityResults = []
2604        reachabilityError = nil
2605        reachabilityLoading = false
2606        ipGeolocation = nil
2607        ipGeolocationError = nil
2608        ipGeolocationLoading = false
2609        emailSecurity = nil
2610        emailSecurityError = nil
2611        emailSecurityLoading = false
2612        ownershipResult = nil
2613        ownershipError = nil
2614        ownershipLoading = false
2615        ownershipHistory = []
2616        ownershipHistoryError = nil
2617        ownershipHistoryLoading = false
2618        ptrRecord = nil
2619        ptrError = nil
2620        ptrLoading = false
2621        redirectChain = []
2622        redirectChainError = nil
2623        redirectChainLoading = false
2624        subdomains = []
2625        subdomainsError = nil
2626        subdomainsLoading = false
2627        extendedSubdomains = []
2628        extendedSubdomainsError = nil
2629        extendedSubdomainsLoading = false
2630        dnsHistory = []
2631        dnsHistoryError = nil
2632        dnsHistoryLoading = false
2633        domainPricing = nil
2634        domainPricingError = nil
2635        domainPricingLoading = false
2636        portScanResults = []
2637        portScanError = nil
2638        portScanLoading = false
2639        customPortResults = []
2640        customPortScanError = nil
2641        customPortScanLoading = false
2642        currentHistoryEntryID = nil
2643        currentSnapshotTimestamp = Date()
2644        currentResultSource = .live
2645        currentCachedSections = []
2646        currentStatusMessage = nil
2647        currentReport = nil
2648    }
2649
2650    private func setAllLoadingStates(_ loading: Bool) {
2651        dnsLoading = loading
2652        availabilityLoading = loading
2653        suggestionsLoading = loading
2654        sslLoading = loading
2655        hstsLoading = loading
2656        httpHeadersLoading = loading
2657        reachabilityLoading = loading
2658        ipGeolocationLoading = loading
2659        emailSecurityLoading = loading
2660        ownershipLoading = loading
2661        ownershipHistoryLoading = false
2662        ptrLoading = loading
2663        redirectChainLoading = loading
2664        subdomainsLoading = loading
2665        extendedSubdomainsLoading = false
2666        dnsHistoryLoading = false
2667        domainPricingLoading = false
2668        portScanLoading = loading
2669    }
2670
2671    private func primaryIPAddress(from sections: [DNSSection]) -> String? {
2672        sections.first(where: { $0.recordType == .A })?.records.first?.value
2673    }
2674
2675    private func isCurrentLookup(_ lookupID: UUID) -> Bool {
2676        activeLookupID == lookupID
2677    }
2678
2679    func recentSnapshots(for trackedDomain: TrackedDomain, limit: Int = 6) -> [HistoryEntry] {
2680        history
2681            .filter { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }
2682            .sorted { $0.timestamp > $1.timestamp }
2683            .prefix(limit)
2684            .map { $0 }
2685    }
2686
2687    func latestSnapshots(for domains: [TrackedDomain]) -> [HistoryEntry] {
2688        domains.compactMap { trackedDomain in
2689            recentSnapshots(for: trackedDomain, limit: 1).first
2690        }
2691    }
2692
2693    func diffSectionsForLatestSnapshots(of trackedDomain: TrackedDomain) -> [DomainDiffSection] {
2694        let snapshots = recentSnapshots(for: trackedDomain, limit: 2)
2695        guard snapshots.count == 2 else { return [] }
2696        return DomainDiffService.diff(from: snapshots[1].snapshot, to: snapshots[0].snapshot)
2697    }
2698
2699    func latestChangeSummary(for trackedDomain: TrackedDomain) -> DomainChangeSummary? {
2700        trackedDomain.lastChangeSummary ?? recentSnapshots(for: trackedDomain, limit: 1).first?.changeSummary
2701    }
2702
2703    func latestSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot? {
2704        recentSnapshots(for: trackedDomain, limit: 1).first?.snapshot
2705    }
2706
2707    func trackedDomain(withID id: UUID) -> TrackedDomain? {
2708        trackedDomains.first(where: { $0.id == id })
2709    }
2710
2711    private func portfolioDashboardStamp() -> PortfolioDashboardStamp {
2712        PortfolioDashboardStamp(
2713            trackedDomainSignature: trackedDomains
2714                .sorted { $0.id.uuidString < $1.id.uuidString }
2715                .map {
2716                    [
2717                        $0.id.uuidString,
2718                        $0.updatedAt.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))),
2719                        String($0.pendingMonitoringAlerts.count),
2720                        $0.lastMonitoredAt?.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))) ?? "0",
2721                        $0.lastAlertAt?.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))) ?? "0"
2722                    ].joined(separator: "|")
2723                },
2724            historySignature: history
2725                .prefix(250)
2726                .map {
2727                    [
2728                        $0.id.uuidString,
2729                        $0.timestamp.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))),
2730                        String($0.changeCount),
2731                        $0.severitySummary?.rawValue.description ?? "-"
2732                    ].joined(separator: "|")
2733                },
2734            monitoringSignature: monitoringLogs
2735                .prefix(100)
2736                .map {
2737                    [
2738                        $0.id.uuidString,
2739                        $0.timestamp.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))),
2740                        String($0.alertsTriggered),
2741                        String($0.changesFound)
2742                    ].joined(separator: "|")
2743                }
2744        )
2745    }
2746
2747    private func buildPortfolioDomainStates() -> [PortfolioDomainStatus] {
2748        let now = Date()
2749        return sortedTrackedDomains(from: trackedDomains, using: .pinned).map { trackedDomain in
2750            let recentEntries = recentSnapshots(for: trackedDomain, limit: 8)
2751            let latestEntry = recentEntries.first
2752            let report = latestEntry.map { self.report(for: $0) } ?? reportBuilder.build(from: placeholderSnapshot(for: trackedDomain))
2753            let recentMonitoringResults = monitoringLogs
2754                .flatMap(\.checkedDomains)
2755                .filter { $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }
2756                .sorted { $0.checkedAt > $1.checkedAt }
2757            let recentFailureResults = recentMonitoringResults.filter {
2758                let isFailure = $0.errorMessage != nil || ($0.alertSeverity ?? .info) >= .warning
2759                return isFailure && now.timeIntervalSince($0.checkedAt) <= 7 * 24 * 60 * 60
2760            }
2761            let recentChangeCount = recentEntries.filter {
2762                guard let changeSummary = $0.changeSummary, changeSummary.hasChanges else { return false }
2763                return now.timeIntervalSince($0.timestamp) <= 7 * 24 * 60 * 60
2764            }.count
2765            let recentDNSChange = recentEntries.contains {
2766                guard let changeSummary = $0.changeSummary else { return false }
2767                return changeSummary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") })
2768                    && now.timeIntervalSince($0.timestamp) <= 7 * 24 * 60 * 60
2769            }
2770            let recentCriticalChange = recentEntries.contains {
2771                guard let changeSummary = $0.changeSummary else { return false }
2772                return changeSummary.impactClassification == .critical
2773                    && now.timeIntervalSince($0.timestamp) <= 7 * 24 * 60 * 60
2774            }
2775            let certificateExpiryState = trackedDomain.certificateWarningLevel == .none
2776                ? report.certificateExpiryState
2777                : trackedDomain.certificateWarningLevel
2778            let isUnreachable: Bool = {
2779                if let latestEntry {
2780                    if !latestEntry.reachabilityResults.isEmpty {
2781                        return !latestEntry.reachabilityResults.contains(where: \.reachable)
2782                    }
2783                    if latestEntry.reachabilityError != nil {
2784                        return true
2785                    }
2786                }
2787                return recentFailureResults.contains { ($0.alertSeverity ?? .info) == .critical && $0.errorMessage != nil }
2788            }()
2789            let hasInvalidTLS = latestEntry?.sslInfo == nil && latestEntry?.sslError != nil
2790            let instabilityScore = DomainHealth.instabilityScore(
2791                recentChangeCount: recentChangeCount,
2792                recentFailureCount: recentFailureResults.count,
2793                pendingAlertCount: trackedDomain.pendingMonitoringAlerts.count,
2794                hasRecentDNSChange: recentDNSChange
2795            )
2796            let health = DomainHealth.classify(
2797                certificateExpiryState: certificateExpiryState,
2798                isReachable: !isUnreachable,
2799                recentMonitoringFailureCount: recentFailureResults.count,
2800                hasRecentDNSChange: recentDNSChange,
2801                instabilityScore: instabilityScore,
2802                hasRecentCriticalChange: recentCriticalChange,
2803                hasInvalidTLS: hasInvalidTLS
2804            )
2805
2806            return PortfolioDomainStatus(
2807                trackedDomain: trackedDomain,
2808                latestEntry: latestEntry,
2809                report: report,
2810                apexDomain: apexDomain(for: trackedDomain.domain),
2811                health: health,
2812                lastChangeDate: latestEntry?.changeSummary?.hasChanges == true
2813                    ? latestEntry?.timestamp
2814                    : trackedDomain.monitoringState.lastChangeDate ?? report.lastChangeDate,
2815                lastMonitoringFailure: recentFailureResults.first?.checkedAt,
2816                instabilityScore: instabilityScore,
2817                certificateExpiryState: certificateExpiryState,
2818                certificateDaysRemaining: trackedDomain.certificateDaysRemaining ?? latestEntry?.sslInfo?.daysUntilExpiry,
2819                isUnreachable: isUnreachable,
2820                recentDNSChange: recentDNSChange,
2821                recentCriticalChange: recentCriticalChange,
2822                recentFailureCount: recentFailureResults.count
2823            )
2824        }
2825    }
2826
2827    private func buildPortfolioActivity(from domainStates: [PortfolioDomainStatus]) -> [PortfolioActivityItem] {
2828        var items: [PortfolioActivityItem] = []
2829        let healthByDomainID = Dictionary(uniqueKeysWithValues: domainStates.map { ($0.trackedDomain.id, $0.health) })
2830
2831        for state in domainStates {
2832            if let latestEntry = state.latestEntry,
2833               let changeSummary = latestEntry.changeSummary,
2834               changeSummary.hasChanges {
2835                items.append(
2836                    PortfolioActivityItem(
2837                        id: "history|\(latestEntry.id.uuidString)",
2838                        trackedDomainID: state.trackedDomain.id,
2839                        domain: state.trackedDomain.domain,
2840                        message: portfolioHistoryMessage(for: latestEntry),
2841                        timestamp: latestEntry.timestamp,
2842                        health: state.health,
2843                        systemImage: portfolioHistoryIcon(for: latestEntry)
2844                    )
2845                )
2846            }
2847        }
2848
2849        for log in monitoringLogs.prefix(25) {
2850            for result in log.checkedDomains where result.didChange || result.errorMessage != nil || result.certificateWarningLevel != .none {
2851                guard let trackedDomain = trackedDomain(for: result.domain) else { continue }
2852                items.append(
2853                    PortfolioActivityItem(
2854                        id: "monitoring|\(log.id.uuidString)|\(result.id.uuidString)",
2855                        trackedDomainID: trackedDomain.id,
2856                        domain: trackedDomain.domain,
2857                        message: portfolioMonitoringMessage(for: result),
2858                        timestamp: result.checkedAt,
2859                        health: healthByDomainID[trackedDomain.id] ?? (result.alertSeverity == .critical ? .critical : .warning),
2860                        systemImage: portfolioMonitoringIcon(for: result)
2861                    )
2862                )
2863            }
2864        }
2865
2866        var seen = Set<String>()
2867        return items
2868            .sorted { $0.timestamp > $1.timestamp }
2869            .filter { item in
2870                let key = "\(item.domain.lowercased())|\(item.message.lowercased())"
2871                return seen.insert(key).inserted
2872            }
2873    }
2874
2875    private func buildAttentionQueue(from domainStates: [PortfolioDomainStatus]) -> [PortfolioAttentionItem] {
2876        domainStates.compactMap { state in
2877            guard state.health != .healthy else { return nil }
2878            let reason: String
2879            let timestamp: Date
2880
2881            if state.isUnreachable {
2882                reason = "Endpoint is unreachable"
2883                timestamp = state.lastMonitoringFailure ?? state.trackedDomain.updatedAt
2884            } else if state.certificateExpiryState == .critical {
2885                reason = "Certificate is expiring in under 14 days"
2886                timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
2887            } else if state.certificateExpiryState == .warning {
2888                reason = "Certificate expires within 30 days"
2889                timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
2890            } else if state.recentFailureCount >= 2 || state.instabilityScore >= 70 {
2891                reason = "Repeated instability detected"
2892                timestamp = state.lastMonitoringFailure ?? state.trackedDomain.updatedAt
2893            } else if state.recentDNSChange {
2894                reason = "DNS changed recently"
2895                timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
2896            } else if state.recentCriticalChange {
2897                reason = "Recent critical change detected"
2898                timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
2899            } else {
2900                reason = "Needs attention"
2901                timestamp = state.trackedDomain.updatedAt
2902            }
2903
2904            return PortfolioAttentionItem(
2905                id: "\(state.trackedDomain.id.uuidString)|\(reason)",
2906                trackedDomainID: state.trackedDomain.id,
2907                domain: state.trackedDomain.domain,
2908                reason: reason,
2909                timestamp: timestamp,
2910                health: state.health
2911            )
2912        }
2913        .sorted { lhs, rhs in
2914            if lhs.health != rhs.health {
2915                return healthRank(lhs.health) > healthRank(rhs.health)
2916            }
2917            return lhs.timestamp > rhs.timestamp
2918        }
2919    }
2920
2921    private func buildPortfolioGroups(from domainStates: [PortfolioDomainStatus]) -> [PortfolioGroup] {
2922        Dictionary(grouping: domainStates, by: \.apexDomain)
2923            .map { apexDomain, domains in
2924                PortfolioGroup(
2925                    apexDomain: apexDomain,
2926                    domains: domains.sorted { lhs, rhs in
2927                        if lhs.health != rhs.health {
2928                            return healthRank(lhs.health) > healthRank(rhs.health)
2929                        }
2930                        return lhs.trackedDomain.domain.localizedCaseInsensitiveCompare(rhs.trackedDomain.domain) == .orderedAscending
2931                    }
2932                )
2933            }
2934            .sorted { lhs, rhs in
2935                if let lhsMostSevere = lhs.domains.map(\.health).map(healthRank).max(),
2936                   let rhsMostSevere = rhs.domains.map(\.health).map(healthRank).max(),
2937                   lhsMostSevere != rhsMostSevere {
2938                    return lhsMostSevere > rhsMostSevere
2939                }
2940                return lhs.apexDomain.localizedCaseInsensitiveCompare(rhs.apexDomain) == .orderedAscending
2941            }
2942    }
2943
2944    private func matchesPortfolioFilter(_ state: PortfolioDomainStatus) -> Bool {
2945        switch dashboardFilter {
2946        case .all:
2947            return true
2948        case .healthy:
2949            return state.health == .healthy
2950        case .warning:
2951            return state.health == .warning
2952        case .critical:
2953            return state.health == .critical
2954        case .changed:
2955            guard let lastChangeDate = state.lastChangeDate else { return false }
2956            return Date().timeIntervalSince(lastChangeDate) <= 24 * 60 * 60
2957        case .expiring:
2958            return state.certificateExpiryState != .none
2959        case .unreachable:
2960            return state.isUnreachable
2961        }
2962    }
2963
2964    private func matchesDashboardSearch(_ state: PortfolioDomainStatus, query: String) -> Bool {
2965        guard !query.isEmpty else { return true }
2966        let normalizedQuery = query.lowercased()
2967        return state.trackedDomain.domain.lowercased().contains(normalizedQuery)
2968            || state.apexDomain.lowercased().contains(normalizedQuery)
2969    }
2970
2971    private func portfolioHistoryMessage(for entry: HistoryEntry) -> String {
2972        guard let summary = entry.changeSummary else {
2973            return "Configuration changed for \(entry.domain)"
2974        }
2975        if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") }) {
2976            return "DNS changed for \(entry.domain)"
2977        }
2978        if summary.changedSections.contains(where: {
2979            $0.localizedCaseInsensitiveContains("certificate")
2980                || $0.localizedCaseInsensitiveContains("tls")
2981        }) {
2982            return "Certificate updated for \(entry.domain)"
2983        }
2984        if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("redirect") }) {
2985            return "Redirect chain changed for \(entry.domain)"
2986        }
2987        return summary.message
2988    }
2989
2990    private func portfolioHistoryIcon(for entry: HistoryEntry) -> String {
2991        guard let summary = entry.changeSummary else { return "clock.arrow.trianglehead.counterclockwise.rotate.90" }
2992        if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") }) {
2993            return "point.3.connected.trianglepath.dotted"
2994        }
2995        if summary.changedSections.contains(where: {
2996            $0.localizedCaseInsensitiveContains("certificate")
2997                || $0.localizedCaseInsensitiveContains("tls")
2998        }) {
2999            return "lock.rotation"
3000        }
3001        if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("redirect") }) {
3002            return "arrow.triangle.branch"
3003        }
3004        return "clock.arrow.trianglehead.counterclockwise.rotate.90"
3005    }
3006
3007    private func portfolioMonitoringMessage(for result: MonitoringDomainResult) -> String {
3008        if result.errorMessage != nil {
3009            return "Monitoring failed for \(result.domain)"
3010        }
3011        if result.certificateWarningLevel != .none {
3012            return "Certificate needs attention for \(result.domain)"
3013        }
3014        if result.didChange {
3015            return result.summaryMessage.isEmpty ? "Monitoring detected a change for \(result.domain)" : result.summaryMessage
3016        }
3017        return "Monitoring updated \(result.domain)"
3018    }
3019
3020    private func portfolioMonitoringIcon(for result: MonitoringDomainResult) -> String {
3021        if result.errorMessage != nil {
3022            return "xmark.octagon.fill"
3023        }
3024        if result.certificateWarningLevel != .none {
3025            return "exclamationmark.triangle.fill"
3026        }
3027        return "waveform.path.ecg"
3028    }
3029
3030    private func apexDomain(for domain: String) -> String {
3031        let parts = domain
3032            .lowercased()
3033            .split(separator: ".")
3034            .map(String.init)
3035        guard parts.count > 2 else { return domain.lowercased() }
3036        return parts.suffix(2).joined(separator: ".")
3037    }
3038
3039    private func healthRank(_ health: DomainHealth) -> Int {
3040        switch health {
3041        case .healthy:
3042            return 0
3043        case .warning:
3044            return 1
3045        case .critical:
3046            return 2
3047        }
3048    }
3049
3050    private func exportSnapshots(for domains: [TrackedDomain]) -> [LookupSnapshot] {
3051        let latestEntries = latestSnapshots(for: domains)
3052
3053        return domains.map { trackedDomain in
3054            if let entry = latestEntries.first(where: { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }) {
3055                return entry.snapshot
3056            }
3057            return placeholderSnapshot(for: trackedDomain)
3058        }
3059    }
3060
3061    func currentBatchReports() -> [DomainReport] {
3062        currentBatchResultEntries.map { entry in
3063            report(for: entry, workflowContext: activeWorkflowContext)
3064        }
3065    }
3066
3067    func workflowReports(from summary: WorkflowRunSummary, changedOnly: Bool) -> [DomainReport] {
3068        let filteredResults = changedOnly ? summary.results.filter(\.hasMeaningfulChange) : summary.results
3069        return filteredResults.compactMap { result in
3070            guard let entry = historyEntry(for: result) else { return nil }
3071            return report(
3072                for: entry,
3073                workflowContext: DomainWorkflowContext(
3074                    workflowID: summary.workflowID,
3075                    workflowName: summary.workflowName,
3076                    source: "workflow"
3077                )
3078            )
3079        }
3080    }
3081
3082    func reports(for domains: [TrackedDomain]) -> [DomainReport] {
3083        let latestEntries = latestSnapshots(for: domains)
3084
3085        return domains.map { trackedDomain in
3086            if let entry = latestEntries.first(where: {
3087                $0.trackedDomainID == trackedDomain.id ||
3088                $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame
3089            }) {
3090                return report(for: entry)
3091            }
3092
3093            return reportBuilder.build(from: placeholderSnapshot(for: trackedDomain))
3094        }
3095    }
3096
3097    func timelineReports(for domain: String) -> [DomainReport] {
3098        historyEntries(for: domain).map { report(for: $0) }
3099    }
3100
3101    private func report(for entry: HistoryEntry, workflowContext: DomainWorkflowContext? = nil) -> DomainReport {
3102        reportBuilder.build(
3103            from: entry,
3104            previousSnapshot: comparisonSnapshot(for: entry),
3105            workflowContext: workflowContext,
3106            historyEntries: historyEntries(for: entry.domain)
3107        )
3108    }
3109
3110    private var activeWorkflowContext: DomainWorkflowContext? {
3111        guard batchLookupSource == .workflow, let activeWorkflowRunID, let activeWorkflowRunName else {
3112            return nil
3113        }
3114        return DomainWorkflowContext(
3115            workflowID: activeWorkflowRunID,
3116            workflowName: activeWorkflowRunName,
3117            source: "workflow"
3118        )
3119    }
3120
3121    private static var defaultAuditReviewer: String {
3122        let reviewer = NSFullUserName().trimmingCharacters(in: .whitespacesAndNewlines)
3123        return reviewer.isEmpty ? "Local Reviewer" : reviewer
3124    }
3125
3126    private func snapshotEvidenceAssets(from report: DomainReport) -> [AuditEvidenceAsset] {
3127        var assets: [AuditEvidenceAsset] = []
3128        if let finalURL = report.web.finalURL {
3129            assets.append(AuditEvidenceAsset(title: "Final URL", kind: .document, reference: finalURL))
3130        }
3131        if let registrar = report.ownership?.registrar {
3132            assets.append(AuditEvidenceAsset(title: "Registrar", kind: .document, reference: registrar))
3133        }
3134        if let primaryIP = report.dns.primaryIP {
3135            assets.append(AuditEvidenceAsset(title: "Primary IP", kind: .document, reference: primaryIP))
3136        }
3137        if !report.web.redirectChain.isEmpty {
3138            assets.append(
3139                AuditEvidenceAsset(
3140                    title: "Redirect Chain",
3141                    kind: .document,
3142                    reference: report.web.redirectChain.map { "\($0.statusCode) \($0.url)" }.joined(separator: " | ")
3143                )
3144            )
3145        }
3146        if !report.web.headers.isEmpty {
3147            assets.append(
3148                AuditEvidenceAsset(
3149                    title: "Observed Headers",
3150                    kind: .document,
3151                    reference: report.web.headers.prefix(6).map { "\($0.name): \($0.value)" }.joined(separator: " | ")
3152                )
3153            )
3154        }
3155        return assets
3156    }
3157
3158    private func placeholderSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot {
3159        LookupSnapshot(
3160            historyEntryID: trackedDomain.lastSnapshotID,
3161            domain: trackedDomain.domain,
3162            timestamp: trackedDomain.updatedAt,
3163            trackedDomainID: trackedDomain.id,
3164            note: trackedDomain.note,
3165            appVersion: AppVersion.current,
3166            resolverDisplayName: resolverDisplayName,
3167            resolverURLString: resolverURLString,
3168            dataSources: [],
3169            provenanceBySection: [:],
3170            availabilityConfidence: nil,
3171            ownershipConfidence: nil,
3172            subdomainConfidence: nil,
3173            emailSecurityConfidence: nil,
3174            geolocationConfidence: nil,
3175            errorDetails: [:],
3176            isPartialSnapshot: true,
3177            validationIssues: ["No stored snapshot data available"],
3178            totalLookupDurationMs: nil,
3179            snapshotIndex: nil,
3180            previousSnapshotID: nil,
3181            changeCount: 0,
3182            severitySummary: trackedDomain.lastChangeSeverity,
3183            dnsSections: [],
3184            dnsError: nil,
3185            availabilityResult: DomainAvailabilityResult(domain: trackedDomain.domain, status: trackedDomain.lastKnownAvailability ?? .unknown),
3186            suggestions: [],
3187            sslInfo: nil,
3188            sslError: nil,
3189            hstsPreloaded: nil,
3190            httpHeaders: [],
3191            httpSecurityGrade: nil,
3192            httpStatusCode: nil,
3193            httpResponseTimeMs: nil,
3194            httpProtocol: nil,
3195            http3Advertised: false,
3196            httpHeadersError: nil,
3197            reachabilityResults: [],
3198            reachabilityError: nil,
3199            ipGeolocation: nil,
3200            ipGeolocationError: nil,
3201            emailSecurity: nil,
3202            emailSecurityError: nil,
3203            ownership: nil,
3204            ownershipError: nil,
3205            ownershipHistory: [],
3206            ownershipHistoryError: nil,
3207            inferredProvider: nil,
3208            priorProviders: [],
3209            domainClassification: nil,
3210            ownershipTransitions: [],
3211            hostingTransitions: [],
3212            subdomainHistory: [],
3213            riskSignals: [],
3214            intelligenceTimeline: [],
3215            ptrRecord: nil,
3216            ptrError: nil,
3217            redirectChain: [],
3218            redirectChainError: nil,
3219            subdomains: [],
3220            subdomainsError: nil,
3221            extendedSubdomains: [],
3222            extendedSubdomainsError: nil,
3223            dnsHistory: [],
3224            dnsHistoryError: nil,
3225            domainPricing: nil,
3226            domainPricingError: nil,
3227            reputation: nil,
3228            reputationError: nil,
3229            portScanResults: [],
3230            portScanError: nil,
3231            changeSummary: trackedDomain.lastChangeSummary,
3232            resultSource: .snapshot,
3233            cachedSections: [],
3234            statusMessage: nil
3235        )
3236    }
3237
3238    private static func loadHistoryEntries() -> [HistoryEntry] {
3239        DataMigrationService.migrateIfNeeded()
3240        return DomainDataPortabilityService.loadHistoryEntries()
3241    }
3242
3243    private static func loadHistoryAutoPruneOption() -> HistoryAutoPruneOption {
3244        guard let rawValue = UserDefaults.standard.string(forKey: historyAutoPruneKey),
3245              let option = HistoryAutoPruneOption(rawValue: rawValue) else {
3246            return .unlimited
3247        }
3248        return option
3249    }
3250
3251    private static func loadTrackedDomains() -> [TrackedDomain] {
3252        DataMigrationService.migrateIfNeeded()
3253        return DomainDataPortabilityService.loadTrackedDomains()
3254    }
3255
3256    func persistWorkflows() {
3257        DomainDataPortabilityService.saveWorkflows(workflows)
3258        CloudSyncService.shared.scheduleSyncIfNeeded()
3259        refreshDataLifecycleSummary()
3260    }
3261
3262    static func loadWorkflows() -> [DomainWorkflow] {
3263        DataMigrationService.migrateIfNeeded()
3264        return DomainDataPortabilityService.loadWorkflows()
3265    }
3266
3267    private static func deduplicatedTrackedDomains(_ domains: [TrackedDomain]) -> [TrackedDomain] {
3268        var seen = Set<String>()
3269        return domains.filter { domain in
3270            let key = domain.domain.lowercased()
3271            return seen.insert(key).inserted
3272        }
3273    }
3274
3275    func normalizedDomains(_ domains: [String]) -> [String] {
3276        var seen = Set<String>()
3277        return domains
3278            .map(normalizedDomain)
3279            .filter { !$0.isEmpty }
3280            .filter { seen.insert($0).inserted }
3281    }
3282
3283    private func historySortPredicate(lhs: HistoryEntry, rhs: HistoryEntry) -> Bool {
3284        switch historySortOption {
3285        case .newest:
3286            return lhs.timestamp > rhs.timestamp
3287        case .oldest:
3288            return lhs.timestamp < rhs.timestamp
3289        case .domain:
3290            let domainOrder = lhs.domain.localizedCaseInsensitiveCompare(rhs.domain)
3291            if domainOrder != .orderedSame {
3292                return domainOrder == .orderedAscending
3293            }
3294            return lhs.timestamp > rhs.timestamp
3295        }
3296    }
3297
3298    private func sortedTrackedDomains(from domains: [TrackedDomain], using sortOption: WatchlistSortOption) -> [TrackedDomain] {
3299        domains.sorted { lhs, rhs in
3300            switch sortOption {
3301            case .pinned:
3302                if lhs.isPinned != rhs.isPinned {
3303                    return lhs.isPinned && !rhs.isPinned
3304                }
3305                if lhs.updatedAt != rhs.updatedAt {
3306                    return lhs.updatedAt > rhs.updatedAt
3307                }
3308                return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending
3309            case .recentlyUpdated:
3310                if lhs.updatedAt != rhs.updatedAt {
3311                    return lhs.updatedAt > rhs.updatedAt
3312                }
3313                return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending
3314            case .alphabetical:
3315                let domainOrder = lhs.domain.localizedCaseInsensitiveCompare(rhs.domain)
3316                if domainOrder != .orderedSame {
3317                    return domainOrder == .orderedAscending
3318                }
3319                return lhs.updatedAt > rhs.updatedAt
3320            }
3321        }
3322    }
3323
3324    static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] {
3325        [
3326            SummaryFieldViewData(label: "Domain", value: snapshot.domain.nilIfEmpty ?? "Unavailable", tone: .primary),
3327            SummaryFieldViewData(label: "Observed IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary),
3328            SummaryFieldViewData(label: "Observed Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary),
3329            SummaryFieldViewData(label: "Inference", value: availabilityInference(from: snapshot), tone: availabilityTone(snapshot.availabilityResult?.status)),
3330            SummaryFieldViewData(label: "Observed TLS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)),
3331            SummaryFieldViewData(label: "Certificate", value: certificateStatusLabel(from: snapshot), tone: certificateStatusTone(from: snapshot)),
3332            SummaryFieldViewData(label: "Source", value: snapshot.statusMessage ?? snapshot.resultSource.label, tone: sourceTone(for: snapshot))
3333        ]
3334    }
3335
3336    static func domainRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
3337        var rows = [
3338            InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary),
3339            InfoRowViewData(label: "Resolver", value: snapshot.resolverDisplayName, tone: .secondary),
3340            InfoRowViewData(label: "Collected", value: snapshot.timestamp.formatted(date: .abbreviated, time: .shortened), tone: .secondary),
3341            InfoRowViewData(label: snapshot.statusMessage == nil ? "Result" : "Snapshot", value: snapshot.statusMessage ?? snapshot.resultSource.label, tone: sourceTone(for: snapshot)),
3342            InfoRowViewData(label: "Lookup Duration", value: durationLabel(snapshot.totalLookupDurationMs), tone: .secondary)
3343        ]
3344        rows.insert(
3345            InfoRowViewData(
3346                label: "Observed Availability",
3347                value: snapshot.availabilityResult?.status == .unknown ? "No direct registration proof" : "Status collected",
3348                tone: .secondary
3349            ),
3350            at: 1
3351        )
3352        rows.insert(
3353            InfoRowViewData(
3354                label: "Inference",
3355                value: availabilityInference(from: snapshot),
3356                tone: availabilityTone(snapshot.availabilityResult?.status)
3357            ),
3358            at: 2
3359        )
3360        if let confidence = snapshot.availabilityConfidence {
3361            rows.insert(
3362                InfoRowViewData(label: "Confidence", value: confidence.title, tone: .secondary),
3363                at: 3
3364            )
3365        }
3366        if let pricing = snapshot.domainPricing {
3367            rows.append(
3368                InfoRowViewData(
3369                    label: "External Price",
3370                    value: pricing.estimatedPrice ?? "Unavailable",
3371                    tone: .secondary
3372                )
3373            )
3374            if let premiumIndicator = pricing.premiumIndicator {
3375                rows.append(
3376                    InfoRowViewData(
3377                        label: "Premium",
3378                        value: premiumIndicator ? "Yes" : "No",
3379                        tone: premiumIndicator ? .warning : .secondary
3380                    )
3381                )
3382            }
3383            if let resaleSignal = pricing.resaleSignal {
3384                rows.append(InfoRowViewData(label: "Resale", value: resaleSignal, tone: .secondary))
3385            }
3386            if let auctionSignal = pricing.auctionSignal {
3387                rows.append(InfoRowViewData(label: "Auction", value: auctionSignal, tone: .secondary))
3388            }
3389        }
3390        if let reputation = snapshot.reputation {
3391            let tone: ResultTone
3392            switch reputation.status {
3393            case .clean: tone = .success
3394            case .listed: tone = .failure
3395            case .unknown: tone = .secondary
3396            }
3397            let value = reputation.status == .listed && !reputation.listedSources.isEmpty
3398                ? "\(reputation.status.title) (\(reputation.listedSources.joined(separator: ", ")))"
3399                : reputation.status.title
3400            rows.append(InfoRowViewData(label: "Reputation", value: value, tone: tone))
3401        }
3402        if let certificateStatus = certificateBadgeLabel(from: snapshot) {
3403            rows.insert(
3404                InfoRowViewData(
3405                    label: "Certificate",
3406                    value: certificateStatus,
3407                    tone: certificateStatusTone(from: snapshot)
3408                ),
3409                at: 2
3410            )
3411        }
3412        return rows
3413    }
3414
3415    static func suggestionRows(from snapshot: LookupSnapshot) -> [DomainSuggestionViewData] {
3416        snapshot.suggestions.map {
3417            DomainSuggestionViewData(
3418                id: $0.id,
3419                domain: $0.domain,
3420                availabilityStatus: $0.status,
3421                status: availabilityLabel($0.status),
3422                tone: availabilityTone($0.status)
3423            )
3424        }
3425    }
3426
3427    static func subdomainRows(from subdomains: [DiscoveredSubdomain]) -> [SubdomainRowViewData] {
3428        subdomains.map { subdomain in
3429            SubdomainRowViewData(
3430                hostname: subdomain.hostname,
3431                isInteresting: subdomain.isExtended || isInterestingSubdomain(subdomain.hostname)
3432            )
3433        }
3434    }
3435
3436    static func dnsRows(from snapshot: LookupSnapshot) -> [DNSRecordSectionViewData] {
3437        snapshot.dnsSections.map { section in
3438            DNSRecordSectionViewData(
3439                title: section.recordType.rawValue,
3440                rows: section.records.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary, speechStyle: .technical) },
3441                wildcardRows: section.wildcardRecords.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary, speechStyle: .technical) },
3442                wildcardTitle: section.wildcardRecords.isEmpty ? nil : "*.\(snapshot.domain)",
3443                message: section.error.map { SectionMessageViewData(text: $0, isError: true) } ??
3444                    ((section.records.isEmpty && section.wildcardRecords.isEmpty) ? SectionMessageViewData(text: "No records found", isError: false) : nil)
3445            )
3446        }
3447    }
3448
3449    static func dnssecLabel(from snapshot: LookupSnapshot) -> String? {
3450        guard let signed = snapshot.dnsSections.compactMap(\.dnssecSigned).first else { return nil }
3451        return "Resolver-reported DNSSEC (not full validation): \(signed ? "Yes" : "No")"
3452    }
3453
3454    static func ptrMessage(from snapshot: LookupSnapshot) -> SectionMessageViewData? {
3455        if let ptrRecord = snapshot.ptrRecord {
3456            return SectionMessageViewData(text: ptrRecord, isError: false)
3457        }
3458        if let ptrError = snapshot.ptrError {
3459            return SectionMessageViewData(text: ptrError, isError: ptrError != "No A record available" && ptrError != "No PTR record found")
3460        }
3461        return nil
3462    }
3463
3464    static func webCertificateRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
3465        guard let sslInfo = snapshot.sslInfo else { return [] }
3466        var rows = [
3467            InfoRowViewData(label: "Common Name", value: sslInfo.commonName, tone: .primary),
3468            InfoRowViewData(label: "Issuer", value: sslInfo.issuer, tone: .primary),
3469            InfoRowViewData(label: "Valid From", value: certificateDateFormatter.string(from: sslInfo.validFrom), tone: .secondary),
3470            InfoRowViewData(label: "Valid Until", value: certificateDateFormatter.string(from: sslInfo.validUntil), tone: .secondary),
3471            InfoRowViewData(label: "Days Until Expiry", value: "\(sslInfo.daysUntilExpiry)", tone: certificateTone(daysRemaining: sslInfo.daysUntilExpiry)),
3472            InfoRowViewData(label: "Chain Depth", value: "\(sslInfo.chainDepth)", tone: .secondary)
3473        ]
3474        if let tlsVersion = sslInfo.tlsVersion {
3475            rows.append(InfoRowViewData(label: "TLS Version", value: tlsVersion, tone: .secondary))
3476        }
3477        if let cipherSuite = sslInfo.cipherSuite {
3478            rows.append(InfoRowViewData(label: "Cipher Suite", value: cipherSuite, tone: .secondary, speechStyle: .technical))
3479        }
3480        if let hstsPreloaded = snapshot.hstsPreloaded {
3481            rows.append(InfoRowViewData(label: "HSTS Preload", value: hstsPreloaded ? "Preloaded" : "Not preloaded", tone: hstsPreloaded ? .success : .secondary))
3482        }
3483        return rows
3484    }
3485
3486    static func webResponseRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
3487        var rows: [InfoRowViewData] = []
3488        if let httpStatusCode = snapshot.httpStatusCode {
3489            rows.append(InfoRowViewData(label: "Status", value: "\(httpStatusCode)", tone: .primary))
3490        }
3491        if let httpResponseTimeMs = snapshot.httpResponseTimeMs {
3492            rows.append(InfoRowViewData(label: "Response Time", value: "\(httpResponseTimeMs) ms", tone: .secondary))
3493        }
3494        if let httpProtocol = snapshot.httpProtocol {
3495            rows.append(InfoRowViewData(label: "Protocol", value: httpProtocol, tone: .secondary))
3496        }
3497        if let httpSecurityGrade = snapshot.httpSecurityGrade {
3498            rows.append(InfoRowViewData(label: "Security Grade", value: httpSecurityGrade, tone: securityGradeTone(httpSecurityGrade)))
3499        }
3500        if snapshot.http3Advertised {
3501            rows.append(InfoRowViewData(label: "HTTP/3", value: "Advertised", tone: .secondary))
3502        }
3503        return rows
3504    }
3505
3506    static func redirectRows(from snapshot: LookupSnapshot) -> [RedirectHopViewData] {
3507        snapshot.redirectChain.map {
3508            RedirectHopViewData(
3509                stepLabel: "\($0.stepNumber)",
3510                statusCode: "\($0.statusCode)",
3511                url: $0.url,
3512                isFinal: $0.isFinal
3513            )
3514        }
3515    }
3516
3517    static func emailRows(from snapshot: LookupSnapshot) -> [EmailRowViewData] {
3518        guard let emailSecurity = snapshot.emailSecurity else { return [] }
3519        return [
3520            EmailRowViewData(label: "SPF", status: emailSecurity.spf.found ? "Present" : "Missing", statusTone: emailSecurity.spf.found ? .success : .warning, detail: emailSecurity.spf.value ?? "No record found", auxiliaryDetail: nil),
3521            EmailRowViewData(label: "DMARC", status: emailSecurity.dmarc.found ? "Present" : "Missing", statusTone: emailSecurity.dmarc.found ? .success : .warning, detail: emailSecurity.dmarc.value ?? "No record found", auxiliaryDetail: nil),
3522            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)" }),
3523            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),
3524            EmailRowViewData(label: "BIMI", status: emailSecurity.bimi.found ? "Present" : "Missing", statusTone: emailSecurity.bimi.found ? .success : .warning, detail: emailSecurity.bimi.value ?? "No record found", auxiliaryDetail: nil)
3525        ]
3526    }
3527
3528    static func ownershipRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
3529        let ownership = snapshot.ownership
3530
3531        return [
3532            InfoRowViewData(label: "Registrar", value: ownership?.registrar ?? "Unavailable", tone: ownership?.registrar == nil ? .secondary : .primary),
3533            InfoRowViewData(label: "Registered", value: ownership?.createdDate.map(ownershipDateFormatter.string(from:)) ?? "Unavailable", tone: ownership?.createdDate == nil ? .secondary : .primary),
3534            InfoRowViewData(label: "Expires", value: ownership?.expirationDate.map(ownershipDateFormatter.string(from:)) ?? "Unavailable", tone: ownership?.expirationDate == nil ? .secondary : .primary),
3535            InfoRowViewData(label: "Status", value: ownership?.status.nilIfEmpty?.joined(separator: ", ") ?? "Unavailable", tone: ownership?.status.isEmpty == false ? .primary : .secondary),
3536            InfoRowViewData(label: "Nameservers", value: ownership?.nameservers.nilIfEmpty?.joined(separator: ", ") ?? "Unavailable", tone: ownership?.nameservers.isEmpty == false ? .primary : .secondary),
3537            InfoRowViewData(label: "Abuse Contact", value: ownership?.abuseEmail ?? "Unavailable", tone: ownership?.abuseEmail == nil ? .secondary : .primary)
3538        ]
3539    }
3540
3541    static func subdomainRows(from snapshot: LookupSnapshot) -> [SubdomainRowViewData] {
3542        snapshot.subdomains.map { subdomain in
3543            SubdomainRowViewData(
3544                hostname: subdomain.hostname,
3545                isInteresting: isInterestingSubdomain(subdomain.hostname)
3546            )
3547        }
3548    }
3549
3550    static func reachabilityRows(from snapshot: LookupSnapshot) -> [ReachabilityRowViewData] {
3551        snapshot.reachabilityResults.map {
3552            ReachabilityRowViewData(
3553                portLabel: "Port \($0.port)",
3554                latencyLabel: $0.latencyMs.map { "\($0) ms" } ?? "",
3555                statusLabel: $0.reachable ? "Reachable" : "Unreachable",
3556                statusTone: $0.reachable ? .success : .failure
3557            )
3558        }
3559    }
3560
3561    static func locationRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
3562        guard let ipGeolocation = snapshot.ipGeolocation else { return [] }
3563        var rows = [InfoRowViewData(label: "IP", value: ipGeolocation.ip, tone: .primary)]
3564        if let org = ipGeolocation.org {
3565            rows.append(InfoRowViewData(label: "Org / ISP", value: org, tone: .secondary))
3566        }
3567        let location = [ipGeolocation.city, ipGeolocation.region, ipGeolocation.countryName].compactMap { $0 }.joined(separator: ", ")
3568        if !location.isEmpty {
3569            rows.append(InfoRowViewData(label: "Location", value: location, tone: .secondary))
3570        }
3571        if let latitude = ipGeolocation.latitude, let longitude = ipGeolocation.longitude {
3572            rows.append(InfoRowViewData(label: "Coordinates", value: "\(latitude), \(longitude)", tone: .secondary))
3573        }
3574        return rows
3575    }
3576
3577    static func portRows(from snapshot: LookupSnapshot, kind: PortScanKind) -> [PortScanRowViewData] {
3578        snapshot.portScanResults
3579            .filter { $0.kind == kind }
3580            .map {
3581                PortScanRowViewData(
3582                    portLabel: "\($0.port)",
3583                    service: $0.service,
3584                    statusLabel: $0.open ? "Open" : "Closed",
3585                    statusTone: $0.open ? .success : .secondary,
3586                    banner: $0.banner,
3587                    durationLabel: $0.durationMs.map { "\($0) ms" }
3588                )
3589            }
3590    }
3591
3592    static func formatBatchExportText(
3593        title: String,
3594        entries: [(snapshot: LookupSnapshot, trackedDomain: TrackedDomain?, changeSummary: DomainChangeSummary?, diffSections: [DomainDiffSection])]
3595    ) -> String {
3596        guard !entries.isEmpty else {
3597            return "\(title)\nNo results available."
3598        }
3599
3600        var lines = [title, String(repeating: "=", count: title.count), ""]
3601        for (index, entry) in entries.enumerated() {
3602            if index > 0 {
3603                lines.append("")
3604                lines.append(String(repeating: "=", count: 48))
3605                lines.append("")
3606            }
3607
3608            lines.append(
3609                formatExportText(
3610                    from: entry.snapshot,
3611                    trackedDomain: entry.trackedDomain,
3612                    changeSummary: entry.changeSummary,
3613                    diffSections: entry.diffSections
3614                )
3615            )
3616        }
3617        return lines.joined(separator: "\n")
3618    }
3619
3620    static func formatCSV(from snapshots: [LookupSnapshot]) -> String {
3621        let headers = [
3622            "domain",
3623            "availability",
3624            "primary_ip",
3625            "redirect_target",
3626            "tls_status",
3627            "http_status_grade",
3628            "email_security_summary",
3629            "registrar",
3630            "ownership_expires",
3631            "ownership_status",
3632            "ownership_nameservers",
3633            "subdomain_count",
3634            "subdomains",
3635            "last_updated"
3636        ]
3637
3638        let rows = snapshots.map { snapshot in
3639            [
3640                snapshot.domain,
3641                availabilityLabel(snapshot.availabilityResult?.status),
3642                primaryIPAddress(from: snapshot) ?? "",
3643                finalRedirectTarget(from: snapshot) ?? "",
3644                httpsSummary(from: snapshot),
3645                httpStatusGradeSummary(from: snapshot),
3646                emailSummary(from: snapshot),
3647                snapshot.ownership?.registrar ?? "",
3648                snapshot.ownership?.expirationDate.map(csvDateFormatter.string(from:)) ?? "",
3649                snapshot.ownership?.status.joined(separator: " | ") ?? "",
3650                snapshot.ownership?.nameservers.joined(separator: " | ") ?? "",
3651                "\(snapshot.subdomains.count)",
3652                snapshot.subdomains.map(\.hostname).joined(separator: " | "),
3653                csvDateFormatter.string(from: snapshot.timestamp)
3654            ]
3655        }
3656
3657        return ([headers] + rows)
3658            .map { row in row.map(csvEscaped).joined(separator: ",") }
3659            .joined(separator: "\n")
3660    }
3661
3662    static func formatExportText(
3663        from snapshot: LookupSnapshot,
3664        trackedDomain: TrackedDomain?,
3665        changeSummary: DomainChangeSummary?,
3666        diffSections: [DomainDiffSection]
3667    ) -> String {
3668        let exportDateFormatter = DateFormatter()
3669        exportDateFormatter.dateFormat = "yyyy-MM-dd HH:mm"
3670
3671        var lines: [String] = [
3672            "DomainDig Export",
3673            "Domain: \(snapshot.domain)",
3674            "Date: \(exportDateFormatter.string(from: snapshot.timestamp))",
3675            "Mode: \(snapshot.statusMessage ?? snapshot.resultSource.label)",
3676            "Resolver: \(snapshot.resolverDisplayName)",
3677            "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))",
3678            "Tracked: \(trackedDomain == nil ? "No" : "Yes")"
3679        ]
3680
3681        if let note = trackedDomain?.note?.nilIfEmpty {
3682            lines.append("Tracking Note: \(note)")
3683        }
3684
3685        func appendSection(_ title: String, body: () -> Void) {
3686            lines.append("")
3687            lines.append(title)
3688            lines.append(String(repeating: "-", count: title.count))
3689            body()
3690        }
3691
3692        appendSection("Summary") {
3693            for item in summaryFields(from: snapshot) {
3694                lines.append("  \(item.label): \(item.value)")
3695            }
3696            if let changeSummary {
3697                lines.append("  Change Summary: \(changeSummary.message)")
3698                lines.append("  Severity: \(changeSummary.severity.title)")
3699                lines.append("  Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))")
3700                lines.append("  Compared At: \(exportDateFormatter.string(from: changeSummary.generatedAt))")
3701            }
3702        }
3703
3704        appendSection("Tracking") {
3705            if let trackedDomain {
3706                lines.append("  Pinned: \(trackedDomain.isPinned ? "Yes" : "No")")
3707                lines.append("  Last Refresh: \(exportDateFormatter.string(from: trackedDomain.updatedAt))")
3708                lines.append("  Last Known Availability: \(availabilityLabel(trackedDomain.lastKnownAvailability))")
3709                if let note = trackedDomain.note?.nilIfEmpty {
3710                    lines.append("  Note: \(note)")
3711                }
3712            } else {
3713                lines.append("  This domain is not currently tracked.")
3714            }
3715        }
3716
3717        appendSection("Diff Summary") {
3718            if diffSections.isEmpty {
3719                lines.append("  No comparison available")
3720            } else {
3721                for section in diffSections where section.items.contains(where: { $0.changeType != .unchanged }) {
3722                    lines.append("  \(section.title)")
3723                    for item in section.items where item.changeType != .unchanged {
3724                        lines.append("    [\(item.changeType.rawValue.capitalized)] \(item.label): \(item.oldValue ?? "None") -> \(item.newValue ?? "None")")
3725                    }
3726                }
3727            }
3728        }
3729
3730        appendSection("Domain") {
3731            for row in domainRows(from: snapshot) {
3732                lines.append("  \(row.label): \(row.value)")
3733            }
3734            if snapshot.suggestions.isEmpty {
3735                lines.append("  Suggestions: None")
3736            } else {
3737                lines.append("  Suggestions:")
3738                for suggestion in snapshot.suggestions {
3739                    lines.append("    \(suggestion.domain): \(availabilityLabel(suggestion.status))")
3740                }
3741            }
3742        }
3743
3744        appendSection("Ownership") {
3745            for row in ownershipRows(from: snapshot) {
3746                lines.append("  \(row.label): \(row.value)")
3747            }
3748            if let ownershipError = snapshot.ownershipError, snapshot.ownership == nil {
3749                lines.append("  Source: \(ownershipError)")
3750            }
3751            if !DataAccessService.hasAccess(to: .ownershipHistory) {
3752                lines.append("  Ownership history (coming soon)")
3753            }
3754        }
3755
3756        appendSection("Subdomains") {
3757            lines.append("  Count: \(snapshot.subdomains.count)")
3758            if snapshot.subdomains.isEmpty {
3759                lines.append("  \(snapshot.subdomainsError ?? "No passive subdomains found")")
3760            } else {
3761                for subdomain in subdomainRows(from: snapshot) {
3762                    let marker = subdomain.isInteresting ? " [interesting]" : ""
3763                    lines.append("  \(subdomain.hostname)\(marker)")
3764                }
3765            }
3766            if !DataAccessService.hasAccess(to: .extendedSubdomains) {
3767                lines.append("  Extended subdomain discovery (Pro+)")
3768            }
3769        }
3770
3771        appendSection("DNS") {
3772            if let dnsError = snapshot.dnsError {
3773                lines.append("  Error: \(dnsError)")
3774            }
3775            if let dnssecLabel = dnssecLabel(from: snapshot) {
3776                lines.append("  \(dnssecLabel)")
3777            }
3778            for section in dnsRows(from: snapshot) {
3779                lines.append("  \(section.title)")
3780                if let message = section.message {
3781                    lines.append("    \(message.isError ? "Error" : "Info"): \(message.text)")
3782                }
3783                for row in section.rows {
3784                    lines.append("    \(row.value) (\(row.label))")
3785                }
3786                if let wildcardTitle = section.wildcardTitle {
3787                    lines.append("    \(wildcardTitle)")
3788                    for row in section.wildcardRows {
3789                        lines.append("      \(row.value) (\(row.label))")
3790                    }
3791                }
3792            }
3793            if let ptrRecord = snapshot.ptrRecord {
3794                lines.append("  PTR: \(ptrRecord)")
3795            } else if let ptrError = snapshot.ptrError {
3796                lines.append("  PTR Error: \(ptrError)")
3797            }
3798        }
3799
3800        appendSection("Web") {
3801            if let sslError = snapshot.sslError {
3802                lines.append("  TLS Error: \(sslError)")
3803            } else {
3804                for row in webCertificateRows(from: snapshot) {
3805                    lines.append("  \(row.label): \(row.value)")
3806                }
3807            }
3808
3809            if let httpHeadersError = snapshot.httpHeadersError {
3810                lines.append("  Headers Error: \(httpHeadersError)")
3811            } else {
3812                for row in webResponseRows(from: snapshot) {
3813                    lines.append("  \(row.label): \(row.value)")
3814                }
3815                if snapshot.httpHeaders.isEmpty {
3816                    lines.append("  Headers: No headers returned")
3817                } else {
3818                    lines.append("  Headers:")
3819                    for header in snapshot.httpHeaders {
3820                        lines.append("    \(header.name): \(header.value)")
3821                    }
3822                }
3823            }
3824
3825            if let redirectChainError = snapshot.redirectChainError {
3826                lines.append("  Redirect Error: \(redirectChainError)")
3827            } else if snapshot.redirectChain.isEmpty {
3828                lines.append("  Redirects: No redirect data available")
3829            } else {
3830                lines.append("  Redirects:")
3831                for hop in redirectRows(from: snapshot) {
3832                    lines.append("    \(hop.stepLabel). \(hop.statusCode) \(hop.url)\(hop.isFinal ? " (final)" : "")")
3833                }
3834            }
3835        }
3836
3837        appendSection("Email") {
3838            if let emailSecurityError = snapshot.emailSecurityError {
3839                lines.append("  Error: \(emailSecurityError)")
3840            } else if emailRows(from: snapshot).isEmpty {
3841                lines.append("  No email security records found")
3842            } else {
3843                for row in emailRows(from: snapshot) {
3844                    lines.append("  \(row.label): \(row.status)")
3845                    lines.append("    \(row.detail)")
3846                    if let auxiliaryDetail = row.auxiliaryDetail {
3847                        lines.append("    \(auxiliaryDetail)")
3848                    }
3849                }
3850            }
3851        }
3852
3853        appendSection("Network") {
3854            if let reachabilityError = snapshot.reachabilityError {
3855                lines.append("  Reachability Error: \(reachabilityError)")
3856            } else if reachabilityRows(from: snapshot).isEmpty {
3857                lines.append("  Reachability: No results")
3858            } else {
3859                lines.append("  Reachability:")
3860                for row in reachabilityRows(from: snapshot) {
3861                    lines.append("    \(row.portLabel): \(row.statusLabel) \(row.latencyLabel)")
3862                }
3863            }
3864
3865            if let ipGeolocationError = snapshot.ipGeolocationError, snapshot.ipGeolocation == nil {
3866                lines.append("  Location Error: \(ipGeolocationError)")
3867            } else if locationRows(from: snapshot).isEmpty {
3868                lines.append("  Location: No data")
3869            } else {
3870                lines.append("  Location:")
3871                for row in locationRows(from: snapshot) {
3872                    lines.append("    \(row.label): \(row.value)")
3873                }
3874            }
3875
3876            if let portScanError = snapshot.portScanError, snapshot.portScanResults.isEmpty {
3877                lines.append("  Port Scan Error: \(portScanError)")
3878            }
3879
3880            lines.append("  Standard Ports:")
3881            let standardRows = portRows(from: snapshot, kind: .standard)
3882            if standardRows.isEmpty {
3883                lines.append("    No results")
3884            } else {
3885                for row in standardRows {
3886                    lines.append("    \(row.portLabel) \(row.service): \(row.statusLabel)\(row.durationLabel.map { " \($0)" } ?? "")")
3887                    if let banner = row.banner {
3888                        lines.append("      Banner: \(banner)")
3889                    }
3890                }
3891            }
3892
3893            lines.append("  Custom Ports:")
3894            let customRows = portRows(from: snapshot, kind: .custom)
3895            if customRows.isEmpty {
3896                lines.append("    No results")
3897            } else {
3898                for row in customRows {
3899                    lines.append("    \(row.portLabel) \(row.service): \(row.statusLabel)\(row.durationLabel.map { " \($0)" } ?? "")")
3900                    if let banner = row.banner {
3901                        lines.append("      Banner: \(banner)")
3902                    }
3903                }
3904            }
3905        }
3906
3907        return lines.joined(separator: "\n")
3908    }
3909
3910    private static func primaryIPAddress(from snapshot: LookupSnapshot) -> String? {
3911        snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
3912    }
3913
3914    private static func finalRedirectTarget(from snapshot: LookupSnapshot) -> String? {
3915        snapshot.redirectChain.last?.url
3916    }
3917
3918    private static func httpStatusGradeSummary(from snapshot: LookupSnapshot) -> String {
3919        let parts = [snapshot.httpStatusCode.map(String.init), snapshot.httpSecurityGrade].compactMap { $0 }
3920        if !parts.isEmpty {
3921            return parts.joined(separator: " / ")
3922        }
3923        return snapshot.httpHeadersError ?? "Unavailable"
3924    }
3925
3926    private static func httpsSummary(from snapshot: LookupSnapshot) -> String {
3927        if snapshot.sslInfo != nil {
3928            return "Valid"
3929        }
3930        if let sslError = snapshot.sslError {
3931            return sslError.localizedCaseInsensitiveContains("certificate") ? "Invalid" : "Failed"
3932        }
3933        return "Unavailable"
3934    }
3935
3936    private static func httpsSummaryTone(from snapshot: LookupSnapshot) -> ResultTone {
3937        if snapshot.sslInfo != nil {
3938            return .success
3939        }
3940        return snapshot.sslError == nil ? .secondary : .failure
3941    }
3942
3943    private static func emailSummary(from snapshot: LookupSnapshot) -> String {
3944        guard let emailSecurity = snapshot.emailSecurity else {
3945            return snapshot.emailSecurityError ?? "Unavailable"
3946        }
3947        return "SPF \(emailSecurity.spf.found ? "Yes" : "No") / DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")"
3948    }
3949
3950    private static func certificateStatusLabel(from snapshot: LookupSnapshot) -> String {
3951        guard let sslInfo = snapshot.sslInfo else {
3952            return snapshot.sslError ?? "Unavailable"
3953        }
3954
3955        switch DomainDiffService.certificateWarningLevel(for: snapshot) {
3956        case .critical:
3957            return "Critical (\(sslInfo.daysUntilExpiry)d)"
3958        case .warning:
3959            return "Warning (\(sslInfo.daysUntilExpiry)d)"
3960        case .none:
3961            return "Healthy (\(sslInfo.daysUntilExpiry)d)"
3962        }
3963    }
3964
3965    private static func certificateBadgeLabel(from snapshot: LookupSnapshot) -> String? {
3966        guard snapshot.sslInfo != nil else { return nil }
3967        return certificateStatusLabel(from: snapshot)
3968    }
3969
3970    private static func certificateStatusTone(from snapshot: LookupSnapshot) -> ResultTone {
3971        guard let daysRemaining = snapshot.sslInfo?.daysUntilExpiry else {
3972            return snapshot.sslError == nil ? .secondary : .failure
3973        }
3974        return certificateTone(daysRemaining: daysRemaining)
3975    }
3976
3977    private static func certificateTone(daysRemaining: Int) -> ResultTone {
3978        if daysRemaining < 14 {
3979            return .failure
3980        }
3981        if daysRemaining < 30 {
3982            return .warning
3983        }
3984        return .success
3985    }
3986
3987    private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String {
3988        switch status {
3989        case .available:
3990            return "Available"
3991        case .registered:
3992            return "Registered"
3993        case .unknown, .none:
3994            return "Unknown"
3995        }
3996    }
3997
3998    private static func availabilityInference(from snapshot: LookupSnapshot) -> String {
3999        switch snapshot.availabilityResult?.status {
4000        case .registered:
4001            return "Likely registered"
4002        case .available:
4003            return "Possibly available"
4004        case .unknown, .none:
4005            return "Unclear"
4006        }
4007    }
4008
4009    private static func availabilityTone(_ status: DomainAvailabilityStatus?) -> ResultTone {
4010        switch status {
4011        case .available:
4012            return .success
4013        case .registered:
4014            return .warning
4015        case .unknown, .none:
4016            return .secondary
4017        }
4018    }
4019
4020    private static func sourceTone(for snapshot: LookupSnapshot) -> ResultTone {
4021        if snapshot.statusMessage != nil {
4022            return .warning
4023        }
4024
4025        switch snapshot.resultSource {
4026        case .live:
4027            return .success
4028        case .cached:
4029            return .secondary
4030        case .mixed:
4031            return .warning
4032        case .snapshot:
4033            return .warning
4034        }
4035    }
4036
4037    private static func securityGradeTone(_ grade: String) -> ResultTone {
4038        switch grade {
4039        case "A", "B":
4040            return .success
4041        case "C":
4042            return .warning
4043        case "D", "F":
4044            return .failure
4045        default:
4046            return .secondary
4047        }
4048    }
4049
4050    private static func durationLabel(_ durationMs: Int?) -> String {
4051        durationMs.map { "\($0) ms" } ?? "Unavailable"
4052    }
4053
4054    private static let certificateDateFormatter: DateFormatter = {
4055        let formatter = DateFormatter()
4056        formatter.dateStyle = .medium
4057        formatter.timeStyle = .short
4058        return formatter
4059    }()
4060
4061    private static let ownershipDateFormatter: DateFormatter = {
4062        let formatter = DateFormatter()
4063        formatter.dateStyle = .medium
4064        formatter.timeStyle = .none
4065        return formatter
4066    }()
4067
4068    private static let csvDateFormatter: ISO8601DateFormatter = {
4069        let formatter = ISO8601DateFormatter()
4070        formatter.formatOptions = [.withInternetDateTime]
4071        return formatter
4072    }()
4073
4074    private func refreshDomainPricing(for domain: String, persistAfterFetch: Bool) async {
4075        domainPricingLoading = true
4076        let outcome = await ExternalDataService.shared.pricing(domain: domain)
4077
4078        switch outcome.value {
4079        case let .success(pricing):
4080            domainPricing = pricing
4081            domainPricingError = nil
4082        case let .empty(message), let .error(message):
4083            domainPricing = nil
4084            domainPricingError = conciseExternalMessage(message, fallback: "External pricing unavailable")
4085        }
4086
4087        domainPricingLoading = false
4088
4089        if persistAfterFetch {
4090            _ = saveHistoryEntry(replaceLatest: true)
4091        }
4092    }
4093
4094    private func refreshReputation(for domain: String, persistAfterFetch: Bool) async {
4095        reputationLoading = true
4096        let outcome = await ExternalDataService.shared.reputation(domain: domain)
4097
4098        switch outcome.value {
4099        case let .success(result):
4100            reputation = result
4101            reputationError = nil
4102        case let .empty(message), let .error(message):
4103            reputation = nil
4104            reputationError = conciseExternalMessage(message, fallback: "Reputation check unavailable")
4105        }
4106
4107        reputationLoading = false
4108
4109        if persistAfterFetch {
4110            _ = saveHistoryEntry(replaceLatest: true)
4111        }
4112    }
4113
4114    private func conciseExternalMessage(_ message: String, fallback: String) -> String {
4115        let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
4116        if trimmed.isEmpty {
4117            return fallback
4118        }
4119        if trimmed.localizedCaseInsensitiveContains("rate") {
4120            return "Rate limited. Try again later."
4121        }
4122        if trimmed.localizedCaseInsensitiveContains("invalid") {
4123            return "External data was invalid."
4124        }
4125        if trimmed.localizedCaseInsensitiveContains("network") {
4126            return "External data is offline."
4127        }
4128        return trimmed
4129    }
4130
4131    private static func defaultUsageCredits() -> [UsageCreditFeature: UsageCreditStatus] {
4132        Dictionary(uniqueKeysWithValues: UsageCreditFeature.allCases.map { feature in
4133            (feature, fallbackCreditStatus(for: feature))
4134        })
4135    }
4136
4137    private static func fallbackCreditStatus(for feature: UsageCreditFeature) -> UsageCreditStatus {
4138        UsageCreditStatus(
4139            feature: feature,
4140            remaining: feature.defaultAllowance,
4141            total: feature.defaultAllowance,
4142            resetContext: "Resets with app version \(AppVersion.current)"
4143        )
4144    }
4145
4146    private static func csvEscaped(_ value: String) -> String {
4147        let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
4148        return "\"\(escaped)\""
4149    }
4150
4151    private static func isInterestingSubdomain(_ hostname: String) -> Bool {
4152        let keywords = ["admin", "api", "dev", "staging", "test", "internal"]
4153        let labels = hostname.lowercased().split(separator: ".").map(String.init)
4154        return labels.contains { label in
4155            keywords.contains(where: { label.contains($0) })
4156        }
4157    }
4158}
4159
4160private extension String {
4161    var nilIfEmpty: String? {
4162        isEmpty ? nil : self
4163    }
4164}
4165
4166private extension Array where Element == String {
4167    var nilIfEmpty: [String]? {
4168        isEmpty ? nil : self
4169    }
4170}