krz/domain-dig

an ios app for DNS & SSL analysis

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

v4.9.0: DomainDig/DomainViewModel.swift · raw

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