krz/domain-dig

an ios app for DNS & SSL analysis

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

v4.8.2: DomainDig/DomainViewModel.swift · raw

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