krz/domain-dig

an ios app for DNS & SSL analysis

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

v4.8.1: DomainDig/DiffService.swift · raw

  1import Foundation
  2
  3enum DiffChangeType: String, Codable {
  4    case added
  5    case removed
  6    case changed
  7    case unchanged
  8
  9    var marker: String {
 10        switch self {
 11        case .added:
 12            return "+"
 13        case .removed:
 14            return "-"
 15        case .changed:
 16            return "~"
 17        case .unchanged:
 18            return "="
 19        }
 20    }
 21
 22    var title: String {
 23        switch self {
 24        case .added:
 25            return "Added"
 26        case .removed:
 27            return "Removed"
 28        case .changed:
 29            return "Changed"
 30        case .unchanged:
 31            return "Unchanged"
 32        }
 33    }
 34}
 35
 36struct DiffItem: Identifiable, Equatable, Codable {
 37    let id: String
 38    let label: String
 39    let changeType: DiffChangeType
 40    let oldValue: String?
 41    let newValue: String?
 42    let severity: ChangeSeverity
 43
 44    init(
 45        id: String,
 46        label: String,
 47        changeType: DiffChangeType,
 48        oldValue: String?,
 49        newValue: String?,
 50        severity: ChangeSeverity
 51    ) {
 52        self.id = id
 53        self.label = label
 54        self.changeType = changeType
 55        self.oldValue = oldValue
 56        self.newValue = newValue
 57        self.severity = severity
 58    }
 59
 60    var hasChanges: Bool {
 61        changeType != .unchanged
 62    }
 63}
 64
 65struct DiffSection: Identifiable, Equatable, Codable {
 66    let id: String
 67    let title: String
 68    let items: [DiffItem]
 69
 70    var hasChanges: Bool {
 71        items.contains(where: \.hasChanges)
 72    }
 73
 74    var severity: ChangeSeverity {
 75        items.map(\.severity).max() ?? .low
 76    }
 77
 78    var changeCount: Int {
 79        items.filter(\.hasChanges).count
 80    }
 81}
 82
 83struct DomainDiff: Identifiable, Equatable, Codable {
 84    let domain: String
 85    let fromTimestamp: Date
 86    let toTimestamp: Date
 87    let sections: [DiffSection]
 88    let changedSectionIDs: [String]
 89    let changedSectionTitles: [String]
 90    let contextNote: String?
 91
 92    var id: String {
 93        "\(domain)-\(fromTimestamp.timeIntervalSince1970)-\(toTimestamp.timeIntervalSince1970)"
 94    }
 95
 96    var changeCount: Int {
 97        sections.reduce(0) { $0 + $1.changeCount }
 98    }
 99
100    var severity: ChangeSeverity {
101        sections.map(\.severity).max() ?? .low
102    }
103}
104
105typealias DomainDiffItem = DiffItem
106typealias DomainDiffSection = DiffSection
107
108/// Result of comparing two distinct domains' latest reports side by side, as
109/// opposed to `DomainDiff`, which compares the same domain across time.
110struct DomainComparisonResult: Identifiable, Equatable {
111    let domainA: String
112    let domainB: String
113    let generatedAt: Date
114    let sections: [DiffSection]
115    let contextNote: String?
116
117    var id: String {
118        "\(domainA)-\(domainB)-\(generatedAt.timeIntervalSince1970)"
119    }
120
121    var changedSections: [DiffSection] {
122        sections.filter(\.hasChanges)
123    }
124
125    var changeCount: Int {
126        sections.reduce(0) { $0 + $1.changeCount }
127    }
128
129    var severity: ChangeSeverity {
130        sections.map(\.severity).max() ?? .low
131    }
132}
133
134enum DiffService {
135    static func compare(from oldReport: DomainReport, to newReport: DomainReport) -> DomainDiff {
136        let sections = [
137            availabilitySection(from: oldReport, to: newReport),
138            ownershipSection(from: oldReport, to: newReport),
139            dnsSection(from: oldReport, to: newReport),
140            webSection(from: oldReport, to: newReport),
141            emailSection(from: oldReport, to: newReport),
142            networkSection(from: oldReport, to: newReport),
143            subdomainsSection(from: oldReport, to: newReport),
144            intelligenceSection(from: oldReport, to: newReport),
145            riskSection(from: oldReport, to: newReport)
146        ]
147
148        let changedSections = sections.filter(\.hasChanges)
149        return DomainDiff(
150            domain: newReport.domain,
151            fromTimestamp: oldReport.timestamp,
152            toTimestamp: newReport.timestamp,
153            sections: sections,
154            changedSectionIDs: changedSections.map(\.id),
155            changedSectionTitles: changedSections.map(\.title),
156            contextNote: comparisonContextNote(from: oldReport, to: newReport)
157        )
158    }
159
160    static func compare(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiff {
161        let builder = DomainReportBuilder()
162        let oldReport = builder.build(from: oldSnapshot, deriveChangeSummary: false)
163        let newReport = builder.build(from: newSnapshot, previousSnapshot: oldSnapshot, deriveChangeSummary: false)
164        return compare(from: oldReport, to: newReport)
165    }
166
167    static func summary(
168        from oldSnapshot: LookupSnapshot,
169        to newSnapshot: LookupSnapshot,
170        generatedAt: Date = Date(),
171        riskAssessment: DomainRiskAssessment? = nil,
172        insights: [String]? = nil
173    ) -> DomainChangeSummary {
174        let diff = compare(from: oldSnapshot, to: newSnapshot)
175        let changedItems = diff.sections.flatMap(\.items).filter(\.hasChanges)
176        let highlights = diff.changedSectionTitles
177        let severity = changedItems.map(\.severity).max() ?? .low
178        let message = summaryMessage(from: highlights, changeCount: changedItems.count)
179        let observedFacts = changedItems.prefix(4).map { item in
180            "\(item.label): \(item.oldValue ?? "none") -> \(item.newValue ?? "none")"
181        }
182
183        let analysis = DomainInsightEngine.analyze(snapshot: newSnapshot, previousSnapshot: oldSnapshot)
184        let currentRiskAssessment = riskAssessment ?? analysis.riskAssessment
185        let currentInsights = insights ?? analysis.insights
186        let previousRiskScore = DomainInsightEngine.analyze(snapshot: oldSnapshot).riskAssessment.score
187        let riskScoreDelta = currentRiskAssessment.score - previousRiskScore
188        let impactClassification = DomainInsightEngine.impactClassification(
189            severity: severity,
190            riskDelta: riskScoreDelta,
191            changedSections: highlights
192        )
193
194        return DomainChangeSummary(
195            hasChanges: !changedItems.isEmpty,
196            changedSections: highlights,
197            message: message,
198            severity: severity,
199            impactClassification: impactClassification,
200            generatedAt: generatedAt,
201            observedFacts: observedFacts,
202            inferredConclusions: highlights.isEmpty ? [] : [message],
203            contextNote: diff.contextNote,
204            riskAssessment: currentRiskAssessment,
205            insights: currentInsights,
206            riskScoreDelta: riskScoreDelta
207        )
208    }
209
210    /// Compares two distinct domains' reports section by section. Reuses the same
211    /// field-level diff logic as time-based comparison; the two reports are simply
212    /// unrelated domains rather than the same domain at different times.
213    static func compare(domainA: DomainReport, domainB: DomainReport) -> DomainComparisonResult {
214        let sections = [
215            availabilitySection(from: domainA, to: domainB),
216            ownershipSection(from: domainA, to: domainB),
217            dnsSection(from: domainA, to: domainB),
218            webSection(from: domainA, to: domainB),
219            emailSection(from: domainA, to: domainB),
220            networkSection(from: domainA, to: domainB),
221            subdomainsSection(from: domainA, to: domainB),
222            intelligenceSection(from: domainA, to: domainB),
223            riskSection(from: domainA, to: domainB)
224        ]
225
226        return DomainComparisonResult(
227            domainA: domainA.domain,
228            domainB: domainB.domain,
229            generatedAt: Date(),
230            sections: sections,
231            contextNote: comparisonContextNote(from: domainA, to: domainB)
232        )
233    }
234
235    static func comparisonContextNote(from oldReport: DomainReport, to newReport: DomainReport) -> String? {
236        var notes: [String] = []
237        if oldReport.resolverURLString != newReport.resolverURLString {
238            notes.append("Compared snapshots used different DNS resolvers.")
239        }
240        if oldReport.resultSource != newReport.resultSource {
241            notes.append("Compared snapshots came from different collection modes.")
242        }
243        return notes.isEmpty ? nil : notes.joined(separator: " ")
244    }
245
246    static func comparisonContextNote(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> String? {
247        comparisonContextNote(
248            from: DomainReportBuilder().build(from: oldSnapshot, deriveChangeSummary: false),
249            to: DomainReportBuilder().build(from: newSnapshot, previousSnapshot: oldSnapshot, deriveChangeSummary: false)
250        )
251    }
252
253    static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel {
254        guard let days = snapshot.sslInfo?.daysUntilExpiry else {
255            return .none
256        }
257        if days < 14 {
258            return .critical
259        }
260        if days < 30 {
261            return .warning
262        }
263        return .none
264    }
265
266    private static func availabilitySection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
267        DiffSection(
268            id: "availability",
269            title: "Domain / Availability",
270            items: [
271                compare(id: "domain", label: "Domain", oldValue: oldReport.domain, newValue: newReport.domain, severity: .low),
272                compare(
273                    id: "availability",
274                    label: "Availability",
275                    oldValue: availabilityLabel(oldReport.availability),
276                    newValue: availabilityLabel(newReport.availability),
277                    severity: .high
278                ),
279                compare(id: "primary-ip", label: "Primary IP", oldValue: oldReport.dns.primaryIP, newValue: newReport.dns.primaryIP, severity: .high),
280                compare(
281                    id: "tls-status",
282                    label: "TLS Status",
283                    oldValue: oldReport.web.tlsStatus,
284                    newValue: newReport.web.tlsStatus,
285                    severity: .medium
286                )
287            ].compactMap { $0 }
288        )
289    }
290
291    private static func ownershipSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
292        DiffSection(
293            id: "ownership",
294            title: "Ownership",
295            items: [
296                compare(id: "registrar", label: "Registrar", oldValue: oldReport.ownership?.registrar, newValue: newReport.ownership?.registrar, severity: .high),
297                compare(id: "registrant", label: "Registrant", oldValue: oldReport.ownership?.registrant, newValue: newReport.ownership?.registrant, severity: .medium),
298                compare(
299                    id: "ownership-created",
300                    label: "Registration Date",
301                    oldValue: ownershipDateLabel(oldReport.ownership?.createdDate),
302                    newValue: ownershipDateLabel(newReport.ownership?.createdDate),
303                    severity: .low
304                ),
305                compare(
306                    id: "ownership-expires",
307                    label: "Expiration Date",
308                    oldValue: ownershipDateLabel(oldReport.ownership?.expirationDate),
309                    newValue: ownershipDateLabel(newReport.ownership?.expirationDate),
310                    severity: .medium
311                ),
312                compare(
313                    id: "ownership-status",
314                    label: "Status",
315                    oldValue: joined(oldReport.ownership?.status),
316                    newValue: joined(newReport.ownership?.status),
317                    severity: .low
318                ),
319                compare(
320                    id: "ownership-nameservers",
321                    label: "Nameservers",
322                    oldValue: joined(oldReport.ownership?.nameservers),
323                    newValue: joined(newReport.ownership?.nameservers),
324                    severity: .medium
325                ),
326                compare(id: "ownership-abuse", label: "Abuse Contact", oldValue: oldReport.ownership?.abuseEmail, newValue: newReport.ownership?.abuseEmail, severity: .low)
327            ].compactMap { $0 }
328        )
329    }
330
331    private static func dnsSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
332        let oldSections = Dictionary(uniqueKeysWithValues: oldReport.dns.recordSections.map { ($0.recordType, $0) })
333        let newSections = Dictionary(uniqueKeysWithValues: newReport.dns.recordSections.map { ($0.recordType, $0) })
334        let recordTypes = Set(oldSections.keys).union(newSections.keys).sorted { $0.rawValue < $1.rawValue }
335
336        var items: [DiffItem] = [
337            compare(id: "dnssec", label: "DNSSEC", oldValue: dnssecLabel(oldReport.dns.dnssecSigned), newValue: dnssecLabel(newReport.dns.dnssecSigned), severity: .medium),
338            compare(id: "ptr", label: "PTR", oldValue: oldReport.dns.ptrRecord, newValue: newReport.dns.ptrRecord, severity: .low)
339        ].compactMap { $0 }
340
341        for type in recordTypes {
342            items.append(
343                compare(
344                    id: "dns-\(type.rawValue.lowercased())-records",
345                    label: "\(type.rawValue) Records",
346                    oldValue: normalizedRecordValues(for: oldSections[type]),
347                    newValue: normalizedRecordValues(for: newSections[type]),
348                    severity: type == .A || type == .NS ? .high : .medium
349                ) ?? DiffItem(id: "", label: "", changeType: .unchanged, oldValue: nil, newValue: nil, severity: .low)
350            )
351            if let ttlChange = compare(
352                id: "dns-\(type.rawValue.lowercased())-ttl",
353                label: "\(type.rawValue) TTL",
354                oldValue: normalizedTTLValues(for: oldSections[type]),
355                newValue: normalizedTTLValues(for: newSections[type]),
356                severity: .low
357            ) {
358                items.append(ttlChange)
359            }
360        }
361
362        return DiffSection(
363            id: "dns",
364            title: "DNS",
365            items: items.filter { !$0.id.isEmpty }
366        )
367    }
368
369    private static func webSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
370        DiffSection(
371            id: "web",
372            title: "Web",
373            items: [
374                compare(id: "web-status", label: "HTTP Status", oldValue: oldReport.web.statusCode.map(String.init), newValue: newReport.web.statusCode.map(String.init), severity: .medium),
375                compare(id: "web-grade", label: "Security Grade", oldValue: oldReport.web.securityGrade, newValue: newReport.web.securityGrade, severity: .medium),
376                compare(id: "web-final-url", label: "Final URL", oldValue: oldReport.web.finalURL, newValue: newReport.web.finalURL, severity: .high),
377                compare(id: "web-tls-issuer", label: "TLS Issuer", oldValue: oldReport.web.tls?.issuer, newValue: newReport.web.tls?.issuer, severity: .medium),
378                compare(id: "web-tls-expiry", label: "TLS Expiration", oldValue: expirationLabel(oldReport.web.tls), newValue: expirationLabel(newReport.web.tls), severity: .medium),
379                compare(id: "web-headers", label: "Headers", oldValue: normalizedHeaders(oldReport.web.headers), newValue: normalizedHeaders(newReport.web.headers), severity: .low),
380                compare(id: "web-redirects", label: "Redirect Chain", oldValue: redirectChainSummary(oldReport.web.redirectChain), newValue: redirectChainSummary(newReport.web.redirectChain), severity: .medium)
381            ].compactMap { $0 }
382        )
383    }
384
385    private static func emailSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
386        DiffSection(
387            id: "email",
388            title: "Email Security",
389            items: [
390                compare(id: "email-summary", label: "Summary", oldValue: oldReport.email.summary, newValue: newReport.email.summary, severity: .medium),
391                compare(id: "email-grade", label: "Grade", oldValue: oldReport.email.grade?.rawValue, newValue: newReport.email.grade?.rawValue, severity: .medium),
392                compare(id: "email-spf", label: "SPF", oldValue: recordLabel(oldReport.email.records?.spf), newValue: recordLabel(newReport.email.records?.spf), severity: .medium),
393                compare(id: "email-dmarc", label: "DMARC", oldValue: recordLabel(oldReport.email.records?.dmarc), newValue: recordLabel(newReport.email.records?.dmarc), severity: .high),
394                compare(id: "email-dkim", label: "DKIM", oldValue: recordLabel(oldReport.email.records?.dkim), newValue: recordLabel(newReport.email.records?.dkim), severity: .medium),
395                compare(id: "email-bimi", label: "BIMI", oldValue: recordLabel(oldReport.email.records?.bimi), newValue: recordLabel(newReport.email.records?.bimi), severity: .low),
396                compare(id: "email-mta-sts", label: "MTA-STS", oldValue: mtaStsLabel(oldReport.email.records?.mtaSts), newValue: mtaStsLabel(newReport.email.records?.mtaSts), severity: .medium)
397            ].compactMap { $0 }
398        )
399    }
400
401    private static func networkSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
402        DiffSection(
403            id: "network",
404            title: "Network",
405            items: [
406                compare(id: "network-reachability", label: "Reachability", oldValue: oldReport.network.reachabilitySummary, newValue: newReport.network.reachabilitySummary, severity: .medium),
407                compare(id: "network-geolocation", label: "Geolocation", oldValue: oldReport.network.geolocationSummary, newValue: newReport.network.geolocationSummary, severity: .medium),
408                compare(id: "network-open-ports", label: "Open Ports", oldValue: joined(oldReport.network.openPorts.map(String.init)), newValue: joined(newReport.network.openPorts.map(String.init)), severity: .high),
409                compare(id: "network-port-scan", label: "Port Scan", oldValue: portScanSummary(oldReport.network.portScan), newValue: portScanSummary(newReport.network.portScan), severity: .medium)
410            ].compactMap { $0 }
411        )
412    }
413
414    private static func subdomainsSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
415        DiffSection(
416            id: "subdomains",
417            title: "Subdomains",
418            items: [
419                compare(id: "subdomains-primary", label: "Primary Subdomains", oldValue: joined(oldReport.subdomains), newValue: joined(newReport.subdomains), severity: .low),
420                compare(id: "subdomains-extended", label: "Extended Subdomains", oldValue: joined(oldReport.extendedSubdomains), newValue: joined(newReport.extendedSubdomains), severity: .low),
421                compare(id: "subdomains-groups", label: "Groups", oldValue: groupSummary(oldReport.subdomainGroups), newValue: groupSummary(newReport.subdomainGroups), severity: .low)
422            ].compactMap { $0 }
423        )
424    }
425
426    private static func riskSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
427        DiffSection(
428            id: "risk",
429            title: "Risk / Insights",
430            items: [
431                compare(id: "risk-score", label: "Risk Score", oldValue: "\(oldReport.riskAssessment.score)", newValue: "\(newReport.riskAssessment.score)", severity: .high),
432                compare(id: "risk-level", label: "Risk Level", oldValue: oldReport.riskAssessment.level.title, newValue: newReport.riskAssessment.level.title, severity: .high),
433                compare(id: "risk-factors", label: "Risk Factors", oldValue: joined(oldReport.riskAssessment.factors.map(\.description)), newValue: joined(newReport.riskAssessment.factors.map(\.description)), severity: .medium),
434                compare(id: "risk-insights", label: "Insights", oldValue: joined(oldReport.insights), newValue: joined(newReport.insights), severity: .medium)
435            ].compactMap { $0 }
436        )
437    }
438
439    private static func intelligenceSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
440        DiffSection(
441            id: "intelligence",
442            title: "Data+ Intelligence",
443            items: [
444                compare(id: "intel-provider", label: "Provider", oldValue: oldReport.inferredProvider?.name, newValue: newReport.inferredProvider?.name, severity: .medium),
445                compare(id: "intel-classification", label: "Classification", oldValue: oldReport.domainClassification?.kind.title, newValue: newReport.domainClassification?.kind.title, severity: .medium),
446                compare(id: "intel-hosting-history", label: "Hosting Transitions", oldValue: joined(oldReport.hostingTransitions.map(\.summary)), newValue: joined(newReport.hostingTransitions.map(\.summary)), severity: .medium),
447                compare(id: "intel-ownership-history", label: "Ownership Transitions", oldValue: joined(oldReport.ownershipTransitions.map(\.summary)), newValue: joined(newReport.ownershipTransitions.map(\.summary)), severity: .high),
448                compare(id: "intel-risk-signals", label: "Risk Signals", oldValue: joined(oldReport.riskSignals.map(\.title)), newValue: joined(newReport.riskSignals.map(\.title)), severity: .medium)
449            ].compactMap { $0 }
450        )
451    }
452
453    private static func compare(
454        id: String,
455        label: String,
456        oldValue: String?,
457        newValue: String?,
458        severity: ChangeSeverity
459    ) -> DiffItem? {
460        let oldValue = normalized(oldValue)
461        let newValue = normalized(newValue)
462
463        guard oldValue != nil || newValue != nil else {
464            return nil
465        }
466
467        let changeType: DiffChangeType
468        switch (oldValue?.lowercased(), newValue?.lowercased()) {
469        case let (old?, new?) where old == new:
470            changeType = .unchanged
471        case (nil, _?):
472            changeType = .added
473        case (_?, nil):
474            changeType = .removed
475        default:
476            changeType = .changed
477        }
478
479        return DiffItem(
480            id: id,
481            label: label,
482            changeType: changeType,
483            oldValue: oldValue,
484            newValue: newValue,
485            severity: severity
486        )
487    }
488
489    static func summaryMessage(from sectionTitles: [String], changeCount: Int) -> String {
490        guard !sectionTitles.isEmpty else {
491            return "No meaningful changes"
492        }
493        if sectionTitles.count == 1 {
494            return "\(sectionTitles[0]) changed"
495        }
496        return "\(sectionTitles[0]) and \(sectionTitles[1].lowercased()) changed (\(changeCount) items)"
497    }
498
499    private static func normalized(_ value: String?) -> String? {
500        guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
501            return nil
502        }
503        return value
504    }
505
506    private static func availabilityLabel(_ status: DomainAvailabilityStatus) -> String {
507        switch status {
508        case .available:
509            return "Available"
510        case .registered:
511            return "Registered"
512        case .unknown:
513            return "Unknown"
514        }
515    }
516
517    private static func ownershipDateLabel(_ date: Date?) -> String? {
518        date?.formatted(date: .abbreviated, time: .omitted)
519    }
520
521    private static func expirationLabel(_ certificate: SSLCertificateInfo?) -> String? {
522        guard let certificate else { return nil }
523        return "\(certificate.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(certificate.daysUntilExpiry)d)"
524    }
525
526    private static func joined(_ values: [String]?) -> String? {
527        guard let values else { return nil }
528        let normalizedValues = values
529            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
530            .filter { !$0.isEmpty }
531            .sorted()
532        return normalizedValues.isEmpty ? nil : normalizedValues.joined(separator: ", ")
533    }
534
535    private static func normalizedRecordValues(for section: DNSSection?) -> String? {
536        guard let section else { return nil }
537        let values = (section.records + section.wildcardRecords)
538            .map(\.value)
539            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
540            .sorted()
541        return values.isEmpty ? nil : values.joined(separator: ", ")
542    }
543
544    private static func normalizedTTLValues(for section: DNSSection?) -> String? {
545        guard let section else { return nil }
546        let values = (section.records + section.wildcardRecords)
547            .map { "\($0.value.lowercased()):\($0.ttl)" }
548            .sorted()
549        return values.isEmpty ? nil : values.joined(separator: ", ")
550    }
551
552    private static func normalizedHeaders(_ headers: [HTTPHeader]) -> String? {
553        let values = headers
554            .map { "\($0.name.lowercased()): \($0.value.trimmingCharacters(in: .whitespacesAndNewlines))" }
555            .sorted()
556        return values.isEmpty ? nil : values.joined(separator: " | ")
557    }
558
559    private static func redirectChainSummary(_ redirects: [RedirectHop]) -> String? {
560        let values = redirects.map { "\($0.statusCode) \($0.url)" }
561        return values.isEmpty ? nil : values.joined(separator: " -> ")
562    }
563
564    private static func portScanSummary(_ results: [PortScanResult]) -> String? {
565        let values = results
566            .sorted { $0.port < $1.port }
567            .map { "\($0.port):\($0.open ? "open" : "closed")" }
568        return values.isEmpty ? nil : values.joined(separator: ", ")
569    }
570
571    private static func groupSummary(_ groups: [SubdomainGroup]) -> String? {
572        joined(groups.map { "\($0.label): \($0.subdomains.count)" })
573    }
574
575    private static func recordLabel(_ record: EmailSecurityRecord?) -> String? {
576        guard let record else { return nil }
577        if record.found {
578            return record.value ?? "Present"
579        }
580        return "Missing"
581    }
582
583    private static func mtaStsLabel(_ result: MTASTSResult?) -> String? {
584        guard let result else { return nil }
585        guard result.txtFound else { return "Missing" }
586        return result.policyMode ?? "Present"
587    }
588
589    private static func dnssecLabel(_ value: Bool?) -> String? {
590        switch value {
591        case true:
592            return "Signed"
593        case false:
594            return "Unsigned"
595        case nil:
596            return nil
597        }
598    }
599}
600
601enum DomainDiffService {
602    static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] {
603        DiffService.compare(from: oldSnapshot, to: newSnapshot).sections
604    }
605
606    static func summary(
607        from oldSnapshot: LookupSnapshot,
608        to newSnapshot: LookupSnapshot,
609        generatedAt: Date = Date(),
610        riskAssessment: DomainRiskAssessment? = nil,
611        insights: [String]? = nil
612    ) -> DomainChangeSummary {
613        DiffService.summary(
614            from: oldSnapshot,
615            to: newSnapshot,
616            generatedAt: generatedAt,
617            riskAssessment: riskAssessment,
618            insights: insights
619        )
620    }
621
622    static func comparisonContextNote(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> String? {
623        DiffService.comparisonContextNote(from: oldSnapshot, to: newSnapshot)
624    }
625
626    static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel {
627        DiffService.certificateWarningLevel(for: snapshot)
628    }
629}