krz/domain-dig

an ios app for DNS & SSL analysis

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

v2.0.0: DomainDig/DomainViewModel.swift · raw

   1import Foundation
   2import SwiftUI
   3
   4enum ResultTone {
   5    case primary
   6    case secondary
   7    case success
   8    case warning
   9    case failure
  10}
  11
  12struct SummaryFieldViewData: Identifiable {
  13    let id = UUID()
  14    let label: String
  15    let value: String
  16    let tone: ResultTone
  17}
  18
  19struct InfoRowViewData: Identifiable {
  20    let id = UUID()
  21    let label: String
  22    let value: String
  23    let tone: ResultTone
  24}
  25
  26struct SectionMessageViewData {
  27    let text: String
  28    let isError: Bool
  29}
  30
  31struct DNSRecordSectionViewData: Identifiable {
  32    let id = UUID()
  33    let title: String
  34    let rows: [InfoRowViewData]
  35    let wildcardRows: [InfoRowViewData]
  36    let wildcardTitle: String?
  37    let message: SectionMessageViewData?
  38}
  39
  40struct EmailRowViewData: Identifiable {
  41    let id = UUID()
  42    let label: String
  43    let status: String
  44    let statusTone: ResultTone
  45    let detail: String
  46    let auxiliaryDetail: String?
  47}
  48
  49struct RedirectHopViewData: Identifiable {
  50    let id = UUID()
  51    let stepLabel: String
  52    let statusCode: String
  53    let url: String
  54    let isFinal: Bool
  55}
  56
  57struct ReachabilityRowViewData: Identifiable {
  58    let id = UUID()
  59    let portLabel: String
  60    let latencyLabel: String
  61    let statusLabel: String
  62    let statusTone: ResultTone
  63}
  64
  65struct PortScanRowViewData: Identifiable {
  66    let id = UUID()
  67    let portLabel: String
  68    let service: String
  69    let statusLabel: String
  70    let statusTone: ResultTone
  71    let banner: String?
  72    let durationLabel: String?
  73}
  74
  75struct DomainSuggestionViewData: Identifiable {
  76    let id: UUID
  77    let domain: String
  78    let status: String
  79    let tone: ResultTone
  80}
  81
  82struct LookupSnapshot {
  83    let historyEntryID: UUID?
  84    let domain: String
  85    let timestamp: Date
  86    let trackedDomainID: UUID?
  87    let resolverDisplayName: String
  88    let resolverURLString: String
  89    let totalLookupDurationMs: Int?
  90    let dnsSections: [DNSSection]
  91    let dnsError: String?
  92    let availabilityResult: DomainAvailabilityResult?
  93    let suggestions: [DomainSuggestionResult]
  94    let sslInfo: SSLCertificateInfo?
  95    let sslError: String?
  96    let hstsPreloaded: Bool?
  97    let httpHeaders: [HTTPHeader]
  98    let httpSecurityGrade: String?
  99    let httpStatusCode: Int?
 100    let httpResponseTimeMs: Int?
 101    let httpProtocol: String?
 102    let http3Advertised: Bool
 103    let httpHeadersError: String?
 104    let reachabilityResults: [PortReachability]
 105    let reachabilityError: String?
 106    let ipGeolocation: IPGeolocation?
 107    let ipGeolocationError: String?
 108    let emailSecurity: EmailSecurityResult?
 109    let emailSecurityError: String?
 110    let ptrRecord: String?
 111    let ptrError: String?
 112    let redirectChain: [RedirectHop]
 113    let redirectChainError: String?
 114    let portScanResults: [PortScanResult]
 115    let portScanError: String?
 116    let changeSummary: DomainChangeSummary?
 117    let isLive: Bool
 118}
 119
 120extension HistoryEntry {
 121    var snapshot: LookupSnapshot {
 122        LookupSnapshot(
 123            historyEntryID: id,
 124            domain: domain,
 125            timestamp: timestamp,
 126            trackedDomainID: trackedDomainID,
 127            resolverDisplayName: resolverDisplayName,
 128            resolverURLString: resolverURLString,
 129            totalLookupDurationMs: totalLookupDurationMs,
 130            dnsSections: dnsSections,
 131            dnsError: nil,
 132            availabilityResult: availabilityResult,
 133            suggestions: suggestions,
 134            sslInfo: sslInfo,
 135            sslError: sslError,
 136            hstsPreloaded: hstsPreloaded,
 137            httpHeaders: httpHeaders,
 138            httpSecurityGrade: HTTPSecurityGrade.grade(for: httpHeaders).rawValue,
 139            httpStatusCode: nil,
 140            httpResponseTimeMs: nil,
 141            httpProtocol: nil,
 142            http3Advertised: false,
 143            httpHeadersError: httpHeadersError,
 144            reachabilityResults: reachabilityResults,
 145            reachabilityError: reachabilityError,
 146            ipGeolocation: ipGeolocation,
 147            ipGeolocationError: ipGeolocationError,
 148            emailSecurity: emailSecurity,
 149            emailSecurityError: emailSecurityError,
 150            ptrRecord: ptrRecord,
 151            ptrError: ptrError,
 152            redirectChain: redirectChain,
 153            redirectChainError: redirectChainError,
 154            portScanResults: portScanResults,
 155            portScanError: portScanError,
 156            changeSummary: changeSummary,
 157            isLive: false
 158        )
 159    }
 160}
 161
 162@MainActor
 163@Observable
 164final class DomainViewModel {
 165    var domain: String = ""
 166
 167    var dnsSections: [DNSSection] = []
 168    var dnsLoading = false
 169    var dnsError: String?
 170    var availabilityResult: DomainAvailabilityResult?
 171    var availabilityLoading = false
 172    var suggestions: [DomainSuggestionResult] = []
 173    var suggestionsLoading = false
 174
 175    var sslInfo: SSLCertificateInfo?
 176    var sslLoading = false
 177    var sslError: String?
 178    var hstsPreloaded: Bool?
 179    var hstsLoading = false
 180
 181    var httpHeaders: [HTTPHeader] = []
 182    var httpSecurityGrade: String?
 183    var httpStatusCode: Int?
 184    var httpResponseTimeMs: Int?
 185    var httpProtocol: String?
 186    var http3Advertised = false
 187    var httpHeadersLoading = false
 188    var httpHeadersError: String?
 189
 190    var reachabilityResults: [PortReachability] = []
 191    var reachabilityLoading = false
 192    var reachabilityError: String?
 193
 194    var ipGeolocation: IPGeolocation?
 195    var ipGeolocationLoading = false
 196    var ipGeolocationError: String?
 197
 198    var emailSecurity: EmailSecurityResult?
 199    var emailSecurityLoading = false
 200    var emailSecurityError: String?
 201
 202    var ptrRecord: String?
 203    var ptrLoading = false
 204    var ptrError: String?
 205
 206    var redirectChain: [RedirectHop] = []
 207    var redirectChainLoading = false
 208    var redirectChainError: String?
 209
 210    var portScanResults: [PortScanResult] = []
 211    var portScanLoading = false
 212    var portScanError: String?
 213    var customPortResults: [PortScanResult] = []
 214    var customPortScanLoading = false
 215    var customPortScanError: String?
 216
 217    var hasRun = false
 218    private(set) var searchedDomain: String = ""
 219    private(set) var lastLookupDurationMs: Int?
 220    private(set) var currentDiffSections: [DomainDiffSection] = []
 221    private(set) var currentChangeSummary: DomainChangeSummary?
 222    private(set) var refreshingTrackedDomainID: UUID?
 223    private(set) var rerunNavigationToken = UUID()
 224
 225    private var lookupTask: Task<Void, Never>?
 226    private var customPortScanTask: Task<Void, Never>?
 227    private var activeLookupID = UUID()
 228    private var lookupStartedAt: Date?
 229
 230    private static let recentSearchesKey = "recentSearches"
 231    private static let maxRecent = 20
 232    var recentSearches: [String] = UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? []
 233
 234    private static let savedDomainsKey = "savedDomains"
 235    var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? []
 236
 237    private static let trackedDomainsKey = "trackedDomains"
 238    private static let legacyWatchedDomainsKey = "watchedDomains"
 239    var trackedDomains: [TrackedDomain] = DomainViewModel.loadTrackedDomains()
 240
 241    private static let historyKey = "lookupHistory"
 242    private static let maxHistory = 50
 243    var history: [HistoryEntry] = {
 244        guard let data = UserDefaults.standard.data(forKey: historyKey),
 245              let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else {
 246            return []
 247        }
 248        return entries
 249    }()
 250
 251    var trimmedDomain: String {
 252        domain
 253            .trimmingCharacters(in: .whitespacesAndNewlines)
 254            .replacingOccurrences(of: "https://", with: "")
 255            .replacingOccurrences(of: "http://", with: "")
 256            .components(separatedBy: "/").first ?? ""
 257    }
 258
 259    var resultsLoaded: Bool {
 260        hasRun &&
 261            !dnsLoading &&
 262            !availabilityLoading &&
 263            !suggestionsLoading &&
 264            !sslLoading &&
 265            !hstsLoading &&
 266            !httpHeadersLoading &&
 267            !reachabilityLoading &&
 268            !ipGeolocationLoading &&
 269            !emailSecurityLoading &&
 270            !ptrLoading &&
 271            !redirectChainLoading &&
 272            !portScanLoading &&
 273            !customPortScanLoading
 274    }
 275
 276    var isCloudflareProxied: Bool {
 277        httpHeaders.contains { $0.name.lowercased() == "cf-ray" }
 278    }
 279
 280    var isCurrentDomainSaved: Bool {
 281        !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() })
 282    }
 283
 284    var sortedTrackedDomains: [TrackedDomain] {
 285        trackedDomains.sorted {
 286            if $0.isPinned != $1.isPinned {
 287                return $0.isPinned && !$1.isPinned
 288            }
 289            if $0.updatedAt != $1.updatedAt {
 290                return $0.updatedAt > $1.updatedAt
 291            }
 292            return $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending
 293        }
 294    }
 295
 296    var currentTrackedDomain: TrackedDomain? {
 297        guard !searchedDomain.isEmpty else { return nil }
 298        return trackedDomain(for: searchedDomain)
 299    }
 300
 301    var isCurrentDomainTracked: Bool {
 302        currentTrackedDomain != nil
 303    }
 304
 305    var trackingLimitMessage: String? {
 306        guard currentTrackedDomain == nil else { return nil }
 307        guard !PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else { return nil }
 308        return "Free version supports up to 3 tracked domains. More tracked domains will be available in a future Pro upgrade."
 309    }
 310
 311    var canTrackCurrentDomain: Bool {
 312        currentTrackedDomain != nil || PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count)
 313    }
 314
 315    var resolverDisplayName: String {
 316        DNSLookupService.currentResolverDisplayName()
 317    }
 318
 319    var resolverURLString: String {
 320        DNSLookupService.currentResolverURLString()
 321    }
 322
 323    var allPortScanResults: [PortScanResult] {
 324        (portScanResults + customPortResults).sorted {
 325            if $0.kind == $1.kind {
 326                return $0.port < $1.port
 327            }
 328            return $0.kind == .standard
 329        }
 330    }
 331
 332    var currentSnapshot: LookupSnapshot {
 333        LookupSnapshot(
 334            historyEntryID: nil,
 335            domain: searchedDomain,
 336            timestamp: Date(),
 337            trackedDomainID: currentTrackedDomain?.id,
 338            resolverDisplayName: resolverDisplayName,
 339            resolverURLString: resolverURLString,
 340            totalLookupDurationMs: lastLookupDurationMs,
 341            dnsSections: dnsSections,
 342            dnsError: dnsError,
 343            availabilityResult: availabilityResult,
 344            suggestions: suggestions,
 345            sslInfo: sslInfo,
 346            sslError: sslError,
 347            hstsPreloaded: hstsPreloaded,
 348            httpHeaders: httpHeaders,
 349            httpSecurityGrade: httpSecurityGrade,
 350            httpStatusCode: httpStatusCode,
 351            httpResponseTimeMs: httpResponseTimeMs,
 352            httpProtocol: httpProtocol,
 353            http3Advertised: http3Advertised,
 354            httpHeadersError: httpHeadersError,
 355            reachabilityResults: reachabilityResults,
 356            reachabilityError: reachabilityError,
 357            ipGeolocation: ipGeolocation,
 358            ipGeolocationError: ipGeolocationError,
 359            emailSecurity: emailSecurity,
 360            emailSecurityError: emailSecurityError,
 361            ptrRecord: ptrRecord,
 362            ptrError: ptrError,
 363            redirectChain: redirectChain,
 364            redirectChainError: redirectChainError,
 365            portScanResults: allPortScanResults,
 366            portScanError: combinedPortScanError,
 367            changeSummary: currentChangeSummary,
 368            isLive: true
 369        )
 370    }
 371
 372    var summaryFields: [SummaryFieldViewData] {
 373        Self.summaryFields(from: currentSnapshot)
 374    }
 375
 376    var domainRows: [InfoRowViewData] {
 377        Self.domainRows(from: currentSnapshot)
 378    }
 379
 380    var dnsRows: [DNSRecordSectionViewData] {
 381        Self.dnsRows(from: currentSnapshot)
 382    }
 383
 384    var suggestionRows: [DomainSuggestionViewData] {
 385        Self.suggestionRows(from: currentSnapshot)
 386    }
 387
 388    var dnssecLabel: String? {
 389        Self.dnssecLabel(from: currentSnapshot)
 390    }
 391
 392    var ptrMessage: SectionMessageViewData? {
 393        Self.ptrMessage(from: currentSnapshot)
 394    }
 395
 396    var webCertificateRows: [InfoRowViewData] {
 397        Self.webCertificateRows(from: currentSnapshot)
 398    }
 399
 400    var webResponseRows: [InfoRowViewData] {
 401        Self.webResponseRows(from: currentSnapshot)
 402    }
 403
 404    var redirectRows: [RedirectHopViewData] {
 405        Self.redirectRows(from: currentSnapshot)
 406    }
 407
 408    var emailRows: [EmailRowViewData] {
 409        Self.emailRows(from: currentSnapshot)
 410    }
 411
 412    var reachabilityRows: [ReachabilityRowViewData] {
 413        Self.reachabilityRows(from: currentSnapshot)
 414    }
 415
 416    var locationRows: [InfoRowViewData] {
 417        Self.locationRows(from: currentSnapshot)
 418    }
 419
 420    var standardPortRows: [PortScanRowViewData] {
 421        Self.portRows(from: currentSnapshot, kind: .standard)
 422    }
 423
 424    var customPortRows: [PortScanRowViewData] {
 425        Self.portRows(from: currentSnapshot, kind: .custom)
 426    }
 427
 428    var combinedPortScanError: String? {
 429        [portScanError, customPortScanError].compactMap { $0 }.joined(separator: "\n").nilIfEmpty
 430    }
 431
 432    func toggleSavedDomain() {
 433        if isCurrentDomainSaved {
 434            savedDomains.removeAll { $0.lowercased() == searchedDomain.lowercased() }
 435        } else {
 436            savedDomains.append(searchedDomain)
 437        }
 438        UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
 439    }
 440
 441    func removeSavedDomains(at offsets: IndexSet) {
 442        savedDomains.remove(atOffsets: offsets)
 443        UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
 444    }
 445
 446    @discardableResult
 447    func trackCurrentDomain() -> Bool {
 448        guard !searchedDomain.isEmpty else { return false }
 449        return trackDomain(domain: searchedDomain, availabilityStatus: availabilityResult?.status)
 450    }
 451
 452    @discardableResult
 453    func trackDomain(domain: String, availabilityStatus: DomainAvailabilityStatus?) -> Bool {
 454        let normalizedDomain = normalizedDomain(domain)
 455        guard !normalizedDomain.isEmpty else { return false }
 456
 457        if trackedDomain(for: normalizedDomain) != nil {
 458            return true
 459        }
 460
 461        guard PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else {
 462            return false
 463        }
 464
 465        trackedDomains.insert(
 466            TrackedDomain(
 467                domain: normalizedDomain,
 468                createdAt: Date(),
 469                updatedAt: Date(),
 470                lastKnownAvailability: availabilityStatus
 471            ),
 472            at: 0
 473        )
 474        persistTrackedDomains()
 475        linkTrackedDomainHistory(for: normalizedDomain)
 476        return true
 477    }
 478
 479    func refreshTrackedDomain(_ trackedDomain: TrackedDomain) {
 480        refreshingTrackedDomainID = trackedDomain.id
 481        domain = trackedDomain.domain
 482        run()
 483    }
 484
 485    func rerunInspection(for trackedDomain: TrackedDomain) {
 486        domain = trackedDomain.domain
 487        run()
 488        rerunNavigationToken = UUID()
 489    }
 490
 491    func deleteTrackedDomains(at offsets: IndexSet) {
 492        let ids = offsets.map { sortedTrackedDomains[$0].id }
 493        trackedDomains.removeAll { ids.contains($0.id) }
 494        history.indices.forEach { index in
 495            if let trackedDomainID = history[index].trackedDomainID, ids.contains(trackedDomainID) {
 496                history[index].trackedDomainID = nil
 497            }
 498        }
 499        persistTrackedDomains()
 500        persistHistory()
 501    }
 502
 503    func deleteTrackedDomain(_ trackedDomain: TrackedDomain) {
 504        trackedDomains.removeAll { $0.id == trackedDomain.id }
 505        history.indices.forEach { index in
 506            if history[index].trackedDomainID == trackedDomain.id {
 507                history[index].trackedDomainID = nil
 508            }
 509        }
 510        persistTrackedDomains()
 511        persistHistory()
 512    }
 513
 514    func togglePinned(for trackedDomain: TrackedDomain) {
 515        guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
 516        trackedDomains[index].isPinned.toggle()
 517        persistTrackedDomains()
 518    }
 519
 520    func updateNote(_ note: String, for trackedDomain: TrackedDomain) {
 521        guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
 522        trackedDomains[index].note = note.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
 523        persistTrackedDomains()
 524    }
 525
 526    func removeHistoryEntries(at offsets: IndexSet) {
 527        history.remove(atOffsets: offsets)
 528        persistHistory()
 529    }
 530
 531    func clearHistory() {
 532        history.removeAll()
 533        persistHistory()
 534    }
 535
 536    func clearRecentSearches() {
 537        recentSearches.removeAll()
 538        UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey)
 539    }
 540
 541    func rerunLookup(from entry: HistoryEntry) {
 542        UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey)
 543        domain = entry.domain
 544        run()
 545        rerunNavigationToken = UUID()
 546    }
 547
 548    func reset() {
 549        lookupTask?.cancel()
 550        customPortScanTask?.cancel()
 551        hasRun = false
 552        searchedDomain = ""
 553        lastLookupDurationMs = nil
 554        currentDiffSections = []
 555        currentChangeSummary = nil
 556        refreshingTrackedDomainID = nil
 557        clearLookupState()
 558    }
 559
 560    func run() {
 561        let target = trimmedDomain
 562        guard !target.isEmpty else { return }
 563
 564        lookupTask?.cancel()
 565        customPortScanTask?.cancel()
 566
 567        let lookupID = UUID()
 568        activeLookupID = lookupID
 569        lookupStartedAt = Date()
 570        lastLookupDurationMs = nil
 571        addRecentSearch(target)
 572        searchedDomain = target
 573        hasRun = true
 574        currentDiffSections = []
 575        currentChangeSummary = nil
 576        clearLookupState()
 577        setAllLoadingStates(true)
 578        customPortScanLoading = false
 579
 580        lookupTask = Task { [weak self] in
 581            guard let self else { return }
 582            await self.performLookup(domain: target, lookupID: lookupID)
 583        }
 584    }
 585
 586    func runCustomPortScan(ports: [UInt16]) async {
 587        guard !searchedDomain.isEmpty else {
 588            customPortScanError = "Run a domain lookup first"
 589            return
 590        }
 591
 592        guard !ports.isEmpty else {
 593            customPortScanError = "Enter at least one valid port"
 594            customPortResults = []
 595            return
 596        }
 597
 598        customPortScanTask?.cancel()
 599        let domain = searchedDomain
 600        let lookupID = activeLookupID
 601
 602        customPortScanLoading = true
 603        customPortScanError = nil
 604        customPortResults = []
 605
 606        customPortScanTask = Task { [weak self] in
 607            guard let self else { return }
 608            let result = await PortScanService.scanPorts(domain: domain, ports: ports, timeout: 3.0)
 609            guard !Task.isCancelled, self.isCurrentLookup(lookupID) else { return }
 610            self.applyCustomPortResult(result)
 611        }
 612    }
 613
 614    func exportText() -> String {
 615        Self.formatExportText(
 616            from: currentSnapshot,
 617            trackedDomain: currentTrackedDomain,
 618            changeSummary: currentChangeSummary,
 619            diffSections: currentDiffSections
 620        )
 621    }
 622
 623    private func performLookup(domain: String, lookupID: UUID) async {
 624        await withTaskGroup(of: Void.self) { group in
 625            group.addTask { await self.runDNS(domain: domain, lookupID: lookupID) }
 626            group.addTask { await self.runAvailability(domain: domain, lookupID: lookupID) }
 627            group.addTask { await self.runSSL(domain: domain, lookupID: lookupID) }
 628            group.addTask { await self.runHSTSPreload(domain: domain, lookupID: lookupID) }
 629            group.addTask { await self.runHTTPHeaders(domain: domain, lookupID: lookupID) }
 630            group.addTask { await self.runReachability(domain: domain, lookupID: lookupID) }
 631            group.addTask { await self.runRedirectChain(domain: domain, lookupID: lookupID) }
 632            group.addTask { await self.runPortScan(domain: domain, lookupID: lookupID) }
 633        }
 634
 635        guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
 636
 637        let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? []
 638        let primaryIP = primaryIPAddress(from: dnsSections)
 639
 640        await withTaskGroup(of: Void.self) { group in
 641            group.addTask { await self.runEmailSecurity(domain: domain, txtRecords: txtRecords, lookupID: lookupID) }
 642            if let primaryIP {
 643                group.addTask { await self.runReverseDNS(ip: primaryIP, lookupID: lookupID) }
 644                group.addTask { await self.runIPGeolocation(ip: primaryIP, lookupID: lookupID) }
 645            } else {
 646                group.addTask { await self.finishDependentWithoutPrimaryIP(lookupID: lookupID) }
 647            }
 648        }
 649
 650        guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
 651
 652        if availabilityResult?.status == .registered {
 653            await runSuggestions(domain: domain, lookupID: lookupID)
 654        } else {
 655            suggestions = []
 656            suggestionsLoading = false
 657        }
 658
 659        guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
 660        lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) }
 661        saveHistoryEntry(replaceLatest: false)
 662        refreshingTrackedDomainID = nil
 663    }
 664
 665    private func runDNS(domain: String, lookupID: UUID) async {
 666        let result = await DNSLookupService.lookupAll(domain: domain)
 667        guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
 668        switch result {
 669        case let .success(sections):
 670            dnsSections = sections
 671            dnsError = nil
 672        case let .empty(message):
 673            dnsSections = []
 674            dnsError = message
 675        case let .error(message):
 676            dnsSections = []
 677            dnsError = message
 678        }
 679        dnsLoading = false
 680    }
 681
 682    private func runAvailability(domain: String, lookupID: UUID) async {
 683        let result = await DomainAvailabilityService.check(domain: domain)
 684        guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
 685        availabilityResult = result
 686        availabilityLoading = false
 687        updateTrackedDomainAvailability(for: result.domain, status: result.status)
 688    }
 689
 690    private func runSSL(domain: String, lookupID: UUID) async {
 691        let result = await SSLCheckService.check(domain: domain)
 692        guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
 693        switch result {
 694        case let .success(info):
 695            sslInfo = info
 696            sslError = nil
 697        case let .empty(message):
 698            sslInfo = nil
 699            sslError = message
 700        case let .error(message):
 701            sslInfo = nil
 702            sslError = message
 703        }
 704        sslLoading = false
 705    }
 706
 707    private func runHSTSPreload(domain: String, lookupID: UUID) async {
 708        let result = await SSLCheckService.checkHSTSPreload(domain: domain)
 709        guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
 710        hstsPreloaded = result
 711        hstsLoading = false
 712    }
 713
 714    private func runHTTPHeaders(domain: String, lookupID: UUID) async {
 715        let result = await HTTPHeadersService.fetch(domain: domain)
 716        guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
 717        switch result {
 718        case let .success(headersResult):
 719            httpHeaders = headersResult.headers
 720            httpSecurityGrade = HTTPSecurityGrade.grade(for: headersResult.headers).rawValue
 721            httpStatusCode = headersResult.statusCode
 722            httpResponseTimeMs = headersResult.responseTimeMs
 723            httpProtocol = headersResult.httpProtocol
 724            http3Advertised = headersResult.http3Advertised
 725            httpHeadersError = nil
 726        case let .empty(message):
 727            httpHeaders = []
 728            httpSecurityGrade = nil
 729            httpStatusCode = nil
 730            httpResponseTimeMs = nil
 731            httpProtocol = nil
 732            http3Advertised = false
 733            httpHeadersError = message
 734        case let .error(message):
 735            httpHeaders = []
 736            httpSecurityGrade = nil
 737            httpStatusCode = nil
 738            httpResponseTimeMs = nil
 739            httpProtocol = nil
 740            http3Advertised = false
 741            httpHeadersError = message
 742        }
 743        httpHeadersLoading = false
 744    }
 745
 746    private func runReachability(domain: String, lookupID: UUID) async {
 747        let result = await ReachabilityService.checkAll(domain: domain)
 748        guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
 749        switch result {
 750        case let .success(results):
 751            reachabilityResults = results
 752            reachabilityError = nil
 753        case let .empty(message):
 754            reachabilityResults = []
 755            reachabilityError = message
 756        case let .error(message):
 757            reachabilityResults = []
 758            reachabilityError = message
 759        }
 760        reachabilityLoading = false
 761    }
 762
 763    private func runEmailSecurity(domain: String, txtRecords: [DNSRecord], lookupID: UUID) async {
 764        let result = await EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords)
 765        guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
 766        switch result {
 767        case let .success(emailResult):
 768            emailSecurity = emailResult
 769            emailSecurityError = nil
 770        case let .empty(message):
 771            emailSecurity = nil
 772            emailSecurityError = message
 773        case let .error(message):
 774            emailSecurity = nil
 775            emailSecurityError = message
 776        }
 777        emailSecurityLoading = false
 778    }
 779
 780    private func runReverseDNS(ip: String, lookupID: UUID) async {
 781        let result = await ReverseDNSService.lookup(ip: ip, resolverURLString: resolverURLString)
 782        guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
 783        switch result {
 784        case let .success(record):
 785            ptrRecord = record
 786            ptrError = nil
 787        case let .empty(message):
 788            ptrRecord = nil
 789            ptrError = message
 790        case let .error(message):
 791            ptrRecord = nil
 792            ptrError = message
 793        }
 794        ptrLoading = false
 795    }
 796
 797    private func runRedirectChain(domain: String, lookupID: UUID) async {
 798        let result = await RedirectChainService.trace(domain: domain)
 799        guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
 800        switch result {
 801        case let .success(hops):
 802            redirectChain = hops
 803            redirectChainError = nil
 804        case let .empty(message):
 805            redirectChain = []
 806            redirectChainError = message
 807        case let .error(message):
 808            redirectChain = []
 809            redirectChainError = message
 810        }
 811        redirectChainLoading = false
 812    }
 813
 814    private func runPortScan(domain: String, lookupID: UUID) async {
 815        let result = await PortScanService.scanAll(domain: domain)
 816        switch result {
 817        case let .success(results):
 818            let enrichedResults = await enrichOpenPortBanners(in: results, domain: domain)
 819            guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
 820            portScanResults = enrichedResults
 821            portScanError = nil
 822        case let .empty(message):
 823            guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
 824            portScanResults = []
 825            portScanError = message
 826        case let .error(message):
 827            guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
 828            portScanResults = []
 829            portScanError = message
 830        }
 831        portScanLoading = false
 832    }
 833
 834    private func runIPGeolocation(ip: String, lookupID: UUID) async {
 835        let result = await IPGeolocationService.lookup(ip: ip)
 836        guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
 837        switch result {
 838        case let .success(geolocation):
 839            ipGeolocation = geolocation
 840            ipGeolocationError = nil
 841        case let .empty(message):
 842            ipGeolocation = nil
 843            ipGeolocationError = message
 844        case let .error(message):
 845            ipGeolocation = nil
 846            ipGeolocationError = message
 847        }
 848        ipGeolocationLoading = false
 849    }
 850
 851    private func finishDependentWithoutPrimaryIP(lookupID: UUID) async {
 852        guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
 853        ptrLoading = false
 854        ptrError = "No A record available"
 855        ipGeolocationLoading = false
 856        ipGeolocationError = "No A record available"
 857    }
 858
 859    private func runSuggestions(domain: String, lookupID: UUID) async {
 860        let results = await DomainAvailabilityService.suggestions(for: domain)
 861        guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
 862        suggestions = results
 863        suggestionsLoading = false
 864    }
 865
 866    private func applyCustomPortResult(_ result: ServiceResult<[PortScanResult]>) {
 867        switch result {
 868        case let .success(results):
 869            customPortResults = results
 870            customPortScanError = nil
 871            saveHistoryEntry(replaceLatest: true)
 872        case let .empty(message):
 873            customPortResults = []
 874            customPortScanError = message
 875        case let .error(message):
 876            customPortResults = []
 877            customPortScanError = message
 878        }
 879        customPortScanLoading = false
 880    }
 881
 882    private func enrichOpenPortBanners(in results: [PortScanResult], domain: String) async -> [PortScanResult] {
 883        let banners = await withTaskGroup(of: (UInt16, String?).self, returning: [UInt16: String].self) { group in
 884            for result in results where result.open {
 885                group.addTask {
 886                    let banner = await PortScanService.grabBanner(host: domain, port: result.port)
 887                    return (result.port, banner)
 888                }
 889            }
 890
 891            var collected: [UInt16: String] = [:]
 892            for await (port, banner) in group {
 893                if let banner {
 894                    collected[port] = banner
 895                }
 896            }
 897            return collected
 898        }
 899
 900        return results.map { result in
 901            var updated = result
 902            updated.banner = banners[result.port]
 903            return updated
 904        }
 905    }
 906
 907    private func saveHistoryEntry(replaceLatest: Bool) {
 908        guard !searchedDomain.isEmpty else { return }
 909
 910        let trackedDomainID = trackedDomain(for: searchedDomain)?.id
 911        let timestamp = Date()
 912        let snapshot = currentSnapshot
 913        let previousSnapshot = previousSnapshot(for: searchedDomain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest)
 914        let changeSummary = previousSnapshot.map { DomainDiffService.summary(from: $0, to: snapshot, generatedAt: timestamp) }
 915
 916        currentChangeSummary = changeSummary
 917        currentDiffSections = previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? []
 918
 919        let entry = HistoryEntry(
 920            domain: searchedDomain,
 921            timestamp: timestamp,
 922            trackedDomainID: trackedDomainID,
 923            dnsSections: dnsSections,
 924            sslInfo: sslInfo,
 925            httpHeaders: httpHeaders,
 926            reachabilityResults: reachabilityResults,
 927            ipGeolocation: ipGeolocation,
 928            emailSecurity: emailSecurity,
 929            mtaSts: emailSecurity?.mtaSts,
 930            ptrRecord: ptrRecord,
 931            redirectChain: redirectChain,
 932            portScanResults: allPortScanResults,
 933            hstsPreloaded: hstsPreloaded,
 934            availabilityResult: availabilityResult,
 935            suggestions: suggestions,
 936            resolverDisplayName: resolverDisplayName,
 937            resolverURLString: resolverURLString,
 938            totalLookupDurationMs: lastLookupDurationMs,
 939            primaryIP: Self.primaryIPAddress(from: snapshot),
 940            finalRedirectURL: Self.finalRedirectTarget(from: snapshot),
 941            tlsStatusSummary: Self.httpsSummary(from: snapshot),
 942            emailSecuritySummary: Self.emailSummary(from: snapshot),
 943            httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
 944            changeSummary: changeSummary,
 945            sslError: sslError,
 946            httpHeadersError: httpHeadersError,
 947            reachabilityError: reachabilityError,
 948            ipGeolocationError: ipGeolocationError,
 949            emailSecurityError: emailSecurityError,
 950            ptrError: ptrError,
 951            redirectChainError: redirectChainError,
 952            portScanError: combinedPortScanError
 953        )
 954
 955        if replaceLatest, !history.isEmpty, history[0].domain.caseInsensitiveCompare(searchedDomain) == .orderedSame {
 956            history[0] = entry
 957        } else {
 958            history.insert(entry, at: 0)
 959            if history.count > Self.maxHistory {
 960                history = Array(history.prefix(Self.maxHistory))
 961            }
 962        }
 963
 964        updateTrackedDomainSnapshotMetadata(
 965            domain: searchedDomain,
 966            snapshotID: entry.id,
 967            availabilityStatus: availabilityResult?.status,
 968            updatedAt: timestamp,
 969            changeSummary: changeSummary
 970        )
 971        persistHistory()
 972    }
 973
 974    private func persistHistory() {
 975        if let data = try? JSONEncoder().encode(history) {
 976            UserDefaults.standard.set(data, forKey: Self.historyKey)
 977        }
 978    }
 979
 980    private func persistTrackedDomains() {
 981        if let data = try? JSONEncoder().encode(trackedDomains) {
 982            UserDefaults.standard.set(data, forKey: Self.trackedDomainsKey)
 983        }
 984    }
 985
 986    private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) {
 987        guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
 988            return
 989        }
 990        trackedDomains[index].lastKnownAvailability = status
 991        persistTrackedDomains()
 992    }
 993
 994    private func updateTrackedDomainSnapshotMetadata(
 995        domain: String,
 996        snapshotID: UUID,
 997        availabilityStatus: DomainAvailabilityStatus?,
 998        updatedAt: Date,
 999        changeSummary: DomainChangeSummary?
1000    ) {
1001        guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
1002            return
1003        }
1004        trackedDomains[index].lastSnapshotID = snapshotID
1005        trackedDomains[index].lastKnownAvailability = availabilityStatus
1006        trackedDomains[index].updatedAt = updatedAt
1007        trackedDomains[index].lastChangeSummary = changeSummary
1008        persistTrackedDomains()
1009    }
1010
1011    private func previousSnapshot(for domain: String, trackedDomainID: UUID?, replacingLatest: Bool) -> LookupSnapshot? {
1012        let matchingEntries = history.filter { entry in
1013            if let trackedDomainID {
1014                return entry.trackedDomainID == trackedDomainID
1015            }
1016            return entry.domain.caseInsensitiveCompare(domain) == .orderedSame
1017        }
1018
1019        if replacingLatest {
1020            return matchingEntries.dropFirst().first?.snapshot
1021        }
1022        return matchingEntries.first?.snapshot
1023    }
1024
1025    private func trackedDomain(for domain: String) -> TrackedDomain? {
1026        trackedDomains.first { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
1027    }
1028
1029    private func normalizedDomain(_ domain: String) -> String {
1030        domain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
1031    }
1032
1033    private func linkTrackedDomainHistory(for domain: String) {
1034        guard let trackedDomain = trackedDomain(for: domain) else { return }
1035        var didChange = false
1036
1037        for index in history.indices where history[index].domain.caseInsensitiveCompare(domain) == .orderedSame {
1038            if history[index].trackedDomainID != trackedDomain.id {
1039                history[index].trackedDomainID = trackedDomain.id
1040                didChange = true
1041            }
1042        }
1043
1044        if didChange {
1045            persistHistory()
1046        }
1047
1048        if let latestEntry = history.first(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }),
1049           let trackedIndex = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) {
1050            trackedDomains[trackedIndex].lastSnapshotID = latestEntry.id
1051            trackedDomains[trackedIndex].lastChangeSummary = latestEntry.changeSummary
1052            trackedDomains[trackedIndex].lastKnownAvailability = latestEntry.availabilityResult?.status
1053            trackedDomains[trackedIndex].updatedAt = latestEntry.timestamp
1054            persistTrackedDomains()
1055        }
1056    }
1057
1058    private func addRecentSearch(_ domain: String) {
1059        recentSearches.removeAll { $0.lowercased() == domain.lowercased() }
1060        recentSearches.insert(domain, at: 0)
1061        if recentSearches.count > Self.maxRecent {
1062            recentSearches = Array(recentSearches.prefix(Self.maxRecent))
1063        }
1064        UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey)
1065    }
1066
1067    private func clearLookupState() {
1068        dnsSections = []
1069        dnsError = nil
1070        dnsLoading = false
1071        availabilityResult = nil
1072        availabilityLoading = false
1073        suggestions = []
1074        suggestionsLoading = false
1075        sslInfo = nil
1076        sslError = nil
1077        sslLoading = false
1078        hstsPreloaded = nil
1079        hstsLoading = false
1080        httpHeaders = []
1081        httpSecurityGrade = nil
1082        httpStatusCode = nil
1083        httpResponseTimeMs = nil
1084        httpProtocol = nil
1085        http3Advertised = false
1086        httpHeadersError = nil
1087        httpHeadersLoading = false
1088        reachabilityResults = []
1089        reachabilityError = nil
1090        reachabilityLoading = false
1091        ipGeolocation = nil
1092        ipGeolocationError = nil
1093        ipGeolocationLoading = false
1094        emailSecurity = nil
1095        emailSecurityError = nil
1096        emailSecurityLoading = false
1097        ptrRecord = nil
1098        ptrError = nil
1099        ptrLoading = false
1100        redirectChain = []
1101        redirectChainError = nil
1102        redirectChainLoading = false
1103        portScanResults = []
1104        portScanError = nil
1105        portScanLoading = false
1106        customPortResults = []
1107        customPortScanError = nil
1108        customPortScanLoading = false
1109    }
1110
1111    private func setAllLoadingStates(_ loading: Bool) {
1112        dnsLoading = loading
1113        availabilityLoading = loading
1114        suggestionsLoading = loading
1115        sslLoading = loading
1116        hstsLoading = loading
1117        httpHeadersLoading = loading
1118        reachabilityLoading = loading
1119        ipGeolocationLoading = loading
1120        emailSecurityLoading = loading
1121        ptrLoading = loading
1122        redirectChainLoading = loading
1123        portScanLoading = loading
1124    }
1125
1126    private func primaryIPAddress(from sections: [DNSSection]) -> String? {
1127        sections.first(where: { $0.recordType == .A })?.records.first?.value
1128    }
1129
1130    private func isCurrentLookup(_ lookupID: UUID) -> Bool {
1131        activeLookupID == lookupID
1132    }
1133
1134    func recentSnapshots(for trackedDomain: TrackedDomain, limit: Int = 6) -> [HistoryEntry] {
1135        history
1136            .filter { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }
1137            .sorted { $0.timestamp > $1.timestamp }
1138            .prefix(limit)
1139            .map { $0 }
1140    }
1141
1142    func diffSectionsForLatestSnapshots(of trackedDomain: TrackedDomain) -> [DomainDiffSection] {
1143        let snapshots = recentSnapshots(for: trackedDomain, limit: 2)
1144        guard snapshots.count == 2 else { return [] }
1145        return DomainDiffService.diff(from: snapshots[1].snapshot, to: snapshots[0].snapshot)
1146    }
1147
1148    func latestChangeSummary(for trackedDomain: TrackedDomain) -> DomainChangeSummary? {
1149        trackedDomain.lastChangeSummary ?? recentSnapshots(for: trackedDomain, limit: 1).first?.changeSummary
1150    }
1151
1152    func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? {
1153        let siblings = history.filter { candidate in
1154            if let trackedDomainID = entry.trackedDomainID {
1155                return candidate.trackedDomainID == trackedDomainID && candidate.id != entry.id
1156            }
1157            return candidate.domain.caseInsensitiveCompare(entry.domain) == .orderedSame && candidate.id != entry.id
1158        }
1159        .sorted { $0.timestamp > $1.timestamp }
1160
1161        return siblings.first?.snapshot
1162    }
1163
1164    private static func loadTrackedDomains() -> [TrackedDomain] {
1165        let defaults = UserDefaults.standard
1166        let decoder = JSONDecoder()
1167
1168        if let data = defaults.data(forKey: trackedDomainsKey),
1169           let domains = try? decoder.decode([TrackedDomain].self, from: data) {
1170            return deduplicatedTrackedDomains(domains)
1171        }
1172
1173        if let legacyData = defaults.data(forKey: legacyWatchedDomainsKey),
1174           let legacyDomains = try? decoder.decode([WatchedDomain].self, from: legacyData) {
1175            return deduplicatedTrackedDomains(
1176                legacyDomains.map {
1177                    TrackedDomain(
1178                        id: $0.id,
1179                        domain: $0.domain.lowercased(),
1180                        createdAt: $0.createdAt,
1181                        updatedAt: $0.createdAt,
1182                        lastKnownAvailability: $0.lastKnownAvailability
1183                    )
1184                }
1185            )
1186        }
1187
1188        return []
1189    }
1190
1191    private static func deduplicatedTrackedDomains(_ domains: [TrackedDomain]) -> [TrackedDomain] {
1192        var seen = Set<String>()
1193        return domains.filter { domain in
1194            let key = domain.domain.lowercased()
1195            return seen.insert(key).inserted
1196        }
1197    }
1198
1199    static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] {
1200        [
1201            SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary),
1202            SummaryFieldViewData(label: "Primary IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary),
1203            SummaryFieldViewData(label: "HTTPS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)),
1204            SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary),
1205            SummaryFieldViewData(label: "Email", value: emailSummary(from: snapshot), tone: .secondary)
1206        ]
1207    }
1208
1209    static func domainRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
1210        var rows = [
1211            InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary),
1212            InfoRowViewData(label: "Resolver", value: snapshot.resolverDisplayName, tone: .secondary),
1213            InfoRowViewData(label: snapshot.isLive ? "Result" : "Snapshot", value: snapshot.isLive ? "Live" : "Snapshot", tone: snapshot.isLive ? .success : .warning),
1214            InfoRowViewData(label: "Lookup Duration", value: durationLabel(snapshot.totalLookupDurationMs), tone: .secondary)
1215        ]
1216        rows.insert(
1217            InfoRowViewData(
1218                label: "Availability",
1219                value: availabilityLabel(snapshot.availabilityResult?.status),
1220                tone: availabilityTone(snapshot.availabilityResult?.status)
1221            ),
1222            at: 1
1223        )
1224        return rows
1225    }
1226
1227    static func suggestionRows(from snapshot: LookupSnapshot) -> [DomainSuggestionViewData] {
1228        snapshot.suggestions.map {
1229            DomainSuggestionViewData(
1230                id: $0.id,
1231                domain: $0.domain,
1232                status: availabilityLabel($0.status),
1233                tone: availabilityTone($0.status)
1234            )
1235        }
1236    }
1237
1238    static func dnsRows(from snapshot: LookupSnapshot) -> [DNSRecordSectionViewData] {
1239        snapshot.dnsSections.map { section in
1240            DNSRecordSectionViewData(
1241                title: section.recordType.rawValue,
1242                rows: section.records.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary) },
1243                wildcardRows: section.wildcardRecords.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary) },
1244                wildcardTitle: section.wildcardRecords.isEmpty ? nil : "*.\(snapshot.domain)",
1245                message: section.error.map { SectionMessageViewData(text: $0, isError: true) } ??
1246                    ((section.records.isEmpty && section.wildcardRecords.isEmpty) ? SectionMessageViewData(text: "No records found", isError: false) : nil)
1247            )
1248        }
1249    }
1250
1251    static func dnssecLabel(from snapshot: LookupSnapshot) -> String? {
1252        guard let signed = snapshot.dnsSections.compactMap(\.dnssecSigned).first else { return nil }
1253        return "Resolver-reported DNSSEC (not full validation): \(signed ? "Yes" : "No")"
1254    }
1255
1256    static func ptrMessage(from snapshot: LookupSnapshot) -> SectionMessageViewData? {
1257        if let ptrRecord = snapshot.ptrRecord {
1258            return SectionMessageViewData(text: ptrRecord, isError: false)
1259        }
1260        if let ptrError = snapshot.ptrError {
1261            return SectionMessageViewData(text: ptrError, isError: ptrError != "No A record available" && ptrError != "No PTR record found")
1262        }
1263        return nil
1264    }
1265
1266    static func webCertificateRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
1267        guard let sslInfo = snapshot.sslInfo else { return [] }
1268        var rows = [
1269            InfoRowViewData(label: "Common Name", value: sslInfo.commonName, tone: .primary),
1270            InfoRowViewData(label: "Issuer", value: sslInfo.issuer, tone: .primary),
1271            InfoRowViewData(label: "Valid From", value: certificateDateFormatter.string(from: sslInfo.validFrom), tone: .secondary),
1272            InfoRowViewData(label: "Valid Until", value: certificateDateFormatter.string(from: sslInfo.validUntil), tone: .secondary),
1273            InfoRowViewData(label: "Days Until Expiry", value: "\(sslInfo.daysUntilExpiry)", tone: sslInfo.daysUntilExpiry < 30 ? .failure : (sslInfo.daysUntilExpiry < 60 ? .warning : .success)),
1274            InfoRowViewData(label: "Chain Depth", value: "\(sslInfo.chainDepth)", tone: .secondary)
1275        ]
1276        if let tlsVersion = sslInfo.tlsVersion {
1277            rows.append(InfoRowViewData(label: "TLS Version", value: tlsVersion, tone: .secondary))
1278        }
1279        if let cipherSuite = sslInfo.cipherSuite {
1280            rows.append(InfoRowViewData(label: "Cipher Suite", value: cipherSuite, tone: .secondary))
1281        }
1282        if let hstsPreloaded = snapshot.hstsPreloaded {
1283            rows.append(InfoRowViewData(label: "HSTS Preload", value: hstsPreloaded ? "Preloaded" : "Not preloaded", tone: hstsPreloaded ? .success : .secondary))
1284        }
1285        return rows
1286    }
1287
1288    static func webResponseRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
1289        var rows: [InfoRowViewData] = []
1290        if let httpStatusCode = snapshot.httpStatusCode {
1291            rows.append(InfoRowViewData(label: "Status", value: "\(httpStatusCode)", tone: .primary))
1292        }
1293        if let httpResponseTimeMs = snapshot.httpResponseTimeMs {
1294            rows.append(InfoRowViewData(label: "Response Time", value: "\(httpResponseTimeMs) ms", tone: .secondary))
1295        }
1296        if let httpProtocol = snapshot.httpProtocol {
1297            rows.append(InfoRowViewData(label: "Protocol", value: httpProtocol, tone: .secondary))
1298        }
1299        if let httpSecurityGrade = snapshot.httpSecurityGrade {
1300            rows.append(InfoRowViewData(label: "Security Grade", value: httpSecurityGrade, tone: securityGradeTone(httpSecurityGrade)))
1301        }
1302        if snapshot.http3Advertised {
1303            rows.append(InfoRowViewData(label: "HTTP/3", value: "Advertised", tone: .secondary))
1304        }
1305        return rows
1306    }
1307
1308    static func redirectRows(from snapshot: LookupSnapshot) -> [RedirectHopViewData] {
1309        snapshot.redirectChain.map {
1310            RedirectHopViewData(
1311                stepLabel: "\($0.stepNumber)",
1312                statusCode: "\($0.statusCode)",
1313                url: $0.url,
1314                isFinal: $0.isFinal
1315            )
1316        }
1317    }
1318
1319    static func emailRows(from snapshot: LookupSnapshot) -> [EmailRowViewData] {
1320        guard let emailSecurity = snapshot.emailSecurity else { return [] }
1321        return [
1322            EmailRowViewData(label: "SPF", status: emailSecurity.spf.found ? "Present" : "Missing", statusTone: emailSecurity.spf.found ? .success : .warning, detail: emailSecurity.spf.value ?? "No record found", auxiliaryDetail: nil),
1323            EmailRowViewData(label: "DMARC", status: emailSecurity.dmarc.found ? "Present" : "Missing", statusTone: emailSecurity.dmarc.found ? .success : .warning, detail: emailSecurity.dmarc.value ?? "No record found", auxiliaryDetail: nil),
1324            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)" }),
1325            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),
1326            EmailRowViewData(label: "BIMI", status: emailSecurity.bimi.found ? "Present" : "Missing", statusTone: emailSecurity.bimi.found ? .success : .warning, detail: emailSecurity.bimi.value ?? "No record found", auxiliaryDetail: nil)
1327        ]
1328    }
1329
1330    static func reachabilityRows(from snapshot: LookupSnapshot) -> [ReachabilityRowViewData] {
1331        snapshot.reachabilityResults.map {
1332            ReachabilityRowViewData(
1333                portLabel: "Port \($0.port)",
1334                latencyLabel: $0.latencyMs.map { "\($0) ms" } ?? "",
1335                statusLabel: $0.reachable ? "Reachable" : "Unreachable",
1336                statusTone: $0.reachable ? .success : .failure
1337            )
1338        }
1339    }
1340
1341    static func locationRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
1342        guard let ipGeolocation = snapshot.ipGeolocation else { return [] }
1343        var rows = [InfoRowViewData(label: "IP", value: ipGeolocation.ip, tone: .primary)]
1344        if let org = ipGeolocation.org {
1345            rows.append(InfoRowViewData(label: "Org / ISP", value: org, tone: .secondary))
1346        }
1347        let location = [ipGeolocation.city, ipGeolocation.region, ipGeolocation.country_name].compactMap { $0 }.joined(separator: ", ")
1348        if !location.isEmpty {
1349            rows.append(InfoRowViewData(label: "Location", value: location, tone: .secondary))
1350        }
1351        if let latitude = ipGeolocation.latitude, let longitude = ipGeolocation.longitude {
1352            rows.append(InfoRowViewData(label: "Coordinates", value: "\(latitude), \(longitude)", tone: .secondary))
1353        }
1354        return rows
1355    }
1356
1357    static func portRows(from snapshot: LookupSnapshot, kind: PortScanKind) -> [PortScanRowViewData] {
1358        snapshot.portScanResults
1359            .filter { $0.kind == kind }
1360            .map {
1361                PortScanRowViewData(
1362                    portLabel: "\($0.port)",
1363                    service: $0.service,
1364                    statusLabel: $0.open ? "Open" : "Closed",
1365                    statusTone: $0.open ? .success : .secondary,
1366                    banner: $0.banner,
1367                    durationLabel: $0.durationMs.map { "\($0) ms" }
1368                )
1369            }
1370    }
1371
1372    static func formatExportText(
1373        from snapshot: LookupSnapshot,
1374        trackedDomain: TrackedDomain?,
1375        changeSummary: DomainChangeSummary?,
1376        diffSections: [DomainDiffSection]
1377    ) -> String {
1378        let exportDateFormatter = DateFormatter()
1379        exportDateFormatter.dateFormat = "yyyy-MM-dd HH:mm"
1380
1381        var lines: [String] = [
1382            "DomainDig Export",
1383            "Domain: \(snapshot.domain)",
1384            "Date: \(exportDateFormatter.string(from: snapshot.timestamp))",
1385            "Mode: \(snapshot.isLive ? "Live" : "Snapshot")",
1386            "Resolver: \(snapshot.resolverDisplayName)",
1387            "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))",
1388            "Tracked: \(trackedDomain == nil ? "No" : "Yes")"
1389        ]
1390
1391        if let note = trackedDomain?.note?.nilIfEmpty {
1392            lines.append("Tracking Note: \(note)")
1393        }
1394
1395        func appendSection(_ title: String, body: () -> Void) {
1396            lines.append("")
1397            lines.append(title)
1398            lines.append(String(repeating: "-", count: title.count))
1399            body()
1400        }
1401
1402        appendSection("Summary") {
1403            for item in summaryFields(from: snapshot) {
1404                lines.append("  \(item.label): \(item.value)")
1405            }
1406            if let changeSummary {
1407                lines.append("  Change Status: \(changeSummary.hasChanges ? "Changed" : "Unchanged")")
1408                lines.append("  Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))")
1409            }
1410        }
1411
1412        appendSection("Tracking") {
1413            if let trackedDomain {
1414                lines.append("  Pinned: \(trackedDomain.isPinned ? "Yes" : "No")")
1415                lines.append("  Last Refresh: \(exportDateFormatter.string(from: trackedDomain.updatedAt))")
1416                lines.append("  Last Known Availability: \(availabilityLabel(trackedDomain.lastKnownAvailability))")
1417                if let note = trackedDomain.note?.nilIfEmpty {
1418                    lines.append("  Note: \(note)")
1419                }
1420            } else {
1421                lines.append("  This domain is not currently tracked.")
1422            }
1423        }
1424
1425        appendSection("Diff Summary") {
1426            if diffSections.isEmpty {
1427                lines.append("  No comparison available")
1428            } else {
1429                for section in diffSections where section.items.contains(where: { $0.changeType != .unchanged }) {
1430                    lines.append("  \(section.title)")
1431                    for item in section.items where item.changeType != .unchanged {
1432                        lines.append("    \(item.label): \(item.oldValue ?? "None") -> \(item.newValue ?? "None")")
1433                    }
1434                }
1435            }
1436        }
1437
1438        appendSection("Domain") {
1439            for row in domainRows(from: snapshot) {
1440                lines.append("  \(row.label): \(row.value)")
1441            }
1442            if snapshot.suggestions.isEmpty {
1443                lines.append("  Suggestions: None")
1444            } else {
1445                lines.append("  Suggestions:")
1446                for suggestion in snapshot.suggestions {
1447                    lines.append("    \(suggestion.domain): \(availabilityLabel(suggestion.status))")
1448                }
1449            }
1450        }
1451
1452        appendSection("DNS") {
1453            if let dnsError = snapshot.dnsError {
1454                lines.append("  Error: \(dnsError)")
1455            }
1456            if let dnssecLabel = dnssecLabel(from: snapshot) {
1457                lines.append("  \(dnssecLabel)")
1458            }
1459            for section in dnsRows(from: snapshot) {
1460                lines.append("  \(section.title)")
1461                if let message = section.message {
1462                    lines.append("    \(message.isError ? "Error" : "Info"): \(message.text)")
1463                }
1464                for row in section.rows {
1465                    lines.append("    \(row.value) (\(row.label))")
1466                }
1467                if let wildcardTitle = section.wildcardTitle {
1468                    lines.append("    \(wildcardTitle)")
1469                    for row in section.wildcardRows {
1470                        lines.append("      \(row.value) (\(row.label))")
1471                    }
1472                }
1473            }
1474            if let ptrRecord = snapshot.ptrRecord {
1475                lines.append("  PTR: \(ptrRecord)")
1476            } else if let ptrError = snapshot.ptrError {
1477                lines.append("  PTR Error: \(ptrError)")
1478            }
1479        }
1480
1481        appendSection("Web") {
1482            if let sslError = snapshot.sslError {
1483                lines.append("  TLS Error: \(sslError)")
1484            } else {
1485                for row in webCertificateRows(from: snapshot) {
1486                    lines.append("  \(row.label): \(row.value)")
1487                }
1488            }
1489
1490            if let httpHeadersError = snapshot.httpHeadersError {
1491                lines.append("  Headers Error: \(httpHeadersError)")
1492            } else {
1493                for row in webResponseRows(from: snapshot) {
1494                    lines.append("  \(row.label): \(row.value)")
1495                }
1496                if snapshot.httpHeaders.isEmpty {
1497                    lines.append("  Headers: No headers returned")
1498                } else {
1499                    lines.append("  Headers:")
1500                    for header in snapshot.httpHeaders {
1501                        lines.append("    \(header.name): \(header.value)")
1502                    }
1503                }
1504            }
1505
1506            if let redirectChainError = snapshot.redirectChainError {
1507                lines.append("  Redirect Error: \(redirectChainError)")
1508            } else if snapshot.redirectChain.isEmpty {
1509                lines.append("  Redirects: No redirect data available")
1510            } else {
1511                lines.append("  Redirects:")
1512                for hop in redirectRows(from: snapshot) {
1513                    lines.append("    \(hop.stepLabel). \(hop.statusCode) \(hop.url)\(hop.isFinal ? " (final)" : "")")
1514                }
1515            }
1516        }
1517
1518        appendSection("Email") {
1519            if let emailSecurityError = snapshot.emailSecurityError {
1520                lines.append("  Error: \(emailSecurityError)")
1521            } else if emailRows(from: snapshot).isEmpty {
1522                lines.append("  No email security records found")
1523            } else {
1524                for row in emailRows(from: snapshot) {
1525                    lines.append("  \(row.label): \(row.status)")
1526                    lines.append("    \(row.detail)")
1527                    if let auxiliaryDetail = row.auxiliaryDetail {
1528                        lines.append("    \(auxiliaryDetail)")
1529                    }
1530                }
1531            }
1532        }
1533
1534        appendSection("Network") {
1535            if let reachabilityError = snapshot.reachabilityError {
1536                lines.append("  Reachability Error: \(reachabilityError)")
1537            } else if reachabilityRows(from: snapshot).isEmpty {
1538                lines.append("  Reachability: No results")
1539            } else {
1540                lines.append("  Reachability:")
1541                for row in reachabilityRows(from: snapshot) {
1542                    lines.append("    \(row.portLabel): \(row.statusLabel) \(row.latencyLabel)")
1543                }
1544            }
1545
1546            if let ipGeolocationError = snapshot.ipGeolocationError, snapshot.ipGeolocation == nil {
1547                lines.append("  Location Error: \(ipGeolocationError)")
1548            } else if locationRows(from: snapshot).isEmpty {
1549                lines.append("  Location: No data")
1550            } else {
1551                lines.append("  Location:")
1552                for row in locationRows(from: snapshot) {
1553                    lines.append("    \(row.label): \(row.value)")
1554                }
1555            }
1556
1557            if let portScanError = snapshot.portScanError, snapshot.portScanResults.isEmpty {
1558                lines.append("  Port Scan Error: \(portScanError)")
1559            }
1560
1561            lines.append("  Standard Ports:")
1562            let standardRows = portRows(from: snapshot, kind: .standard)
1563            if standardRows.isEmpty {
1564                lines.append("    No results")
1565            } else {
1566                for row in standardRows {
1567                    lines.append("    \(row.portLabel) \(row.service): \(row.statusLabel)\(row.durationLabel.map { " \($0)" } ?? "")")
1568                    if let banner = row.banner {
1569                        lines.append("      Banner: \(banner)")
1570                    }
1571                }
1572            }
1573
1574            lines.append("  Custom Ports:")
1575            let customRows = portRows(from: snapshot, kind: .custom)
1576            if customRows.isEmpty {
1577                lines.append("    No results")
1578            } else {
1579                for row in customRows {
1580                    lines.append("    \(row.portLabel) \(row.service): \(row.statusLabel)\(row.durationLabel.map { " \($0)" } ?? "")")
1581                    if let banner = row.banner {
1582                        lines.append("      Banner: \(banner)")
1583                    }
1584                }
1585            }
1586        }
1587
1588        return lines.joined(separator: "\n")
1589    }
1590
1591    private static func primaryIPAddress(from snapshot: LookupSnapshot) -> String? {
1592        snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
1593    }
1594
1595    private static func finalRedirectTarget(from snapshot: LookupSnapshot) -> String? {
1596        snapshot.redirectChain.last?.url
1597    }
1598
1599    private static func httpsSummary(from snapshot: LookupSnapshot) -> String {
1600        if snapshot.sslInfo != nil {
1601            return "Valid"
1602        }
1603        if let sslError = snapshot.sslError {
1604            return sslError.localizedCaseInsensitiveContains("certificate") ? "Invalid" : "Failed"
1605        }
1606        return "Unavailable"
1607    }
1608
1609    private static func httpsSummaryTone(from snapshot: LookupSnapshot) -> ResultTone {
1610        if snapshot.sslInfo != nil {
1611            return .success
1612        }
1613        return snapshot.sslError == nil ? .secondary : .failure
1614    }
1615
1616    private static func emailSummary(from snapshot: LookupSnapshot) -> String {
1617        guard let emailSecurity = snapshot.emailSecurity else {
1618            return snapshot.emailSecurityError ?? "Unavailable"
1619        }
1620        return "SPF \(emailSecurity.spf.found ? "Yes" : "No") / DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")"
1621    }
1622
1623    private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String {
1624        switch status {
1625        case .available:
1626            return "Available"
1627        case .registered:
1628            return "Registered"
1629        case .unknown, .none:
1630            return "Unknown"
1631        }
1632    }
1633
1634    private static func availabilityTone(_ status: DomainAvailabilityStatus?) -> ResultTone {
1635        switch status {
1636        case .available:
1637            return .success
1638        case .registered:
1639            return .warning
1640        case .unknown, .none:
1641            return .secondary
1642        }
1643    }
1644
1645    private static func securityGradeTone(_ grade: String) -> ResultTone {
1646        switch grade {
1647        case "A", "B":
1648            return .success
1649        case "C":
1650            return .warning
1651        case "D", "F":
1652            return .failure
1653        default:
1654            return .secondary
1655        }
1656    }
1657
1658    private static func durationLabel(_ durationMs: Int?) -> String {
1659        durationMs.map { "\($0) ms" } ?? "Unavailable"
1660    }
1661
1662    private static let certificateDateFormatter: DateFormatter = {
1663        let formatter = DateFormatter()
1664        formatter.dateStyle = .medium
1665        formatter.timeStyle = .short
1666        return formatter
1667    }()
1668}
1669
1670private extension String {
1671    var nonEmpty: String? {
1672        isEmpty ? nil : self
1673    }
1674
1675    var nilIfEmpty: String? {
1676        isEmpty ? nil : self
1677    }
1678}