krz/domain-dig

an ios app for DNS & SSL analysis

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

v4.5.0: DomainDig/DomainViewModel.swift · raw

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