krz/domain-dig

an ios app for DNS & SSL analysis

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

v4.7.0: DomainDig/DomainViewModel.swift · raw

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