krz/domain-dig

an ios app for DNS & SSL analysis

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

v4.4.1: 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    }
2586
2587    private func beginBulkPersistenceDeferral() {
2588        historyPersistenceSuspended = true
2589        trackedDomainsPersistenceSuspended = true
2590        historyPersistenceDirty = false
2591        trackedDomainsPersistenceDirty = false
2592    }
2593
2594    private func endBulkPersistenceDeferral() {
2595        historyPersistenceSuspended = false
2596        trackedDomainsPersistenceSuspended = false
2597
2598        if trackedDomainsPersistenceDirty {
2599            trackedDomainsPersistenceDirty = false
2600            DomainDataPortabilityService.saveTrackedDomains(trackedDomains)
2601            CloudSyncService.shared.scheduleSyncIfNeeded()
2602        }
2603
2604        if historyPersistenceDirty {
2605            historyPersistenceDirty = false
2606            DomainDataPortabilityService.saveHistoryEntries(history)
2607        }
2608
2609        refreshDataLifecycleSummary()
2610    }
2611
2612    private func trimHistoryToLimit() {
2613        let hardLimit = historyAutoPruneOption.keepCount ?? Self.maxHistory
2614        history = Array(history.prefix(min(hardLimit, Self.maxHistory)))
2615    }
2616
2617    private func nextSnapshotIndex(for domain: String, trackedDomainID: UUID?) -> Int {
2618        let siblings = history.filter { entry in
2619            if let trackedDomainID {
2620                return entry.trackedDomainID == trackedDomainID
2621            }
2622            return entry.domain.caseInsensitiveCompare(domain) == .orderedSame
2623        }
2624        let existingMax = siblings.compactMap(\.snapshotIndex).max() ?? siblings.count
2625        return existingMax + 1
2626    }
2627
2628    private func persistMonitoringSettings(localActivationConfirmed: Bool = false) {
2629        monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
2630        MonitoringStorage.saveSettings(monitoringSettings)
2631        CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: localActivationConfirmed)
2632    }
2633
2634    private func sanitizeMonitoringSelection() {
2635        monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
2636        MonitoringStorage.saveSettings(monitoringSettings)
2637        CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: false)
2638    }
2639
2640    private func clearMonitoringLogs() {
2641        monitoringLogs.removeAll()
2642        monitoringStatusMessage = nil
2643        MonitoringStorage.saveLogs([])
2644    }
2645
2646    private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) {
2647        guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
2648            return
2649        }
2650        trackedDomains[index].lastKnownAvailability = status
2651        persistTrackedDomains()
2652    }
2653
2654    private func updateTrackedDomainSnapshotMetadata(
2655        domain: String,
2656        snapshotID: UUID,
2657        availabilityStatus: DomainAvailabilityStatus?,
2658        updatedAt: Date,
2659        changeSummary: DomainChangeSummary?,
2660        changeSeverity: ChangeSeverity?,
2661        certificateWarningLevel: CertificateWarningLevel,
2662        certificateDaysRemaining: Int?
2663    ) {
2664        guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
2665            return
2666        }
2667        trackedDomains[index].lastSnapshotID = snapshotID
2668        trackedDomains[index].lastKnownAvailability = availabilityStatus
2669        trackedDomains[index].updatedAt = updatedAt
2670        trackedDomains[index].lastChangeSummary = changeSummary
2671        trackedDomains[index].lastChangeSeverity = changeSeverity
2672        trackedDomains[index].certificateWarningLevel = certificateWarningLevel
2673        trackedDomains[index].certificateDaysRemaining = certificateDaysRemaining
2674        persistTrackedDomains()
2675    }
2676
2677    private func notifyIfNeeded(for entry: HistoryEntry, snapshot: LookupSnapshot, previousSnapshot: LookupSnapshot?) {
2678        guard notificationsAuthorized, entry.trackedDomainID != nil else { return }
2679
2680        Task {
2681            if let summary = entry.changeSummary, summary.hasChanges {
2682                await LocalNotificationService.shared.notifyDomainEvent(
2683                    domain: entry.domain,
2684                    message: summary.message,
2685                    severity: summary.severity
2686                )
2687            }
2688
2689            let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: snapshot)
2690            if certificateWarningLevel == .critical, let daysRemaining = snapshot.sslInfo?.daysUntilExpiry {
2691                await LocalNotificationService.shared.notifyCertificateWarning(
2692                    domain: entry.domain,
2693                    daysRemaining: daysRemaining
2694                )
2695            }
2696
2697            if let previousStatus = previousSnapshot?.availabilityResult?.status,
2698               let newStatus = snapshot.availabilityResult?.status,
2699               previousStatus != newStatus {
2700                await LocalNotificationService.shared.notifyDomainEvent(
2701                    domain: entry.domain,
2702                    message: "Availability changed",
2703                    severity: .high
2704                )
2705            }
2706        }
2707    }
2708
2709    private func previousSnapshot(for domain: String, trackedDomainID: UUID?, replacingLatest: Bool) -> LookupSnapshot? {
2710        let matchingEntries = history.filter { entry in
2711            if let trackedDomainID {
2712                return entry.trackedDomainID == trackedDomainID
2713            }
2714            return entry.domain.caseInsensitiveCompare(domain) == .orderedSame
2715        }
2716
2717        if replacingLatest {
2718            return matchingEntries.dropFirst().first?.snapshot
2719        }
2720        return matchingEntries.first?.snapshot
2721    }
2722
2723    private func trackedDomain(for domain: String) -> TrackedDomain? {
2724        trackedDomains.first { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
2725    }
2726
2727    private func normalizedDomain(_ domain: String) -> String {
2728        domain
2729            .trimmingCharacters(in: .whitespacesAndNewlines)
2730            .replacingOccurrences(of: "https://", with: "")
2731            .replacingOccurrences(of: "http://", with: "")
2732            .components(separatedBy: "/")
2733            .first?
2734            .lowercased() ?? ""
2735    }
2736
2737    private func linkTrackedDomainHistory(for domain: String) {
2738        guard let trackedDomain = trackedDomain(for: domain) else { return }
2739        var didChange = false
2740
2741        for index in history.indices where history[index].domain.caseInsensitiveCompare(domain) == .orderedSame {
2742            if history[index].trackedDomainID != trackedDomain.id {
2743                history[index].trackedDomainID = trackedDomain.id
2744                didChange = true
2745            }
2746        }
2747
2748        if didChange {
2749            persistHistory()
2750        }
2751
2752        if let latestEntry = history.first(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }),
2753           let trackedIndex = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) {
2754            trackedDomains[trackedIndex].lastSnapshotID = latestEntry.id
2755            trackedDomains[trackedIndex].lastChangeSummary = latestEntry.changeSummary
2756            trackedDomains[trackedIndex].lastChangeSeverity = latestEntry.changeSummary?.severity
2757            trackedDomains[trackedIndex].lastKnownAvailability = latestEntry.availabilityResult?.status
2758            trackedDomains[trackedIndex].certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: latestEntry.snapshot)
2759            trackedDomains[trackedIndex].certificateDaysRemaining = latestEntry.sslInfo?.daysUntilExpiry
2760            trackedDomains[trackedIndex].updatedAt = latestEntry.timestamp
2761            persistTrackedDomains()
2762        }
2763    }
2764
2765    private func addRecentSearch(_ domain: String) {
2766        recentSearches.removeAll { $0.lowercased() == domain.lowercased() }
2767        recentSearches.insert(domain, at: 0)
2768        if recentSearches.count > Self.maxRecent {
2769            recentSearches = Array(recentSearches.prefix(Self.maxRecent))
2770        }
2771        DomainDataPortabilityService.saveRecentSearches(recentSearches)
2772        CloudSyncService.shared.markAppSettingsChanged()
2773        refreshDataLifecycleSummary()
2774    }
2775
2776    private func beginLookup(for target: String, cancelExistingTask: Bool = true) -> UUID {
2777        if cancelExistingTask {
2778            lookupTask?.cancel()
2779        }
2780        customPortScanTask?.cancel()
2781
2782        let lookupID = UUID()
2783        activeLookupID = lookupID
2784        lookupStartedAt = Date()
2785        lastLookupDurationMs = nil
2786        addRecentSearch(target)
2787        searchedDomain = target
2788        hasRun = true
2789        currentHistoryEntryID = nil
2790        currentSnapshotTimestamp = Date()
2791        currentResultSource = .live
2792        currentCachedSections = []
2793        currentStatusMessage = nil
2794        currentDiffSections = []
2795        currentChangeSummary = nil
2796        currentReport = nil
2797        ownershipDiff = []
2798        clearLookupState()
2799        setAllLoadingStates(true)
2800        customPortScanLoading = false
2801        return lookupID
2802    }
2803
2804    private func startBatchLookup(domains: [String], source: BatchLookupSource, workflow: DomainWorkflow? = nil) {
2805        guard !domains.isEmpty else { return }
2806        guard !batchLookupRunning else { return }
2807
2808        let now = Date()
2809        if let lastBatchStartedAt, now.timeIntervalSince(lastBatchStartedAt) < 1 {
2810            return
2811        }
2812
2813        lastBatchStartedAt = now
2814        clearBatchState()
2815        batchLookupSource = source
2816        activeWorkflowRunID = workflow?.id
2817        activeWorkflowRunName = workflow?.name
2818        batchTotalCount = domains.count
2819        batchLookupRunning = true
2820        batchResults = domains.map {
2821            BatchLookupResult(
2822                domain: $0,
2823                historyEntryID: nil,
2824                availability: nil,
2825                primaryIP: nil,
2826                quickStatus: "Pending",
2827                timestamp: Date(),
2828                status: .pending
2829            )
2830        }
2831
2832        lookupTask?.cancel()
2833        customPortScanTask?.cancel()
2834        batchTask?.cancel()
2835
2836        batchTask = Task { [weak self] in
2837            guard let self else { return }
2838            self.notificationsAuthorized = await LocalNotificationService.shared.requestAuthorizationIfNeeded()
2839            await self.runBatchLookup(domains: domains, source: source)
2840        }
2841    }
2842
2843    private func clearBatchState() {
2844        batchResults = []
2845        batchLookupSource = .manual
2846        batchCurrentDomain = nil
2847        batchCompletedCount = 0
2848        batchTotalCount = 0
2849        batchLookupRunning = false
2850        latestBatchSweepSummary = nil
2851        latestWorkflowRunSummary = nil
2852        activeBatchDomains = []
2853        activeWorkflowRunID = nil
2854        activeWorkflowRunName = nil
2855        batchTask = nil
2856    }
2857
2858    private func parsedDomains(from input: String) -> [String] {
2859        let separators = CharacterSet(charactersIn: ",\n")
2860        var seen = Set<String>()
2861
2862        return input
2863            .components(separatedBy: separators)
2864            .map(normalizedDomain)
2865            .filter { !$0.isEmpty }
2866            .filter { seen.insert($0).inserted }
2867    }
2868
2869    private func runBatchLookup(domains: [String], source: BatchLookupSource) async {
2870        let concurrencyLimit = min(source == .watchlistRefresh ? 4 : 3, max(domains.count, 1))
2871        var nextIndex = 0
2872        beginBulkPersistenceDeferral()
2873
2874        await withTaskGroup(of: (String, BatchLookupPayload?).self) { group in
2875            for _ in 0..<concurrencyLimit {
2876                guard nextIndex < domains.count else { break }
2877                let domain = domains[nextIndex]
2878                nextIndex += 1
2879                enqueueBatchLookup(domain: domain, source: source, group: &group)
2880            }
2881
2882            while let (domain, payload) = await group.next() {
2883                completeBatchLookup(domain: domain, payload: payload)
2884
2885                if nextIndex < domains.count, !Task.isCancelled {
2886                    let nextDomain = domains[nextIndex]
2887                    nextIndex += 1
2888                    enqueueBatchLookup(domain: nextDomain, source: source, group: &group)
2889                }
2890            }
2891        }
2892
2893        endBulkPersistenceDeferral()
2894        finishBatchLookup(source: source)
2895    }
2896
2897    private func enqueueBatchLookup(
2898        domain: String,
2899        source: BatchLookupSource,
2900        group: inout TaskGroup<(String, BatchLookupPayload?)>
2901    ) {
2902        activeBatchDomains.append(domain)
2903        batchCurrentDomain = activeBatchDomains.first
2904        if source == .watchlistRefresh {
2905            refreshingTrackedDomainID = trackedDomain(for: domain)?.id
2906        }
2907        updateBatchResult(domain: domain, status: .running, quickStatus: "Running", entry: nil, errorMessage: nil)
2908        let previousSnapshot = previousSnapshot(for: domain, trackedDomainID: trackedDomain(for: domain)?.id, replacingLatest: false)
2909
2910        group.addTask { [domain, previousSnapshot] in
2911            let payload = await Self.performBatchLookup(domain: domain, previousSnapshot: previousSnapshot)
2912            return (domain, payload)
2913        }
2914    }
2915
2916    private func completeBatchLookup(domain: String, payload: BatchLookupPayload?) {
2917        activeBatchDomains.removeAll { $0.caseInsensitiveCompare(domain) == .orderedSame }
2918        batchCurrentDomain = activeBatchDomains.first
2919
2920        guard let payload else {
2921            updateBatchResult(
2922                domain: domain,
2923                status: .failed,
2924                quickStatus: "Failed",
2925                entry: nil,
2926                resultSource: .live,
2927                errorMessage: "Lookup cancelled"
2928            )
2929            batchCompletedCount += 1
2930            return
2931        }
2932
2933        let entry = payload.snapshot.historyEntryID.flatMap { id in
2934            history.first(where: { $0.id == id })
2935        } ?? saveHistoryEntry(from: payload.snapshot, replaceLatest: false, updateCurrentState: false)
2936        let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot)
2937        let riskAssessment = entry?.changeSummary?.riskAssessment ?? DomainInsightEngine.analyze(snapshot: payload.snapshot).riskAssessment
2938        let quickStatus: String
2939        if entry?.changeSummary?.hasChanges == true {
2940            quickStatus = entry?.changeSummary?.impactClassification == .critical ? "Critical" : (entry?.changeSummary?.severity == .high ? "High" : "Changed")
2941        } else if certificateWarningLevel != .none {
2942            quickStatus = certificateWarningLevel == .critical ? "Critical" : "Warning"
2943        } else if riskAssessment.level == .high {
2944            quickStatus = "High"
2945        } else {
2946            quickStatus = "Unchanged"
2947        }
2948
2949        updateBatchResult(
2950            domain: domain,
2951            status: .completed,
2952            quickStatus: quickStatus,
2953            entry: entry,
2954            resultSource: payload.snapshot.resultSource,
2955            errorMessage: payload.snapshot.statusMessage
2956        )
2957        batchCompletedCount += 1
2958    }
2959
2960    private func finishBatchLookup(source: BatchLookupSource) {
2961        batchLookupRunning = false
2962        batchCurrentDomain = nil
2963        activeBatchDomains = []
2964        refreshingTrackedDomainID = nil
2965        batchTask = nil
2966
2967        let changedCount = batchResults.filter { $0.quickStatus == "Changed" || $0.quickStatus == "High" || $0.quickStatus == "Critical" }.count
2968        let unchangedCount = batchResults.filter { $0.quickStatus == "Unchanged" && $0.status == .completed }.count
2969        let warningCount = batchResults.filter {
2970            $0.certificateWarningLevel != .none
2971                || $0.changeClassification == .warning
2972                || $0.changeClassification == .critical
2973                || $0.riskLevel == .high
2974        }.count
2975
2976        let summary = BatchSweepSummary(
2977            source: source,
2978            totalDomains: batchResults.count,
2979            changedDomains: changedCount,
2980            unchangedDomains: unchangedCount,
2981            warningDomains: warningCount,
2982            results: batchResults.sorted { lhs, rhs in
2983                if lhs.status != rhs.status {
2984                    return lhs.status.rawValue < rhs.status.rawValue
2985                }
2986                return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending
2987            },
2988            generatedAt: Date()
2989        )
2990        latestBatchSweepSummary = summary
2991
2992        if source == .workflow, let activeWorkflowRunID, let activeWorkflowRunName {
2993            let workflowReports: [DomainReport] = summary.results.compactMap { result in
2994                guard let entry = historyEntry(for: result) else { return nil }
2995                return report(for: entry)
2996            }
2997            latestWorkflowRunSummary = WorkflowRunSummary(
2998                workflowID: activeWorkflowRunID,
2999                workflowName: activeWorkflowRunName,
3000                totalDomains: batchResults.count,
3001                changedDomains: changedCount,
3002                unchangedDomains: unchangedCount,
3003                warningDomains: warningCount,
3004                results: summary.results,
3005                workflowInsights: DomainInsightEngine.workflowInsights(for: workflowReports),
3006                generatedAt: summary.generatedAt
3007            )
3008        }
3009
3010        if notificationsAuthorized, source != .workflow {
3011            Task {
3012                await LocalNotificationService.shared.notifySweepComplete(summary: summary)
3013            }
3014        }
3015    }
3016
3017    private func updateBatchResult(
3018        domain: String,
3019        status: BatchLookupStatus,
3020        quickStatus: String,
3021        entry: HistoryEntry?,
3022        resultSource: LookupResultSource = .live,
3023        errorMessage: String?
3024    ) {
3025        guard let index = batchResults.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
3026            return
3027        }
3028
3029        batchResults[index] = BatchLookupResult(
3030            id: batchResults[index].id,
3031            domain: domain,
3032            historyEntryID: entry?.id,
3033            resultSource: resultSource,
3034            availability: entry?.availabilityResult?.status,
3035            primaryIP: entry?.primaryIP,
3036            quickStatus: quickStatus,
3037            summaryMessage: entry?.changeSummary?.message,
3038            changeSeverity: entry?.changeSummary?.severity,
3039            changeClassification: entry?.changeSummary?.impactClassification,
3040            certificateWarningLevel: entry.map { DomainDiffService.certificateWarningLevel(for: $0.snapshot) } ?? batchResults[index].certificateWarningLevel,
3041            riskScore: entry.map { $0.changeSummary?.riskAssessment?.score ?? report(for: $0).riskAssessment.score },
3042            riskLevel: entry.map { $0.changeSummary?.riskAssessment?.level ?? report(for: $0).riskAssessment.level },
3043            timestamp: entry?.timestamp ?? Date(),
3044            status: status,
3045            errorMessage: errorMessage
3046        )
3047    }
3048
3049    private func clearLookupState() {
3050        dnsSections = []
3051        dnsError = nil
3052        dnsLoading = false
3053        availabilityResult = nil
3054        availabilityLoading = false
3055        suggestions = []
3056        suggestionsLoading = false
3057        sslInfo = nil
3058        sslError = nil
3059        sslLoading = false
3060        hstsPreloaded = nil
3061        hstsLoading = false
3062        httpHeaders = []
3063        httpSecurityGrade = nil
3064        httpStatusCode = nil
3065        httpResponseTimeMs = nil
3066        httpProtocol = nil
3067        http3Advertised = false
3068        httpHeadersError = nil
3069        httpHeadersLoading = false
3070        reachabilityResults = []
3071        reachabilityError = nil
3072        reachabilityLoading = false
3073        ipGeolocation = nil
3074        ipGeolocationError = nil
3075        ipGeolocationLoading = false
3076        emailSecurity = nil
3077        emailSecurityError = nil
3078        emailSecurityLoading = false
3079        ownershipResult = nil
3080        ownershipError = nil
3081        ownershipLoading = false
3082        ownershipHistory = []
3083        ownershipHistoryError = nil
3084        ownershipHistoryLoading = false
3085        ptrRecord = nil
3086        ptrError = nil
3087        ptrLoading = false
3088        redirectChain = []
3089        redirectChainError = nil
3090        redirectChainLoading = false
3091        subdomains = []
3092        subdomainsError = nil
3093        subdomainsLoading = false
3094        extendedSubdomains = []
3095        extendedSubdomainsError = nil
3096        extendedSubdomainsLoading = false
3097        dnsHistory = []
3098        dnsHistoryError = nil
3099        dnsHistoryLoading = false
3100        domainPricing = nil
3101        domainPricingError = nil
3102        domainPricingLoading = false
3103        portScanResults = []
3104        portScanError = nil
3105        portScanLoading = false
3106        customPortResults = []
3107        customPortScanError = nil
3108        customPortScanLoading = false
3109        currentHistoryEntryID = nil
3110        currentSnapshotTimestamp = Date()
3111        currentResultSource = .live
3112        currentCachedSections = []
3113        currentStatusMessage = nil
3114        currentReport = nil
3115    }
3116
3117    private func setAllLoadingStates(_ loading: Bool) {
3118        dnsLoading = loading
3119        availabilityLoading = loading
3120        suggestionsLoading = loading
3121        sslLoading = loading
3122        hstsLoading = loading
3123        httpHeadersLoading = loading
3124        reachabilityLoading = loading
3125        ipGeolocationLoading = loading
3126        emailSecurityLoading = loading
3127        ownershipLoading = loading
3128        ownershipHistoryLoading = false
3129        ptrLoading = loading
3130        redirectChainLoading = loading
3131        subdomainsLoading = loading
3132        extendedSubdomainsLoading = false
3133        dnsHistoryLoading = false
3134        domainPricingLoading = false
3135        portScanLoading = loading
3136    }
3137
3138    private func primaryIPAddress(from sections: [DNSSection]) -> String? {
3139        sections.first(where: { $0.recordType == .A })?.records.first?.value
3140    }
3141
3142    private func isCurrentLookup(_ lookupID: UUID) -> Bool {
3143        activeLookupID == lookupID
3144    }
3145
3146    func recentSnapshots(for trackedDomain: TrackedDomain, limit: Int = 6) -> [HistoryEntry] {
3147        history
3148            .filter { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }
3149            .sorted { $0.timestamp > $1.timestamp }
3150            .prefix(limit)
3151            .map { $0 }
3152    }
3153
3154    func latestSnapshots(for domains: [TrackedDomain]) -> [HistoryEntry] {
3155        domains.compactMap { trackedDomain in
3156            recentSnapshots(for: trackedDomain, limit: 1).first
3157        }
3158    }
3159
3160    func diffSectionsForLatestSnapshots(of trackedDomain: TrackedDomain) -> [DomainDiffSection] {
3161        let snapshots = recentSnapshots(for: trackedDomain, limit: 2)
3162        guard snapshots.count == 2 else { return [] }
3163        return DomainDiffService.diff(from: snapshots[1].snapshot, to: snapshots[0].snapshot)
3164    }
3165
3166    func latestChangeSummary(for trackedDomain: TrackedDomain) -> DomainChangeSummary? {
3167        trackedDomain.lastChangeSummary ?? recentSnapshots(for: trackedDomain, limit: 1).first?.changeSummary
3168    }
3169
3170    func latestSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot? {
3171        recentSnapshots(for: trackedDomain, limit: 1).first?.snapshot
3172    }
3173
3174    func trackedDomain(withID id: UUID) -> TrackedDomain? {
3175        trackedDomains.first(where: { $0.id == id })
3176    }
3177
3178    private func portfolioDashboardStamp() -> PortfolioDashboardStamp {
3179        PortfolioDashboardStamp(
3180            trackedDomainSignature: trackedDomains
3181                .sorted { $0.id.uuidString < $1.id.uuidString }
3182                .map {
3183                    [
3184                        $0.id.uuidString,
3185                        $0.updatedAt.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))),
3186                        String($0.pendingMonitoringAlerts.count),
3187                        $0.lastMonitoredAt?.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))) ?? "0",
3188                        $0.lastAlertAt?.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))) ?? "0"
3189                    ].joined(separator: "|")
3190                },
3191            historySignature: history
3192                .prefix(250)
3193                .map {
3194                    [
3195                        $0.id.uuidString,
3196                        $0.timestamp.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))),
3197                        String($0.changeCount),
3198                        $0.severitySummary?.rawValue.description ?? "-"
3199                    ].joined(separator: "|")
3200                },
3201            monitoringSignature: monitoringLogs
3202                .prefix(100)
3203                .map {
3204                    [
3205                        $0.id.uuidString,
3206                        $0.timestamp.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))),
3207                        String($0.alertsTriggered),
3208                        String($0.changesFound)
3209                    ].joined(separator: "|")
3210                }
3211        )
3212    }
3213
3214    private func buildPortfolioDomainStates() -> [PortfolioDomainStatus] {
3215        let now = Date()
3216        return sortedTrackedDomains(from: trackedDomains, using: .pinned).map { trackedDomain in
3217            let recentEntries = recentSnapshots(for: trackedDomain, limit: 8)
3218            let latestEntry = recentEntries.first
3219            let report = latestEntry.map { self.report(for: $0) } ?? reportBuilder.build(from: placeholderSnapshot(for: trackedDomain))
3220            let recentMonitoringResults = monitoringLogs
3221                .flatMap(\.checkedDomains)
3222                .filter { $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }
3223                .sorted { $0.checkedAt > $1.checkedAt }
3224            let recentFailureResults = recentMonitoringResults.filter {
3225                let isFailure = $0.errorMessage != nil || ($0.alertSeverity ?? .info) >= .warning
3226                return isFailure && now.timeIntervalSince($0.checkedAt) <= 7 * 24 * 60 * 60
3227            }
3228            let recentChangeCount = recentEntries.filter {
3229                guard let changeSummary = $0.changeSummary, changeSummary.hasChanges else { return false }
3230                return now.timeIntervalSince($0.timestamp) <= 7 * 24 * 60 * 60
3231            }.count
3232            let recentDNSChange = recentEntries.contains {
3233                guard let changeSummary = $0.changeSummary else { return false }
3234                return changeSummary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") })
3235                    && now.timeIntervalSince($0.timestamp) <= 7 * 24 * 60 * 60
3236            }
3237            let recentCriticalChange = recentEntries.contains {
3238                guard let changeSummary = $0.changeSummary else { return false }
3239                return changeSummary.impactClassification == .critical
3240                    && now.timeIntervalSince($0.timestamp) <= 7 * 24 * 60 * 60
3241            }
3242            let certificateExpiryState = trackedDomain.certificateWarningLevel == .none
3243                ? report.certificateExpiryState
3244                : trackedDomain.certificateWarningLevel
3245            let isUnreachable: Bool = {
3246                if let latestEntry {
3247                    if !latestEntry.reachabilityResults.isEmpty {
3248                        return !latestEntry.reachabilityResults.contains(where: \.reachable)
3249                    }
3250                    if latestEntry.reachabilityError != nil {
3251                        return true
3252                    }
3253                }
3254                return recentFailureResults.contains { ($0.alertSeverity ?? .info) == .critical && $0.errorMessage != nil }
3255            }()
3256            let hasInvalidTLS = latestEntry?.sslInfo == nil && latestEntry?.sslError != nil
3257            let instabilityScore = DomainHealth.instabilityScore(
3258                recentChangeCount: recentChangeCount,
3259                recentFailureCount: recentFailureResults.count,
3260                pendingAlertCount: trackedDomain.pendingMonitoringAlerts.count,
3261                hasRecentDNSChange: recentDNSChange
3262            )
3263            let health = DomainHealth.classify(
3264                certificateExpiryState: certificateExpiryState,
3265                isReachable: !isUnreachable,
3266                recentMonitoringFailureCount: recentFailureResults.count,
3267                hasRecentDNSChange: recentDNSChange,
3268                instabilityScore: instabilityScore,
3269                hasRecentCriticalChange: recentCriticalChange,
3270                hasInvalidTLS: hasInvalidTLS
3271            )
3272
3273            return PortfolioDomainStatus(
3274                trackedDomain: trackedDomain,
3275                latestEntry: latestEntry,
3276                report: report,
3277                apexDomain: apexDomain(for: trackedDomain.domain),
3278                health: health,
3279                lastChangeDate: latestEntry?.changeSummary?.hasChanges == true
3280                    ? latestEntry?.timestamp
3281                    : trackedDomain.monitoringState.lastChangeDate ?? report.lastChangeDate,
3282                lastMonitoringFailure: recentFailureResults.first?.checkedAt,
3283                instabilityScore: instabilityScore,
3284                certificateExpiryState: certificateExpiryState,
3285                certificateDaysRemaining: trackedDomain.certificateDaysRemaining ?? latestEntry?.sslInfo?.daysUntilExpiry,
3286                isUnreachable: isUnreachable,
3287                recentDNSChange: recentDNSChange,
3288                recentCriticalChange: recentCriticalChange,
3289                recentFailureCount: recentFailureResults.count
3290            )
3291        }
3292    }
3293
3294    private func buildPortfolioActivity(from domainStates: [PortfolioDomainStatus]) -> [PortfolioActivityItem] {
3295        var items: [PortfolioActivityItem] = []
3296        let healthByDomainID = Dictionary(uniqueKeysWithValues: domainStates.map { ($0.trackedDomain.id, $0.health) })
3297
3298        for state in domainStates {
3299            if let latestEntry = state.latestEntry,
3300               let changeSummary = latestEntry.changeSummary,
3301               changeSummary.hasChanges {
3302                items.append(
3303                    PortfolioActivityItem(
3304                        id: "history|\(latestEntry.id.uuidString)",
3305                        trackedDomainID: state.trackedDomain.id,
3306                        domain: state.trackedDomain.domain,
3307                        message: portfolioHistoryMessage(for: latestEntry),
3308                        timestamp: latestEntry.timestamp,
3309                        health: state.health,
3310                        systemImage: portfolioHistoryIcon(for: latestEntry)
3311                    )
3312                )
3313            }
3314        }
3315
3316        for log in monitoringLogs.prefix(25) {
3317            for result in log.checkedDomains where result.didChange || result.errorMessage != nil || result.certificateWarningLevel != .none {
3318                guard let trackedDomain = trackedDomain(for: result.domain) else { continue }
3319                items.append(
3320                    PortfolioActivityItem(
3321                        id: "monitoring|\(log.id.uuidString)|\(result.id.uuidString)",
3322                        trackedDomainID: trackedDomain.id,
3323                        domain: trackedDomain.domain,
3324                        message: portfolioMonitoringMessage(for: result),
3325                        timestamp: result.checkedAt,
3326                        health: healthByDomainID[trackedDomain.id] ?? (result.alertSeverity == .critical ? .critical : .warning),
3327                        systemImage: portfolioMonitoringIcon(for: result)
3328                    )
3329                )
3330            }
3331        }
3332
3333        var seen = Set<String>()
3334        return items
3335            .sorted { $0.timestamp > $1.timestamp }
3336            .filter { item in
3337                let key = "\(item.domain.lowercased())|\(item.message.lowercased())"
3338                return seen.insert(key).inserted
3339            }
3340    }
3341
3342    private func buildAttentionQueue(from domainStates: [PortfolioDomainStatus]) -> [PortfolioAttentionItem] {
3343        domainStates.compactMap { state in
3344            guard state.health != .healthy else { return nil }
3345            let reason: String
3346            let timestamp: Date
3347
3348            if state.isUnreachable {
3349                reason = "Endpoint is unreachable"
3350                timestamp = state.lastMonitoringFailure ?? state.trackedDomain.updatedAt
3351            } else if state.certificateExpiryState == .critical {
3352                reason = "Certificate is expiring in under 14 days"
3353                timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
3354            } else if state.certificateExpiryState == .warning {
3355                reason = "Certificate expires within 30 days"
3356                timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
3357            } else if state.recentFailureCount >= 2 || state.instabilityScore >= 70 {
3358                reason = "Repeated instability detected"
3359                timestamp = state.lastMonitoringFailure ?? state.trackedDomain.updatedAt
3360            } else if state.recentDNSChange {
3361                reason = "DNS changed recently"
3362                timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
3363            } else if state.recentCriticalChange {
3364                reason = "Recent critical change detected"
3365                timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
3366            } else {
3367                reason = "Needs attention"
3368                timestamp = state.trackedDomain.updatedAt
3369            }
3370
3371            return PortfolioAttentionItem(
3372                id: "\(state.trackedDomain.id.uuidString)|\(reason)",
3373                trackedDomainID: state.trackedDomain.id,
3374                domain: state.trackedDomain.domain,
3375                reason: reason,
3376                timestamp: timestamp,
3377                health: state.health
3378            )
3379        }
3380        .sorted { lhs, rhs in
3381            if lhs.health != rhs.health {
3382                return healthRank(lhs.health) > healthRank(rhs.health)
3383            }
3384            return lhs.timestamp > rhs.timestamp
3385        }
3386    }
3387
3388    private func buildPortfolioGroups(from domainStates: [PortfolioDomainStatus]) -> [PortfolioGroup] {
3389        Dictionary(grouping: domainStates, by: \.apexDomain)
3390            .map { apexDomain, domains in
3391                PortfolioGroup(
3392                    apexDomain: apexDomain,
3393                    domains: domains.sorted { lhs, rhs in
3394                        if lhs.health != rhs.health {
3395                            return healthRank(lhs.health) > healthRank(rhs.health)
3396                        }
3397                        return lhs.trackedDomain.domain.localizedCaseInsensitiveCompare(rhs.trackedDomain.domain) == .orderedAscending
3398                    }
3399                )
3400            }
3401            .sorted { lhs, rhs in
3402                if let lhsMostSevere = lhs.domains.map(\.health).map(healthRank).max(),
3403                   let rhsMostSevere = rhs.domains.map(\.health).map(healthRank).max(),
3404                   lhsMostSevere != rhsMostSevere {
3405                    return lhsMostSevere > rhsMostSevere
3406                }
3407                return lhs.apexDomain.localizedCaseInsensitiveCompare(rhs.apexDomain) == .orderedAscending
3408            }
3409    }
3410
3411    private func matchesPortfolioFilter(_ state: PortfolioDomainStatus) -> Bool {
3412        switch dashboardFilter {
3413        case .all:
3414            return true
3415        case .healthy:
3416            return state.health == .healthy
3417        case .warning:
3418            return state.health == .warning
3419        case .critical:
3420            return state.health == .critical
3421        case .changed:
3422            guard let lastChangeDate = state.lastChangeDate else { return false }
3423            return Date().timeIntervalSince(lastChangeDate) <= 24 * 60 * 60
3424        case .expiring:
3425            return state.certificateExpiryState != .none
3426        case .unreachable:
3427            return state.isUnreachable
3428        }
3429    }
3430
3431    private func matchesDashboardSearch(_ state: PortfolioDomainStatus, query: String) -> Bool {
3432        guard !query.isEmpty else { return true }
3433        let normalizedQuery = query.lowercased()
3434        return state.trackedDomain.domain.lowercased().contains(normalizedQuery)
3435            || state.apexDomain.lowercased().contains(normalizedQuery)
3436    }
3437
3438    private func portfolioHistoryMessage(for entry: HistoryEntry) -> String {
3439        guard let summary = entry.changeSummary else {
3440            return "Configuration changed for \(entry.domain)"
3441        }
3442        if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") }) {
3443            return "DNS changed for \(entry.domain)"
3444        }
3445        if summary.changedSections.contains(where: {
3446            $0.localizedCaseInsensitiveContains("certificate")
3447                || $0.localizedCaseInsensitiveContains("tls")
3448        }) {
3449            return "Certificate updated for \(entry.domain)"
3450        }
3451        if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("redirect") }) {
3452            return "Redirect chain changed for \(entry.domain)"
3453        }
3454        return summary.message
3455    }
3456
3457    private func portfolioHistoryIcon(for entry: HistoryEntry) -> String {
3458        guard let summary = entry.changeSummary else { return "clock.arrow.trianglehead.counterclockwise.rotate.90" }
3459        if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") }) {
3460            return "point.3.connected.trianglepath.dotted"
3461        }
3462        if summary.changedSections.contains(where: {
3463            $0.localizedCaseInsensitiveContains("certificate")
3464                || $0.localizedCaseInsensitiveContains("tls")
3465        }) {
3466            return "lock.rotation"
3467        }
3468        if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("redirect") }) {
3469            return "arrow.triangle.branch"
3470        }
3471        return "clock.arrow.trianglehead.counterclockwise.rotate.90"
3472    }
3473
3474    private func portfolioMonitoringMessage(for result: MonitoringDomainResult) -> String {
3475        if result.errorMessage != nil {
3476            return "Monitoring failed for \(result.domain)"
3477        }
3478        if result.certificateWarningLevel != .none {
3479            return "Certificate needs attention for \(result.domain)"
3480        }
3481        if result.didChange {
3482            return result.summaryMessage.isEmpty ? "Monitoring detected a change for \(result.domain)" : result.summaryMessage
3483        }
3484        return "Monitoring updated \(result.domain)"
3485    }
3486
3487    private func portfolioMonitoringIcon(for result: MonitoringDomainResult) -> String {
3488        if result.errorMessage != nil {
3489            return "xmark.octagon.fill"
3490        }
3491        if result.certificateWarningLevel != .none {
3492            return "exclamationmark.triangle.fill"
3493        }
3494        return "waveform.path.ecg"
3495    }
3496
3497    private func apexDomain(for domain: String) -> String {
3498        let parts = domain
3499            .lowercased()
3500            .split(separator: ".")
3501            .map(String.init)
3502        guard parts.count > 2 else { return domain.lowercased() }
3503        return parts.suffix(2).joined(separator: ".")
3504    }
3505
3506    private func healthRank(_ health: DomainHealth) -> Int {
3507        switch health {
3508        case .healthy:
3509            return 0
3510        case .warning:
3511            return 1
3512        case .critical:
3513            return 2
3514        }
3515    }
3516
3517    func resolverMismatchNote(for entry: HistoryEntry) -> String? {
3518        guard entry.resolverURLString != resolverURLString else { return nil }
3519        return "Current resolver differs from this snapshot. Re-running may produce different evidence."
3520    }
3521
3522    func resolverMismatchNote(for trackedDomain: TrackedDomain) -> String? {
3523        guard let snapshot = latestSnapshot(for: trackedDomain), snapshot.resolverURLString != resolverURLString else {
3524            return nil
3525        }
3526        return "Current resolver differs from the latest snapshot for this tracked domain."
3527    }
3528
3529    func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? {
3530        previousHistoryEntry(for: entry)?.snapshot
3531    }
3532
3533    func timelineEntries(for domain: String) -> [SnapshotSummary] {
3534        historyEntries(for: domain).map(\.snapshotSummary)
3535    }
3536
3537    func timelineSections(for domain: String, grouping: TimelineGroupingOption? = nil) -> [TimelineSection] {
3538        let entries = timelineEntries(for: domain)
3539        let grouping = grouping ?? timelineGrouping
3540
3541        guard grouping == .relativeDay else {
3542            return entries.isEmpty ? [] : [TimelineSection(id: "all", title: "All Snapshots", entries: entries)]
3543        }
3544
3545        let calendar = Calendar.current
3546        let today = Date()
3547        let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today
3548        let grouped = Dictionary(grouping: entries) { entry -> String in
3549            if calendar.isDate(entry.timestamp, inSameDayAs: today) {
3550                return "Today"
3551            }
3552            if calendar.isDate(entry.timestamp, inSameDayAs: yesterday) {
3553                return "Yesterday"
3554            }
3555            return "Older"
3556        }
3557
3558        return ["Today", "Yesterday", "Older"].compactMap { title in
3559            guard let items = grouped[title], !items.isEmpty else { return nil }
3560            return TimelineSection(id: title.lowercased(), title: title, entries: items)
3561        }
3562    }
3563
3564    func historyEntries(for domain: String) -> [HistoryEntry] {
3565        history
3566            .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
3567            .sorted { $0.timestamp > $1.timestamp }
3568    }
3569
3570    func historyEntry(withID id: UUID) -> HistoryEntry? {
3571        history.first(where: { $0.id == id })
3572    }
3573
3574    func previousHistoryEntry(for entry: HistoryEntry) -> HistoryEntry? {
3575        let siblings = historyEntries(for: entry.domain)
3576        guard let index = siblings.firstIndex(where: { $0.id == entry.id }) else { return nil }
3577        let nextIndex = index + 1
3578        guard siblings.indices.contains(nextIndex) else { return nil }
3579        return siblings[nextIndex]
3580    }
3581
3582    func toggleSnapshotSelection(_ entry: HistoryEntry) {
3583        if selectedSnapshotIDs.contains(entry.id) {
3584            selectedSnapshotIDs.remove(entry.id)
3585        } else if selectedSnapshotIDs.count < 2 {
3586            selectedSnapshotIDs.insert(entry.id)
3587        } else if let oldest = selectedSnapshotIDs.first {
3588            selectedSnapshotIDs.remove(oldest)
3589            selectedSnapshotIDs.insert(entry.id)
3590        }
3591    }
3592
3593    func clearSnapshotSelection() {
3594        selectedSnapshotIDs.removeAll()
3595    }
3596
3597    var selectedSnapshots: [HistoryEntry] {
3598        selectedSnapshotIDs.compactMap(historyEntry(withID:)).sorted { $0.timestamp < $1.timestamp }
3599    }
3600
3601    @discardableResult
3602    func generateDiffForSelectedSnapshots(focusSectionID: String? = nil) -> DomainDiff? {
3603        guard selectedSnapshots.count == 2 else {
3604            activeDomainDiff = nil
3605            activeDiffChangeIndex = 0
3606            return nil
3607        }
3608
3609        let diff = DiffService.compare(from: selectedSnapshots[0].snapshot, to: selectedSnapshots[1].snapshot)
3610        activeDomainDiff = diff
3611        if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) {
3612            activeDiffChangeIndex = index
3613        } else {
3614            activeDiffChangeIndex = 0
3615        }
3616        return diff
3617    }
3618
3619    func generateDiff(from olderEntry: HistoryEntry, to newerEntry: HistoryEntry, focusSectionID: String? = nil) -> DomainDiff {
3620        selectedSnapshotIDs = [olderEntry.id, newerEntry.id]
3621        let diff = DiffService.compare(from: olderEntry.snapshot, to: newerEntry.snapshot)
3622        activeDomainDiff = diff
3623        if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) {
3624            activeDiffChangeIndex = index
3625        } else {
3626            activeDiffChangeIndex = 0
3627        }
3628        return diff
3629    }
3630
3631    func historyEntry(for batchResult: BatchLookupResult) -> HistoryEntry? {
3632        guard let historyEntryID = batchResult.historyEntryID else { return nil }
3633        return history.first(where: { $0.id == historyEntryID })
3634    }
3635
3636    var currentDiffTargetSectionID: String? {
3637        guard let activeDomainDiff, activeDomainDiff.changedSectionIDs.indices.contains(activeDiffChangeIndex) else {
3638            return nil
3639        }
3640        return activeDomainDiff.changedSectionIDs[activeDiffChangeIndex]
3641    }
3642
3643    func moveToNextDiffChange() {
3644        guard let activeDomainDiff, !activeDomainDiff.changedSectionIDs.isEmpty else { return }
3645        activeDiffChangeIndex = min(activeDiffChangeIndex + 1, activeDomainDiff.changedSectionIDs.count - 1)
3646    }
3647
3648    func moveToPreviousDiffChange() {
3649        guard activeDomainDiff != nil else { return }
3650        activeDiffChangeIndex = max(activeDiffChangeIndex - 1, 0)
3651    }
3652
3653    private func exportSnapshots(for domains: [TrackedDomain]) -> [LookupSnapshot] {
3654        let latestEntries = latestSnapshots(for: domains)
3655
3656        return domains.map { trackedDomain in
3657            if let entry = latestEntries.first(where: { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }) {
3658                return entry.snapshot
3659            }
3660            return placeholderSnapshot(for: trackedDomain)
3661        }
3662    }
3663
3664    private func currentBatchReports() -> [DomainReport] {
3665        currentBatchResultEntries.map { entry in
3666            report(for: entry, workflowContext: activeWorkflowContext)
3667        }
3668    }
3669
3670    private func workflowReports(from summary: WorkflowRunSummary, changedOnly: Bool) -> [DomainReport] {
3671        let filteredResults = changedOnly ? summary.results.filter(\.hasMeaningfulChange) : summary.results
3672        return filteredResults.compactMap { result in
3673            guard let entry = historyEntry(for: result) else { return nil }
3674            return report(
3675                for: entry,
3676                workflowContext: DomainWorkflowContext(
3677                    workflowID: summary.workflowID,
3678                    workflowName: summary.workflowName,
3679                    source: "workflow"
3680                )
3681            )
3682        }
3683    }
3684
3685    private func reports(for domains: [TrackedDomain]) -> [DomainReport] {
3686        let latestEntries = latestSnapshots(for: domains)
3687
3688        return domains.map { trackedDomain in
3689            if let entry = latestEntries.first(where: {
3690                $0.trackedDomainID == trackedDomain.id ||
3691                $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame
3692            }) {
3693                return report(for: entry)
3694            }
3695
3696            return reportBuilder.build(from: placeholderSnapshot(for: trackedDomain))
3697        }
3698    }
3699
3700    private func timelineReports(for domain: String) -> [DomainReport] {
3701        historyEntries(for: domain).map { report(for: $0) }
3702    }
3703
3704    private func report(for entry: HistoryEntry, workflowContext: DomainWorkflowContext? = nil) -> DomainReport {
3705        reportBuilder.build(
3706            from: entry,
3707            previousSnapshot: comparisonSnapshot(for: entry),
3708            workflowContext: workflowContext,
3709            historyEntries: historyEntries(for: entry.domain)
3710        )
3711    }
3712
3713    private var activeWorkflowContext: DomainWorkflowContext? {
3714        guard batchLookupSource == .workflow, let activeWorkflowRunID, let activeWorkflowRunName else {
3715            return nil
3716        }
3717        return DomainWorkflowContext(
3718            workflowID: activeWorkflowRunID,
3719            workflowName: activeWorkflowRunName,
3720            source: "workflow"
3721        )
3722    }
3723
3724    private static var defaultAuditReviewer: String {
3725        let reviewer = NSFullUserName().trimmingCharacters(in: .whitespacesAndNewlines)
3726        return reviewer.isEmpty ? "Local Reviewer" : reviewer
3727    }
3728
3729    private func snapshotEvidenceAssets(from report: DomainReport) -> [AuditEvidenceAsset] {
3730        var assets: [AuditEvidenceAsset] = []
3731        if let finalURL = report.web.finalURL {
3732            assets.append(AuditEvidenceAsset(title: "Final URL", kind: .document, reference: finalURL))
3733        }
3734        if let registrar = report.ownership?.registrar {
3735            assets.append(AuditEvidenceAsset(title: "Registrar", kind: .document, reference: registrar))
3736        }
3737        if let primaryIP = report.dns.primaryIP {
3738            assets.append(AuditEvidenceAsset(title: "Primary IP", kind: .document, reference: primaryIP))
3739        }
3740        if !report.web.redirectChain.isEmpty {
3741            assets.append(
3742                AuditEvidenceAsset(
3743                    title: "Redirect Chain",
3744                    kind: .document,
3745                    reference: report.web.redirectChain.map { "\($0.statusCode) \($0.url)" }.joined(separator: " | ")
3746                )
3747            )
3748        }
3749        if !report.web.headers.isEmpty {
3750            assets.append(
3751                AuditEvidenceAsset(
3752                    title: "Observed Headers",
3753                    kind: .document,
3754                    reference: report.web.headers.prefix(6).map { "\($0.name): \($0.value)" }.joined(separator: " | ")
3755                )
3756            )
3757        }
3758        return assets
3759    }
3760
3761    private func placeholderSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot {
3762        LookupSnapshot(
3763            historyEntryID: trackedDomain.lastSnapshotID,
3764            domain: trackedDomain.domain,
3765            timestamp: trackedDomain.updatedAt,
3766            trackedDomainID: trackedDomain.id,
3767            note: trackedDomain.note,
3768            appVersion: AppVersion.current,
3769            resolverDisplayName: resolverDisplayName,
3770            resolverURLString: resolverURLString,
3771            dataSources: [],
3772            provenanceBySection: [:],
3773            availabilityConfidence: nil,
3774            ownershipConfidence: nil,
3775            subdomainConfidence: nil,
3776            emailSecurityConfidence: nil,
3777            geolocationConfidence: nil,
3778            errorDetails: [:],
3779            isPartialSnapshot: true,
3780            validationIssues: ["No stored snapshot data available"],
3781            totalLookupDurationMs: nil,
3782            snapshotIndex: nil,
3783            previousSnapshotID: nil,
3784            changeCount: 0,
3785            severitySummary: trackedDomain.lastChangeSeverity,
3786            dnsSections: [],
3787            dnsError: nil,
3788            availabilityResult: DomainAvailabilityResult(domain: trackedDomain.domain, status: trackedDomain.lastKnownAvailability ?? .unknown),
3789            suggestions: [],
3790            sslInfo: nil,
3791            sslError: nil,
3792            hstsPreloaded: nil,
3793            httpHeaders: [],
3794            httpSecurityGrade: nil,
3795            httpStatusCode: nil,
3796            httpResponseTimeMs: nil,
3797            httpProtocol: nil,
3798            http3Advertised: false,
3799            httpHeadersError: nil,
3800            reachabilityResults: [],
3801            reachabilityError: nil,
3802            ipGeolocation: nil,
3803            ipGeolocationError: nil,
3804            emailSecurity: nil,
3805            emailSecurityError: nil,
3806            ownership: nil,
3807            ownershipError: nil,
3808            ownershipHistory: [],
3809            ownershipHistoryError: nil,
3810            inferredProvider: nil,
3811            priorProviders: [],
3812            domainClassification: nil,
3813            ownershipTransitions: [],
3814            hostingTransitions: [],
3815            subdomainHistory: [],
3816            riskSignals: [],
3817            intelligenceTimeline: [],
3818            ptrRecord: nil,
3819            ptrError: nil,
3820            redirectChain: [],
3821            redirectChainError: nil,
3822            subdomains: [],
3823            subdomainsError: nil,
3824            extendedSubdomains: [],
3825            extendedSubdomainsError: nil,
3826            dnsHistory: [],
3827            dnsHistoryError: nil,
3828            domainPricing: nil,
3829            domainPricingError: nil,
3830            portScanResults: [],
3831            portScanError: nil,
3832            changeSummary: trackedDomain.lastChangeSummary,
3833            resultSource: .snapshot,
3834            cachedSections: [],
3835            statusMessage: nil
3836        )
3837    }
3838
3839    private static func loadHistoryEntries() -> [HistoryEntry] {
3840        DataMigrationService.migrateIfNeeded()
3841        return DomainDataPortabilityService.loadHistoryEntries()
3842    }
3843
3844    private static func loadHistoryAutoPruneOption() -> HistoryAutoPruneOption {
3845        guard let rawValue = UserDefaults.standard.string(forKey: historyAutoPruneKey),
3846              let option = HistoryAutoPruneOption(rawValue: rawValue) else {
3847            return .unlimited
3848        }
3849        return option
3850    }
3851
3852    private static func loadTrackedDomains() -> [TrackedDomain] {
3853        DataMigrationService.migrateIfNeeded()
3854        return DomainDataPortabilityService.loadTrackedDomains()
3855    }
3856
3857    private func persistWorkflows() {
3858        DomainDataPortabilityService.saveWorkflows(workflows)
3859        CloudSyncService.shared.scheduleSyncIfNeeded()
3860        refreshDataLifecycleSummary()
3861    }
3862
3863    private static func loadWorkflows() -> [DomainWorkflow] {
3864        DataMigrationService.migrateIfNeeded()
3865        return DomainDataPortabilityService.loadWorkflows()
3866    }
3867
3868    private static func deduplicatedTrackedDomains(_ domains: [TrackedDomain]) -> [TrackedDomain] {
3869        var seen = Set<String>()
3870        return domains.filter { domain in
3871            let key = domain.domain.lowercased()
3872            return seen.insert(key).inserted
3873        }
3874    }
3875
3876    private func normalizedDomains(_ domains: [String]) -> [String] {
3877        var seen = Set<String>()
3878        return domains
3879            .map(normalizedDomain)
3880            .filter { !$0.isEmpty }
3881            .filter { seen.insert($0).inserted }
3882    }
3883
3884    private func historySortPredicate(lhs: HistoryEntry, rhs: HistoryEntry) -> Bool {
3885        switch historySortOption {
3886        case .newest:
3887            return lhs.timestamp > rhs.timestamp
3888        case .oldest:
3889            return lhs.timestamp < rhs.timestamp
3890        case .domain:
3891            let domainOrder = lhs.domain.localizedCaseInsensitiveCompare(rhs.domain)
3892            if domainOrder != .orderedSame {
3893                return domainOrder == .orderedAscending
3894            }
3895            return lhs.timestamp > rhs.timestamp
3896        }
3897    }
3898
3899    private func sortedTrackedDomains(from domains: [TrackedDomain], using sortOption: WatchlistSortOption) -> [TrackedDomain] {
3900        domains.sorted { lhs, rhs in
3901            switch sortOption {
3902            case .pinned:
3903                if lhs.isPinned != rhs.isPinned {
3904                    return lhs.isPinned && !rhs.isPinned
3905                }
3906                if lhs.updatedAt != rhs.updatedAt {
3907                    return lhs.updatedAt > rhs.updatedAt
3908                }
3909                return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending
3910            case .recentlyUpdated:
3911                if lhs.updatedAt != rhs.updatedAt {
3912                    return lhs.updatedAt > rhs.updatedAt
3913                }
3914                return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending
3915            case .alphabetical:
3916                let domainOrder = lhs.domain.localizedCaseInsensitiveCompare(rhs.domain)
3917                if domainOrder != .orderedSame {
3918                    return domainOrder == .orderedAscending
3919                }
3920                return lhs.updatedAt > rhs.updatedAt
3921            }
3922        }
3923    }
3924
3925    static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] {
3926        [
3927            SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary),
3928            SummaryFieldViewData(label: "Observed IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary),
3929            SummaryFieldViewData(label: "Observed Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary),
3930            SummaryFieldViewData(label: "Inference", value: availabilityInference(from: snapshot), tone: availabilityTone(snapshot.availabilityResult?.status)),
3931            SummaryFieldViewData(label: "Observed TLS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)),
3932            SummaryFieldViewData(label: "Certificate", value: certificateStatusLabel(from: snapshot), tone: certificateStatusTone(from: snapshot)),
3933            SummaryFieldViewData(label: "Source", value: snapshot.statusMessage ?? snapshot.resultSource.label, tone: sourceTone(for: snapshot))
3934        ]
3935    }
3936
3937    static func domainRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
3938        var rows = [
3939            InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary),
3940            InfoRowViewData(label: "Resolver", value: snapshot.resolverDisplayName, tone: .secondary),
3941            InfoRowViewData(label: "Collected", value: snapshot.timestamp.formatted(date: .abbreviated, time: .shortened), tone: .secondary),
3942            InfoRowViewData(label: snapshot.statusMessage == nil ? "Result" : "Snapshot", value: snapshot.statusMessage ?? snapshot.resultSource.label, tone: sourceTone(for: snapshot)),
3943            InfoRowViewData(label: "Lookup Duration", value: durationLabel(snapshot.totalLookupDurationMs), tone: .secondary)
3944        ]
3945        rows.insert(
3946            InfoRowViewData(
3947                label: "Observed Availability",
3948                value: snapshot.availabilityResult?.status == .unknown ? "No direct registration proof" : "Status collected",
3949                tone: .secondary
3950            ),
3951            at: 1
3952        )
3953        rows.insert(
3954            InfoRowViewData(
3955                label: "Inference",
3956                value: availabilityInference(from: snapshot),
3957                tone: availabilityTone(snapshot.availabilityResult?.status)
3958            ),
3959            at: 2
3960        )
3961        if let confidence = snapshot.availabilityConfidence {
3962            rows.insert(
3963                InfoRowViewData(label: "Confidence", value: confidence.title, tone: .secondary),
3964                at: 3
3965            )
3966        }
3967        if let pricing = snapshot.domainPricing {
3968            rows.append(
3969                InfoRowViewData(
3970                    label: "External Price",
3971                    value: pricing.estimatedPrice ?? "Unavailable",
3972                    tone: .secondary
3973                )
3974            )
3975            if let premiumIndicator = pricing.premiumIndicator {
3976                rows.append(
3977                    InfoRowViewData(
3978                        label: "Premium",
3979                        value: premiumIndicator ? "Yes" : "No",
3980                        tone: premiumIndicator ? .warning : .secondary
3981                    )
3982                )
3983            }
3984            if let resaleSignal = pricing.resaleSignal {
3985                rows.append(InfoRowViewData(label: "Resale", value: resaleSignal, tone: .secondary))
3986            }
3987            if let auctionSignal = pricing.auctionSignal {
3988                rows.append(InfoRowViewData(label: "Auction", value: auctionSignal, tone: .secondary))
3989            }
3990        }
3991        if let certificateStatus = certificateBadgeLabel(from: snapshot) {
3992            rows.insert(
3993                InfoRowViewData(
3994                    label: "Certificate",
3995                    value: certificateStatus,
3996                    tone: certificateStatusTone(from: snapshot)
3997                ),
3998                at: 2
3999            )
4000        }
4001        return rows
4002    }
4003
4004    static func suggestionRows(from snapshot: LookupSnapshot) -> [DomainSuggestionViewData] {
4005        snapshot.suggestions.map {
4006            DomainSuggestionViewData(
4007                id: $0.id,
4008                domain: $0.domain,
4009                availabilityStatus: $0.status,
4010                status: availabilityLabel($0.status),
4011                tone: availabilityTone($0.status)
4012            )
4013        }
4014    }
4015
4016    static func subdomainRows(from subdomains: [DiscoveredSubdomain]) -> [SubdomainRowViewData] {
4017        subdomains.map { subdomain in
4018            SubdomainRowViewData(
4019                hostname: subdomain.hostname,
4020                isInteresting: subdomain.isExtended || isInterestingSubdomain(subdomain.hostname)
4021            )
4022        }
4023    }
4024
4025    static func dnsRows(from snapshot: LookupSnapshot) -> [DNSRecordSectionViewData] {
4026        snapshot.dnsSections.map { section in
4027            DNSRecordSectionViewData(
4028                title: section.recordType.rawValue,
4029                rows: section.records.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary) },
4030                wildcardRows: section.wildcardRecords.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary) },
4031                wildcardTitle: section.wildcardRecords.isEmpty ? nil : "*.\(snapshot.domain)",
4032                message: section.error.map { SectionMessageViewData(text: $0, isError: true) } ??
4033                    ((section.records.isEmpty && section.wildcardRecords.isEmpty) ? SectionMessageViewData(text: "No records found", isError: false) : nil)
4034            )
4035        }
4036    }
4037
4038    static func dnssecLabel(from snapshot: LookupSnapshot) -> String? {
4039        guard let signed = snapshot.dnsSections.compactMap(\.dnssecSigned).first else { return nil }
4040        return "Resolver-reported DNSSEC (not full validation): \(signed ? "Yes" : "No")"
4041    }
4042
4043    static func ptrMessage(from snapshot: LookupSnapshot) -> SectionMessageViewData? {
4044        if let ptrRecord = snapshot.ptrRecord {
4045            return SectionMessageViewData(text: ptrRecord, isError: false)
4046        }
4047        if let ptrError = snapshot.ptrError {
4048            return SectionMessageViewData(text: ptrError, isError: ptrError != "No A record available" && ptrError != "No PTR record found")
4049        }
4050        return nil
4051    }
4052
4053    static func webCertificateRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
4054        guard let sslInfo = snapshot.sslInfo else { return [] }
4055        var rows = [
4056            InfoRowViewData(label: "Common Name", value: sslInfo.commonName, tone: .primary),
4057            InfoRowViewData(label: "Issuer", value: sslInfo.issuer, tone: .primary),
4058            InfoRowViewData(label: "Valid From", value: certificateDateFormatter.string(from: sslInfo.validFrom), tone: .secondary),
4059            InfoRowViewData(label: "Valid Until", value: certificateDateFormatter.string(from: sslInfo.validUntil), tone: .secondary),
4060            InfoRowViewData(label: "Days Until Expiry", value: "\(sslInfo.daysUntilExpiry)", tone: certificateTone(daysRemaining: sslInfo.daysUntilExpiry)),
4061            InfoRowViewData(label: "Chain Depth", value: "\(sslInfo.chainDepth)", tone: .secondary)
4062        ]
4063        if let tlsVersion = sslInfo.tlsVersion {
4064            rows.append(InfoRowViewData(label: "TLS Version", value: tlsVersion, tone: .secondary))
4065        }
4066        if let cipherSuite = sslInfo.cipherSuite {
4067            rows.append(InfoRowViewData(label: "Cipher Suite", value: cipherSuite, tone: .secondary))
4068        }
4069        if let hstsPreloaded = snapshot.hstsPreloaded {
4070            rows.append(InfoRowViewData(label: "HSTS Preload", value: hstsPreloaded ? "Preloaded" : "Not preloaded", tone: hstsPreloaded ? .success : .secondary))
4071        }
4072        return rows
4073    }
4074
4075    static func webResponseRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
4076        var rows: [InfoRowViewData] = []
4077        if let httpStatusCode = snapshot.httpStatusCode {
4078            rows.append(InfoRowViewData(label: "Status", value: "\(httpStatusCode)", tone: .primary))
4079        }
4080        if let httpResponseTimeMs = snapshot.httpResponseTimeMs {
4081            rows.append(InfoRowViewData(label: "Response Time", value: "\(httpResponseTimeMs) ms", tone: .secondary))
4082        }
4083        if let httpProtocol = snapshot.httpProtocol {
4084            rows.append(InfoRowViewData(label: "Protocol", value: httpProtocol, tone: .secondary))
4085        }
4086        if let httpSecurityGrade = snapshot.httpSecurityGrade {
4087            rows.append(InfoRowViewData(label: "Security Grade", value: httpSecurityGrade, tone: securityGradeTone(httpSecurityGrade)))
4088        }
4089        if snapshot.http3Advertised {
4090            rows.append(InfoRowViewData(label: "HTTP/3", value: "Advertised", tone: .secondary))
4091        }
4092        return rows
4093    }
4094
4095    static func redirectRows(from snapshot: LookupSnapshot) -> [RedirectHopViewData] {
4096        snapshot.redirectChain.map {
4097            RedirectHopViewData(
4098                stepLabel: "\($0.stepNumber)",
4099                statusCode: "\($0.statusCode)",
4100                url: $0.url,
4101                isFinal: $0.isFinal
4102            )
4103        }
4104    }
4105
4106    static func emailRows(from snapshot: LookupSnapshot) -> [EmailRowViewData] {
4107        guard let emailSecurity = snapshot.emailSecurity else { return [] }
4108        return [
4109            EmailRowViewData(label: "SPF", status: emailSecurity.spf.found ? "Present" : "Missing", statusTone: emailSecurity.spf.found ? .success : .warning, detail: emailSecurity.spf.value ?? "No record found", auxiliaryDetail: nil),
4110            EmailRowViewData(label: "DMARC", status: emailSecurity.dmarc.found ? "Present" : "Missing", statusTone: emailSecurity.dmarc.found ? .success : .warning, detail: emailSecurity.dmarc.value ?? "No record found", auxiliaryDetail: nil),
4111            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)" }),
4112            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),
4113            EmailRowViewData(label: "BIMI", status: emailSecurity.bimi.found ? "Present" : "Missing", statusTone: emailSecurity.bimi.found ? .success : .warning, detail: emailSecurity.bimi.value ?? "No record found", auxiliaryDetail: nil)
4114        ]
4115    }
4116
4117    static func ownershipRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
4118        let ownership = snapshot.ownership
4119
4120        return [
4121            InfoRowViewData(label: "Registrar", value: ownership?.registrar ?? "Unavailable", tone: ownership?.registrar == nil ? .secondary : .primary),
4122            InfoRowViewData(label: "Registered", value: ownership?.createdDate.map(ownershipDateFormatter.string(from:)) ?? "Unavailable", tone: ownership?.createdDate == nil ? .secondary : .primary),
4123            InfoRowViewData(label: "Expires", value: ownership?.expirationDate.map(ownershipDateFormatter.string(from:)) ?? "Unavailable", tone: ownership?.expirationDate == nil ? .secondary : .primary),
4124            InfoRowViewData(label: "Status", value: ownership?.status.nilIfEmpty?.joined(separator: ", ") ?? "Unavailable", tone: ownership?.status.isEmpty == false ? .primary : .secondary),
4125            InfoRowViewData(label: "Nameservers", value: ownership?.nameservers.nilIfEmpty?.joined(separator: ", ") ?? "Unavailable", tone: ownership?.nameservers.isEmpty == false ? .primary : .secondary),
4126            InfoRowViewData(label: "Abuse Contact", value: ownership?.abuseEmail ?? "Unavailable", tone: ownership?.abuseEmail == nil ? .secondary : .primary)
4127        ]
4128    }
4129
4130    static func subdomainRows(from snapshot: LookupSnapshot) -> [SubdomainRowViewData] {
4131        snapshot.subdomains.map { subdomain in
4132            SubdomainRowViewData(
4133                hostname: subdomain.hostname,
4134                isInteresting: isInterestingSubdomain(subdomain.hostname)
4135            )
4136        }
4137    }
4138
4139    static func reachabilityRows(from snapshot: LookupSnapshot) -> [ReachabilityRowViewData] {
4140        snapshot.reachabilityResults.map {
4141            ReachabilityRowViewData(
4142                portLabel: "Port \($0.port)",
4143                latencyLabel: $0.latencyMs.map { "\($0) ms" } ?? "",
4144                statusLabel: $0.reachable ? "Reachable" : "Unreachable",
4145                statusTone: $0.reachable ? .success : .failure
4146            )
4147        }
4148    }
4149
4150    static func locationRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
4151        guard let ipGeolocation = snapshot.ipGeolocation else { return [] }
4152        var rows = [InfoRowViewData(label: "IP", value: ipGeolocation.ip, tone: .primary)]
4153        if let org = ipGeolocation.org {
4154            rows.append(InfoRowViewData(label: "Org / ISP", value: org, tone: .secondary))
4155        }
4156        let location = [ipGeolocation.city, ipGeolocation.region, ipGeolocation.country_name].compactMap { $0 }.joined(separator: ", ")
4157        if !location.isEmpty {
4158            rows.append(InfoRowViewData(label: "Location", value: location, tone: .secondary))
4159        }
4160        if let latitude = ipGeolocation.latitude, let longitude = ipGeolocation.longitude {
4161            rows.append(InfoRowViewData(label: "Coordinates", value: "\(latitude), \(longitude)", tone: .secondary))
4162        }
4163        return rows
4164    }
4165
4166    static func portRows(from snapshot: LookupSnapshot, kind: PortScanKind) -> [PortScanRowViewData] {
4167        snapshot.portScanResults
4168            .filter { $0.kind == kind }
4169            .map {
4170                PortScanRowViewData(
4171                    portLabel: "\($0.port)",
4172                    service: $0.service,
4173                    statusLabel: $0.open ? "Open" : "Closed",
4174                    statusTone: $0.open ? .success : .secondary,
4175                    banner: $0.banner,
4176                    durationLabel: $0.durationMs.map { "\($0) ms" }
4177                )
4178            }
4179    }
4180
4181    static func formatBatchExportText(
4182        title: String,
4183        entries: [(snapshot: LookupSnapshot, trackedDomain: TrackedDomain?, changeSummary: DomainChangeSummary?, diffSections: [DomainDiffSection])]
4184    ) -> String {
4185        guard !entries.isEmpty else {
4186            return "\(title)\nNo results available."
4187        }
4188
4189        var lines = [title, String(repeating: "=", count: title.count), ""]
4190        for (index, entry) in entries.enumerated() {
4191            if index > 0 {
4192                lines.append("")
4193                lines.append(String(repeating: "=", count: 48))
4194                lines.append("")
4195            }
4196
4197            lines.append(
4198                formatExportText(
4199                    from: entry.snapshot,
4200                    trackedDomain: entry.trackedDomain,
4201                    changeSummary: entry.changeSummary,
4202                    diffSections: entry.diffSections
4203                )
4204            )
4205        }
4206        return lines.joined(separator: "\n")
4207    }
4208
4209    static func formatCSV(from snapshots: [LookupSnapshot]) -> String {
4210        let headers = [
4211            "domain",
4212            "availability",
4213            "primary_ip",
4214            "redirect_target",
4215            "tls_status",
4216            "http_status_grade",
4217            "email_security_summary",
4218            "registrar",
4219            "ownership_expires",
4220            "ownership_status",
4221            "ownership_nameservers",
4222            "subdomain_count",
4223            "subdomains",
4224            "last_updated"
4225        ]
4226
4227        let rows = snapshots.map { snapshot in
4228            [
4229                snapshot.domain,
4230                availabilityLabel(snapshot.availabilityResult?.status),
4231                primaryIPAddress(from: snapshot) ?? "",
4232                finalRedirectTarget(from: snapshot) ?? "",
4233                httpsSummary(from: snapshot),
4234                httpStatusGradeSummary(from: snapshot),
4235                emailSummary(from: snapshot),
4236                snapshot.ownership?.registrar ?? "",
4237                snapshot.ownership?.expirationDate.map(csvDateFormatter.string(from:)) ?? "",
4238                snapshot.ownership?.status.joined(separator: " | ") ?? "",
4239                snapshot.ownership?.nameservers.joined(separator: " | ") ?? "",
4240                "\(snapshot.subdomains.count)",
4241                snapshot.subdomains.map(\.hostname).joined(separator: " | "),
4242                csvDateFormatter.string(from: snapshot.timestamp)
4243            ]
4244        }
4245
4246        return ([headers] + rows)
4247            .map { row in row.map(csvEscaped).joined(separator: ",") }
4248            .joined(separator: "\n")
4249    }
4250
4251    static func formatExportText(
4252        from snapshot: LookupSnapshot,
4253        trackedDomain: TrackedDomain?,
4254        changeSummary: DomainChangeSummary?,
4255        diffSections: [DomainDiffSection]
4256    ) -> String {
4257        let exportDateFormatter = DateFormatter()
4258        exportDateFormatter.dateFormat = "yyyy-MM-dd HH:mm"
4259
4260        var lines: [String] = [
4261            "DomainDig Export",
4262            "Domain: \(snapshot.domain)",
4263            "Date: \(exportDateFormatter.string(from: snapshot.timestamp))",
4264            "Mode: \(snapshot.statusMessage ?? snapshot.resultSource.label)",
4265            "Resolver: \(snapshot.resolverDisplayName)",
4266            "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))",
4267            "Tracked: \(trackedDomain == nil ? "No" : "Yes")"
4268        ]
4269
4270        if let note = trackedDomain?.note?.nilIfEmpty {
4271            lines.append("Tracking Note: \(note)")
4272        }
4273
4274        func appendSection(_ title: String, body: () -> Void) {
4275            lines.append("")
4276            lines.append(title)
4277            lines.append(String(repeating: "-", count: title.count))
4278            body()
4279        }
4280
4281        appendSection("Summary") {
4282            for item in summaryFields(from: snapshot) {
4283                lines.append("  \(item.label): \(item.value)")
4284            }
4285            if let changeSummary {
4286                lines.append("  Change Summary: \(changeSummary.message)")
4287                lines.append("  Severity: \(changeSummary.severity.title)")
4288                lines.append("  Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))")
4289                lines.append("  Compared At: \(exportDateFormatter.string(from: changeSummary.generatedAt))")
4290            }
4291        }
4292
4293        appendSection("Tracking") {
4294            if let trackedDomain {
4295                lines.append("  Pinned: \(trackedDomain.isPinned ? "Yes" : "No")")
4296                lines.append("  Last Refresh: \(exportDateFormatter.string(from: trackedDomain.updatedAt))")
4297                lines.append("  Last Known Availability: \(availabilityLabel(trackedDomain.lastKnownAvailability))")
4298                if let note = trackedDomain.note?.nilIfEmpty {
4299                    lines.append("  Note: \(note)")
4300                }
4301            } else {
4302                lines.append("  This domain is not currently tracked.")
4303            }
4304        }
4305
4306        appendSection("Diff Summary") {
4307            if diffSections.isEmpty {
4308                lines.append("  No comparison available")
4309            } else {
4310                for section in diffSections where section.items.contains(where: { $0.changeType != .unchanged }) {
4311                    lines.append("  \(section.title)")
4312                    for item in section.items where item.changeType != .unchanged {
4313                        lines.append("    [\(item.changeType.rawValue.capitalized)] \(item.label): \(item.oldValue ?? "None") -> \(item.newValue ?? "None")")
4314                    }
4315                }
4316            }
4317        }
4318
4319        appendSection("Domain") {
4320            for row in domainRows(from: snapshot) {
4321                lines.append("  \(row.label): \(row.value)")
4322            }
4323            if snapshot.suggestions.isEmpty {
4324                lines.append("  Suggestions: None")
4325            } else {
4326                lines.append("  Suggestions:")
4327                for suggestion in snapshot.suggestions {
4328                    lines.append("    \(suggestion.domain): \(availabilityLabel(suggestion.status))")
4329                }
4330            }
4331        }
4332
4333        appendSection("Ownership") {
4334            for row in ownershipRows(from: snapshot) {
4335                lines.append("  \(row.label): \(row.value)")
4336            }
4337            if let ownershipError = snapshot.ownershipError, snapshot.ownership == nil {
4338                lines.append("  Source: \(ownershipError)")
4339            }
4340            if !DataAccessService.hasAccess(to: .ownershipHistory) {
4341                lines.append("  Ownership history (coming soon)")
4342            }
4343        }
4344
4345        appendSection("Subdomains") {
4346            lines.append("  Count: \(snapshot.subdomains.count)")
4347            if snapshot.subdomains.isEmpty {
4348                lines.append("  \(snapshot.subdomainsError ?? "No passive subdomains found")")
4349            } else {
4350                for subdomain in subdomainRows(from: snapshot) {
4351                    let marker = subdomain.isInteresting ? " [interesting]" : ""
4352                    lines.append("  \(subdomain.hostname)\(marker)")
4353                }
4354            }
4355            if !DataAccessService.hasAccess(to: .extendedSubdomains) {
4356                lines.append("  Extended subdomain discovery (Pro+)")
4357            }
4358        }
4359
4360        appendSection("DNS") {
4361            if let dnsError = snapshot.dnsError {
4362                lines.append("  Error: \(dnsError)")
4363            }
4364            if let dnssecLabel = dnssecLabel(from: snapshot) {
4365                lines.append("  \(dnssecLabel)")
4366            }
4367            for section in dnsRows(from: snapshot) {
4368                lines.append("  \(section.title)")
4369                if let message = section.message {
4370                    lines.append("    \(message.isError ? "Error" : "Info"): \(message.text)")
4371                }
4372                for row in section.rows {
4373                    lines.append("    \(row.value) (\(row.label))")
4374                }
4375                if let wildcardTitle = section.wildcardTitle {
4376                    lines.append("    \(wildcardTitle)")
4377                    for row in section.wildcardRows {
4378                        lines.append("      \(row.value) (\(row.label))")
4379                    }
4380                }
4381            }
4382            if let ptrRecord = snapshot.ptrRecord {
4383                lines.append("  PTR: \(ptrRecord)")
4384            } else if let ptrError = snapshot.ptrError {
4385                lines.append("  PTR Error: \(ptrError)")
4386            }
4387        }
4388
4389        appendSection("Web") {
4390            if let sslError = snapshot.sslError {
4391                lines.append("  TLS Error: \(sslError)")
4392            } else {
4393                for row in webCertificateRows(from: snapshot) {
4394                    lines.append("  \(row.label): \(row.value)")
4395                }
4396            }
4397
4398            if let httpHeadersError = snapshot.httpHeadersError {
4399                lines.append("  Headers Error: \(httpHeadersError)")
4400            } else {
4401                for row in webResponseRows(from: snapshot) {
4402                    lines.append("  \(row.label): \(row.value)")
4403                }
4404                if snapshot.httpHeaders.isEmpty {
4405                    lines.append("  Headers: No headers returned")
4406                } else {
4407                    lines.append("  Headers:")
4408                    for header in snapshot.httpHeaders {
4409                        lines.append("    \(header.name): \(header.value)")
4410                    }
4411                }
4412            }
4413
4414            if let redirectChainError = snapshot.redirectChainError {
4415                lines.append("  Redirect Error: \(redirectChainError)")
4416            } else if snapshot.redirectChain.isEmpty {
4417                lines.append("  Redirects: No redirect data available")
4418            } else {
4419                lines.append("  Redirects:")
4420                for hop in redirectRows(from: snapshot) {
4421                    lines.append("    \(hop.stepLabel). \(hop.statusCode) \(hop.url)\(hop.isFinal ? " (final)" : "")")
4422                }
4423            }
4424        }
4425
4426        appendSection("Email") {
4427            if let emailSecurityError = snapshot.emailSecurityError {
4428                lines.append("  Error: \(emailSecurityError)")
4429            } else if emailRows(from: snapshot).isEmpty {
4430                lines.append("  No email security records found")
4431            } else {
4432                for row in emailRows(from: snapshot) {
4433                    lines.append("  \(row.label): \(row.status)")
4434                    lines.append("    \(row.detail)")
4435                    if let auxiliaryDetail = row.auxiliaryDetail {
4436                        lines.append("    \(auxiliaryDetail)")
4437                    }
4438                }
4439            }
4440        }
4441
4442        appendSection("Network") {
4443            if let reachabilityError = snapshot.reachabilityError {
4444                lines.append("  Reachability Error: \(reachabilityError)")
4445            } else if reachabilityRows(from: snapshot).isEmpty {
4446                lines.append("  Reachability: No results")
4447            } else {
4448                lines.append("  Reachability:")
4449                for row in reachabilityRows(from: snapshot) {
4450                    lines.append("    \(row.portLabel): \(row.statusLabel) \(row.latencyLabel)")
4451                }
4452            }
4453
4454            if let ipGeolocationError = snapshot.ipGeolocationError, snapshot.ipGeolocation == nil {
4455                lines.append("  Location Error: \(ipGeolocationError)")
4456            } else if locationRows(from: snapshot).isEmpty {
4457                lines.append("  Location: No data")
4458            } else {
4459                lines.append("  Location:")
4460                for row in locationRows(from: snapshot) {
4461                    lines.append("    \(row.label): \(row.value)")
4462                }
4463            }
4464
4465            if let portScanError = snapshot.portScanError, snapshot.portScanResults.isEmpty {
4466                lines.append("  Port Scan Error: \(portScanError)")
4467            }
4468
4469            lines.append("  Standard Ports:")
4470            let standardRows = portRows(from: snapshot, kind: .standard)
4471            if standardRows.isEmpty {
4472                lines.append("    No results")
4473            } else {
4474                for row in standardRows {
4475                    lines.append("    \(row.portLabel) \(row.service): \(row.statusLabel)\(row.durationLabel.map { " \($0)" } ?? "")")
4476                    if let banner = row.banner {
4477                        lines.append("      Banner: \(banner)")
4478                    }
4479                }
4480            }
4481
4482            lines.append("  Custom Ports:")
4483            let customRows = portRows(from: snapshot, kind: .custom)
4484            if customRows.isEmpty {
4485                lines.append("    No results")
4486            } else {
4487                for row in customRows {
4488                    lines.append("    \(row.portLabel) \(row.service): \(row.statusLabel)\(row.durationLabel.map { " \($0)" } ?? "")")
4489                    if let banner = row.banner {
4490                        lines.append("      Banner: \(banner)")
4491                    }
4492                }
4493            }
4494        }
4495
4496        return lines.joined(separator: "\n")
4497    }
4498
4499    private static func primaryIPAddress(from snapshot: LookupSnapshot) -> String? {
4500        snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
4501    }
4502
4503    private static func finalRedirectTarget(from snapshot: LookupSnapshot) -> String? {
4504        snapshot.redirectChain.last?.url
4505    }
4506
4507    private static func httpStatusGradeSummary(from snapshot: LookupSnapshot) -> String {
4508        let parts = [snapshot.httpStatusCode.map(String.init), snapshot.httpSecurityGrade].compactMap { $0 }
4509        if !parts.isEmpty {
4510            return parts.joined(separator: " / ")
4511        }
4512        return snapshot.httpHeadersError ?? "Unavailable"
4513    }
4514
4515    private static func httpsSummary(from snapshot: LookupSnapshot) -> String {
4516        if snapshot.sslInfo != nil {
4517            return "Valid"
4518        }
4519        if let sslError = snapshot.sslError {
4520            return sslError.localizedCaseInsensitiveContains("certificate") ? "Invalid" : "Failed"
4521        }
4522        return "Unavailable"
4523    }
4524
4525    private static func httpsSummaryTone(from snapshot: LookupSnapshot) -> ResultTone {
4526        if snapshot.sslInfo != nil {
4527            return .success
4528        }
4529        return snapshot.sslError == nil ? .secondary : .failure
4530    }
4531
4532    private static func emailSummary(from snapshot: LookupSnapshot) -> String {
4533        guard let emailSecurity = snapshot.emailSecurity else {
4534            return snapshot.emailSecurityError ?? "Unavailable"
4535        }
4536        return "SPF \(emailSecurity.spf.found ? "Yes" : "No") / DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")"
4537    }
4538
4539    private static func certificateStatusLabel(from snapshot: LookupSnapshot) -> String {
4540        guard let sslInfo = snapshot.sslInfo else {
4541            return snapshot.sslError ?? "Unavailable"
4542        }
4543
4544        switch DomainDiffService.certificateWarningLevel(for: snapshot) {
4545        case .critical:
4546            return "Critical (\(sslInfo.daysUntilExpiry)d)"
4547        case .warning:
4548            return "Warning (\(sslInfo.daysUntilExpiry)d)"
4549        case .none:
4550            return "Healthy (\(sslInfo.daysUntilExpiry)d)"
4551        }
4552    }
4553
4554    private static func certificateBadgeLabel(from snapshot: LookupSnapshot) -> String? {
4555        guard snapshot.sslInfo != nil else { return nil }
4556        return certificateStatusLabel(from: snapshot)
4557    }
4558
4559    private static func certificateStatusTone(from snapshot: LookupSnapshot) -> ResultTone {
4560        guard let daysRemaining = snapshot.sslInfo?.daysUntilExpiry else {
4561            return snapshot.sslError == nil ? .secondary : .failure
4562        }
4563        return certificateTone(daysRemaining: daysRemaining)
4564    }
4565
4566    private static func certificateTone(daysRemaining: Int) -> ResultTone {
4567        if daysRemaining < 14 {
4568            return .failure
4569        }
4570        if daysRemaining < 30 {
4571            return .warning
4572        }
4573        return .success
4574    }
4575
4576    private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String {
4577        switch status {
4578        case .available:
4579            return "Available"
4580        case .registered:
4581            return "Registered"
4582        case .unknown, .none:
4583            return "Unknown"
4584        }
4585    }
4586
4587    private static func availabilityInference(from snapshot: LookupSnapshot) -> String {
4588        switch snapshot.availabilityResult?.status {
4589        case .registered:
4590            return "Likely registered"
4591        case .available:
4592            return "Possibly available"
4593        case .unknown, .none:
4594            return "Unclear"
4595        }
4596    }
4597
4598    private static func availabilityTone(_ status: DomainAvailabilityStatus?) -> ResultTone {
4599        switch status {
4600        case .available:
4601            return .success
4602        case .registered:
4603            return .warning
4604        case .unknown, .none:
4605            return .secondary
4606        }
4607    }
4608
4609    private static func sourceTone(for snapshot: LookupSnapshot) -> ResultTone {
4610        if snapshot.statusMessage != nil {
4611            return .warning
4612        }
4613
4614        switch snapshot.resultSource {
4615        case .live:
4616            return .success
4617        case .cached:
4618            return .secondary
4619        case .mixed:
4620            return .warning
4621        case .snapshot:
4622            return .warning
4623        }
4624    }
4625
4626    private static func securityGradeTone(_ grade: String) -> ResultTone {
4627        switch grade {
4628        case "A", "B":
4629            return .success
4630        case "C":
4631            return .warning
4632        case "D", "F":
4633            return .failure
4634        default:
4635            return .secondary
4636        }
4637    }
4638
4639    private static func durationLabel(_ durationMs: Int?) -> String {
4640        durationMs.map { "\($0) ms" } ?? "Unavailable"
4641    }
4642
4643    private static let certificateDateFormatter: DateFormatter = {
4644        let formatter = DateFormatter()
4645        formatter.dateStyle = .medium
4646        formatter.timeStyle = .short
4647        return formatter
4648    }()
4649
4650    private static let ownershipDateFormatter: DateFormatter = {
4651        let formatter = DateFormatter()
4652        formatter.dateStyle = .medium
4653        formatter.timeStyle = .none
4654        return formatter
4655    }()
4656
4657    private static let csvDateFormatter: ISO8601DateFormatter = {
4658        let formatter = ISO8601DateFormatter()
4659        formatter.formatOptions = [.withInternetDateTime]
4660        return formatter
4661    }()
4662
4663    private func refreshDomainPricing(for domain: String, persistAfterFetch: Bool) async {
4664        domainPricingLoading = true
4665        let outcome = await ExternalDataService.shared.pricing(domain: domain)
4666
4667        switch outcome.value {
4668        case let .success(pricing):
4669            domainPricing = pricing
4670            domainPricingError = nil
4671        case let .empty(message):
4672            domainPricing = nil
4673            domainPricingError = conciseExternalMessage(message, fallback: "External pricing unavailable")
4674        case let .error(message):
4675            domainPricing = nil
4676            domainPricingError = conciseExternalMessage(message, fallback: "External pricing unavailable")
4677        }
4678
4679        domainPricingLoading = false
4680
4681        if persistAfterFetch {
4682            _ = saveHistoryEntry(replaceLatest: true)
4683        }
4684    }
4685
4686    private func conciseExternalMessage(_ message: String, fallback: String) -> String {
4687        let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
4688        if trimmed.isEmpty {
4689            return fallback
4690        }
4691        if trimmed.localizedCaseInsensitiveContains("rate") {
4692            return "Rate limited. Try again later."
4693        }
4694        if trimmed.localizedCaseInsensitiveContains("invalid") {
4695            return "External data was invalid."
4696        }
4697        if trimmed.localizedCaseInsensitiveContains("network") {
4698            return "External data is offline."
4699        }
4700        return trimmed
4701    }
4702
4703    private static func defaultUsageCredits() -> [UsageCreditFeature: UsageCreditStatus] {
4704        Dictionary(uniqueKeysWithValues: UsageCreditFeature.allCases.map { feature in
4705            (feature, fallbackCreditStatus(for: feature))
4706        })
4707    }
4708
4709    private static func fallbackCreditStatus(for feature: UsageCreditFeature) -> UsageCreditStatus {
4710        UsageCreditStatus(
4711            feature: feature,
4712            remaining: feature.defaultAllowance,
4713            total: feature.defaultAllowance,
4714            resetContext: "Resets with app version \(AppVersion.current)"
4715        )
4716    }
4717
4718    private static func csvEscaped(_ value: String) -> String {
4719        let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
4720        return "\"\(escaped)\""
4721    }
4722
4723    private static func isInterestingSubdomain(_ hostname: String) -> Bool {
4724        let keywords = ["admin", "api", "dev", "staging", "test", "internal"]
4725        let labels = hostname.lowercased().split(separator: ".").map(String.init)
4726        return labels.contains { label in
4727            keywords.contains(where: { label.contains($0) })
4728        }
4729    }
4730}
4731
4732private extension String {
4733    var nonEmpty: String? {
4734        isEmpty ? nil : self
4735    }
4736
4737    var nilIfEmpty: String? {
4738        isEmpty ? nil : self
4739    }
4740}
4741
4742private extension Array where Element == String {
4743    var nilIfEmpty: [String]? {
4744        isEmpty ? nil : self
4745    }
4746}