krz/domain-dig

an ios app for DNS & SSL analysis

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

v4.8.1: DomainReportExporter.swift · raw

  1import Foundation
  2
  3#if canImport(UIKit)
  4import UIKit
  5#endif
  6
  7enum DomainExportFormat: String, CaseIterable, Identifiable, Codable {
  8    case text = "txt"
  9    case csv = "csv"
 10    case json = "json"
 11    case markdown = "md"
 12    case pdf = "pdf"
 13
 14    var id: String { rawValue }
 15    var fileExtension: String { rawValue }
 16
 17    var title: String {
 18        switch self {
 19        case .text: return "TXT"
 20        case .csv: return "CSV"
 21        case .json: return "JSON"
 22        case .markdown: return "Markdown"
 23        case .pdf: return "PDF"
 24        }
 25    }
 26}
 27
 28enum DomainReportExporter {
 29    static func data(for report: DomainReport, format: DomainExportFormat) throws -> Data {
 30        switch format {
 31        case .text:
 32            return Data(text(for: report).utf8)
 33        case .csv:
 34            return Data(csv(for: [report]).utf8)
 35        case .json:
 36            return try jsonEncoder.encode(report)
 37        case .markdown:
 38            return Data(markdown(for: report).utf8)
 39        case .pdf:
 40            return pdfData(fromMarkdown: markdown(for: report))
 41        }
 42    }
 43
 44    static func data(for reports: [DomainReport], format: DomainExportFormat, title: String) throws -> Data {
 45        switch format {
 46        case .text:
 47            return Data(batchText(for: reports, title: title).utf8)
 48        case .csv:
 49            return Data(csv(for: reports).utf8)
 50        case .json:
 51            return try jsonEncoder.encode(reports)
 52        case .markdown:
 53            return Data(batchMarkdown(for: reports, title: title).utf8)
 54        case .pdf:
 55            return pdfData(fromMarkdown: batchMarkdown(for: reports, title: title))
 56        }
 57    }
 58
 59    /// Renders `text(for:)`'s content as Markdown: the leading title becomes an
 60    /// H1, `appendSection`'s "Title\n----" underlines become H2 headers, and
 61    /// other non-empty lines that aren't already list items become bullets.
 62    /// This reuses the exact same section content as the text export rather
 63    /// than re-deriving it, so the two formats never drift.
 64    static func markdown(for report: DomainReport) -> String {
 65        markdown(fromPlainText: text(for: report), title: "DomainDig Report")
 66    }
 67
 68    static func batchMarkdown(for reports: [DomainReport], title: String) -> String {
 69        markdown(fromPlainText: batchText(for: reports, title: title), title: title)
 70    }
 71
 72    private static func markdown(fromPlainText text: String, title: String) -> String {
 73        let lines = text.components(separatedBy: "\n")
 74        var output: [String] = ["# \(title)", ""]
 75        var index = 0
 76        while index < lines.count {
 77            let line = lines[index]
 78            if index == 0, line == title {
 79                index += 1
 80                if index < lines.count, isUnderline(lines[index], for: line) {
 81                    index += 1
 82                }
 83                continue
 84            }
 85            if index + 1 < lines.count, !line.isEmpty, isUnderline(lines[index + 1], for: line) {
 86                output.append("")
 87                output.append("## \(line)")
 88                index += 2
 89                continue
 90            }
 91            if isRule(line) {
 92                output.append("")
 93                output.append("---")
 94                index += 1
 95                continue
 96            }
 97            if line.isEmpty || line.hasPrefix("-") || line.hasPrefix("  ") {
 98                output.append(line)
 99            } else {
100                output.append("- \(line)")
101            }
102            index += 1
103        }
104        return output.joined(separator: "\n")
105    }
106
107    /// True when `line` is a run of `-` or `=` exactly as long as the heading it
108    /// underlines. `batchText` uses `=` for the document title, `appendSection`
109    /// uses `-` for section headers.
110    private static func isUnderline(_ line: String, for heading: String) -> Bool {
111        guard !heading.isEmpty else { return false }
112        return line == String(repeating: "-", count: heading.count)
113            || line == String(repeating: "=", count: heading.count)
114    }
115
116    /// True for a standalone divider not attached to a heading  `batchText`
117    /// emits a fixed 48-character `=` run between reports.
118    private static func isRule(_ line: String) -> Bool {
119        line.count >= 3 && (line.allSatisfy { $0 == "=" } || line.allSatisfy { $0 == "-" })
120    }
121
122    /// Renders Markdown as a simple monospaced multi-page PDF. Foundation-only
123    /// consumers (no UIKit available) get the Markdown bytes back instead.
124    static func pdfData(fromMarkdown markdown: String) -> Data {
125        #if canImport(UIKit)
126        let renderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: 612, height: 792))
127        return renderer.pdfData { context in
128            let lines = markdown.components(separatedBy: .newlines)
129            let paragraphStyle = NSMutableParagraphStyle()
130            paragraphStyle.lineBreakMode = .byWordWrapping
131            let attributes: [NSAttributedString.Key: Any] = [
132                .font: UIFont.monospacedSystemFont(ofSize: 11, weight: .regular),
133                .paragraphStyle: paragraphStyle
134            ]
135
136            var yOffset: CGFloat = 36
137            context.beginPage()
138
139            for line in lines {
140                if yOffset > 744 {
141                    context.beginPage()
142                    yOffset = 36
143                }
144
145                let renderedLine = NSString(string: line.isEmpty ? " " : line)
146                renderedLine.draw(
147                    in: CGRect(x: 36, y: yOffset, width: 540, height: 22),
148                    withAttributes: attributes
149                )
150                yOffset += 16
151            }
152        }
153        #else
154        return Data(markdown.utf8)
155        #endif
156    }
157
158    static func text(for report: DomainReport) -> String {
159        var lines = [
160            "DomainDig Report",
161            "Domain: \(report.domain)",
162            "Timestamp: \(textDateFormatter.string(from: report.timestamp))",
163            "App Version: \(report.metadata.appVersion)",
164            "Resolver: \(report.metadata.resolverDisplayName)",
165            "Resolver URL: \(report.metadata.resolverURLString)",
166            "Source: \(report.provenance.source.label)",
167            "Availability: \(availabilityLabel(report.availability))",
168            "Availability Confidence: \(report.availabilityConfidence?.title ?? "N/A")"
169        ]
170
171        if report.metadata.isPartialSnapshot {
172            lines.append("Snapshot Integrity: Partial snapshot")
173        }
174        if let auditNote = report.metadata.auditNote, !auditNote.isEmpty {
175            lines.append("Audit Note: \(auditNote)")
176        }
177        if !report.provenance.dataSources.isEmpty {
178            lines.append("Data Sources: \(report.provenance.dataSources.joined(separator: ", "))")
179        }
180        if !report.metadata.validationIssues.isEmpty {
181            lines.append("Validation: \(report.metadata.validationIssues.joined(separator: " | "))")
182        }
183        if let workflowContext = report.workflowContext {
184            lines.append("Workflow Context: \(workflowContext.workflowName ?? workflowContext.source)")
185        }
186
187        appendSection("Summary", to: &lines) {
188            [
189                "Primary IP: \(report.dns.primaryIP ?? "Unavailable")",
190                "Risk Score: \(report.riskAssessment.score) (\(report.riskAssessment.level.title))",
191                "TLS Status: \(report.web.tlsStatus)",
192                "HTTP: \(httpSummary(for: report))",
193                "Email: \(report.email.summary)",
194                "Subdomains: \(report.subdomains.count)",
195                "Extended Subdomains: \(report.extendedSubdomains.count)",
196                "External Price: \(report.domainPricing?.estimatedPrice ?? "Unavailable")",
197                "Reputation: \(report.reputation?.status.title ?? "Unavailable")"
198            ]
199        }
200
201        appendSection("Risk", to: &lines) {
202            var values = [
203                "Score: \(report.riskAssessment.score)",
204                "Level: \(report.riskAssessment.level.title)"
205            ]
206            if report.riskAssessment.factors.isEmpty {
207                values.append("Factors: None")
208            } else {
209                values.append("Factors:")
210                for factor in report.riskAssessment.factors {
211                    values.append("  [\(factor.impact.rawValue)] \(factor.description)")
212                }
213            }
214            return values
215        }
216
217        appendSection("Insights", to: &lines) {
218            report.insights.isEmpty ? ["No deterministic insights triggered"] : report.insights.map { "- \($0)" }
219        }
220
221        appendSection("Ownership", to: &lines) {
222            var ownershipLines = [
223                "Registrar: \(report.ownership?.registrar ?? "Unavailable")",
224                "Confidence: \(report.ownershipConfidence?.title ?? "N/A")",
225                "Created: \(ownershipDateLabel(report.ownership?.createdDate))",
226                "Expires: \(ownershipDateLabel(report.ownership?.expirationDate))",
227                "Registrant: \(report.ownership?.registrant ?? "Unavailable")",
228                "Nameservers: \(joined(report.ownership?.nameservers) ?? "Unavailable")",
229                "Status: \(joined(report.ownership?.status) ?? "Unavailable")",
230                "Abuse Contact: \(report.ownership?.abuseEmail ?? "Unavailable")"
231            ]
232            if let provenance = report.sectionProvenance[.ownership] {
233                ownershipLines.append("Provenance: \(provenanceLabel(provenance))")
234            }
235            if let error = report.dns.error, report.ownership == nil {
236                ownershipLines.append("Error: \(error)")
237            } else if let error = report.changeSummary?.message, report.ownership == nil, report.ownership == nil {
238                _ = error
239            }
240            return ownershipLines
241        }
242
243        appendSection("Ownership History", to: &lines) {
244            guard !report.ownershipHistory.isEmpty else {
245                return ["No ownership history available"]
246            }
247
248            return report.ownershipHistory.map { event in
249                [
250                    textDateFormatter.string(from: event.date),
251                    event.summary,
252                    "source=\(event.source)"
253                ].joined(separator: " | ")
254            }
255        }
256
257        appendSection("DNS", to: &lines) {
258            var dnsLines = [
259                "Lookup Duration: \(durationLabel(report.dns.lookupDurationMs))",
260                "Primary IP: \(report.dns.primaryIP ?? "Unavailable")",
261                "PTR: \(report.dns.ptrRecord ?? report.dns.ptrError ?? "Unavailable")",
262                "DNSSEC: \(dnssecLabel(report.dns.dnssecSigned))",
263                "Patterns: \(report.dns.patternSummary.patterns.joined(separator: " | ").nilIfEmpty ?? "None")"
264            ]
265            if let provenance = report.sectionProvenance[.dns] {
266                dnsLines.append("Provenance: \(provenanceLabel(provenance))")
267            }
268            if let error = report.dns.error {
269                dnsLines.append("Error: \(error)")
270            }
271            if report.dns.recordSections.isEmpty {
272                dnsLines.append("Records: None")
273            } else {
274                dnsLines.append("Records:")
275                for section in report.dns.recordSections {
276                    var seen: Set<String> = []
277                    let values = (section.records + section.wildcardRecords)
278                        .map(\.value)
279                        .filter { seen.insert($0).inserted }
280                    let renderedValues = values.isEmpty ? "None" : values.joined(separator: " | ")
281                    dnsLines.append("  \(section.recordType.rawValue): \(renderedValues)")
282                }
283            }
284            return dnsLines
285        }
286
287        appendSection("DNS History", to: &lines) {
288            guard !report.dnsHistory.isEmpty else {
289                return ["No DNS history available"]
290            }
291
292            return report.dnsHistory.map { event in
293                [
294                    textDateFormatter.string(from: event.date),
295                    event.summary,
296                    "A=\(event.aRecords.joined(separator: " | ").nilIfEmpty ?? "-")",
297                    "NS=\(event.nameservers.joined(separator: " | ").nilIfEmpty ?? "-")",
298                    "source=\(event.source)"
299                ].joined(separator: " | ")
300            }
301        }
302
303        appendSection("Web", to: &lines) {
304            var webLines = [
305                "TLS Status: \(report.web.tlsStatus)",
306                "TLS Grade: \(report.web.tlsGrade.rawValue)",
307                "TLS Highlights: \(report.web.tlsHighlights.joined(separator: " | "))",
308                "Certificate Warning: \(report.web.certificateWarningLevel.title)",
309                "Security Grade: \(report.web.securityGrade ?? "Unavailable")",
310                "HTTP Status: \(report.web.statusCode.map(String.init) ?? "Unavailable")",
311                "Protocol: \(report.web.httpProtocol ?? "Unavailable")",
312                "HTTP/3 Advertised: \(report.web.http3Advertised ? "Yes" : "No")",
313                "Final URL: \(report.web.finalURL ?? "Unavailable")",
314                "HSTS Preloaded: \(booleanLabel(report.web.hstsPreloaded))",
315                "Header Count: \(report.web.headerCount)"
316            ]
317            if let provenance = report.sectionProvenance[.ssl] {
318                webLines.append("TLS Provenance: \(provenanceLabel(provenance))")
319            }
320            if let provenance = report.sectionProvenance[.httpHeaders] {
321                webLines.append("HTTP Provenance: \(provenanceLabel(provenance))")
322            }
323            if let provenance = report.sectionProvenance[.redirectChain] {
324                webLines.append("Redirect Provenance: \(provenanceLabel(provenance))")
325            }
326            if let tlsError = report.web.tlsError {
327                webLines.append("TLS Error: \(tlsError)")
328            }
329            if let headersError = report.web.headersError {
330                webLines.append("Headers Error: \(headersError)")
331            }
332            if let redirectError = report.web.redirectError {
333                webLines.append("Redirect Error: \(redirectError)")
334            }
335            if !report.web.headers.isEmpty {
336                webLines.append("Headers:")
337                for header in report.web.headers {
338                    webLines.append("  \(header.name): \(header.value)")
339                }
340            }
341            if !report.web.redirectChain.isEmpty {
342                webLines.append("Redirect Chain:")
343                for hop in report.web.redirectChain {
344                    webLines.append("  \(hop.stepNumber). \(hop.statusCode) \(hop.url)\(hop.isFinal ? " (final)" : "")")
345                }
346            }
347            return webLines
348        }
349
350        appendSection("Email", to: &lines) {
351            var emailLines = [report.email.summary]
352            if let grade = report.email.grade {
353                emailLines.append("Grade: \(grade.rawValue)")
354            }
355            if !report.email.reasons.isEmpty {
356                emailLines.append("Why: \(report.email.reasons.joined(separator: " | "))")
357            }
358            emailLines.append("Confidence: \(report.emailConfidence?.title ?? "N/A")")
359            if let provenance = report.sectionProvenance[.emailSecurity] {
360                emailLines.append("Provenance: \(provenanceLabel(provenance))")
361            }
362            if let records = report.email.records {
363                emailLines.append("SPF: \(recordLabel(records.spf))")
364                emailLines.append("DMARC: \(recordLabel(records.dmarc))")
365                emailLines.append("DKIM: \(recordLabel(records.dkim))")
366                emailLines.append("BIMI: \(recordLabel(records.bimi))")
367                emailLines.append("MTA-STS: \(records.mtaSts?.txtFound == true ? records.mtaSts?.policyMode ?? "found" : "Unavailable")")
368            }
369            if let error = report.email.error {
370                emailLines.append("Error: \(error)")
371            }
372            return emailLines
373        }
374
375        appendSection("Network", to: &lines) {
376            var networkLines = [
377                "Reachability: \(report.network.reachabilitySummary)",
378                "Geolocation: \(report.network.geolocationSummary)",
379                "Geolocation Confidence: \(report.geolocationConfidence?.title ?? "N/A")",
380                "Open Ports: \(report.network.openPorts.map(String.init).joined(separator: ", ").nilIfEmpty ?? "None")"
381            ]
382            if let provenance = report.sectionProvenance[.ipGeolocation] {
383                networkLines.append("Geolocation Provenance: \(provenanceLabel(provenance))")
384            }
385            if let error = report.network.reachabilityError {
386                networkLines.append("Reachability Error: \(error)")
387            }
388            if let error = report.network.geolocationError {
389                networkLines.append("Geolocation Error: \(error)")
390            }
391            if let error = report.network.portScanError {
392                networkLines.append("Port Scan Error: \(error)")
393            }
394            if !report.network.portScan.isEmpty {
395                networkLines.append("Port Scan:")
396                for result in report.network.portScan {
397                    networkLines.append(
398                        "  \(result.port) \(result.service): \(result.open ? "open" : "closed")\(result.banner.map { " banner=\($0)" } ?? "")"
399                    )
400                }
401            }
402            return networkLines
403        }
404
405        appendSection("Subdomains", to: &lines) {
406            var values = ["Confidence: \(report.subdomainConfidence?.title ?? "N/A")"]
407            if let provenance = report.sectionProvenance[.subdomains] {
408                values.append("Provenance: \(provenanceLabel(provenance))")
409            }
410            if report.subdomains.isEmpty {
411                values.append("None")
412                return values
413            }
414            if !report.subdomainGroups.isEmpty {
415                values.append("Groups: \(report.subdomainGroups.map { "\($0.label): \($0.subdomains.count)" }.joined(separator: " | "))")
416            }
417            values.append(contentsOf: report.subdomains.map { "- \($0)" })
418            if !report.extendedSubdomains.isEmpty {
419                values.append("Extended:")
420                values.append(contentsOf: report.extendedSubdomains.map { "- \($0)" })
421            }
422            return values
423        }
424
425        appendSection("Pricing", to: &lines) {
426            guard let pricing = report.domainPricing else {
427                return ["External pricing unavailable"]
428            }
429
430            return [
431                "Estimated Price: \(pricing.estimatedPrice ?? "Unavailable")",
432                "Premium: \(pricing.premiumIndicator == true ? "Yes" : "No")",
433                "Resale: \(pricing.resaleSignal ?? "Unavailable")",
434                "Auction: \(pricing.auctionSignal ?? "Unavailable")",
435                "Source: \(pricing.source)",
436                "Collected: \(textDateFormatter.string(from: pricing.collectedAt))"
437            ]
438        }
439
440        appendSection("Changes", to: &lines) {
441            guard let changeSummary = report.changeSummary else {
442                return ["No comparison available"]
443            }
444
445            var values = [
446                "Has Changes: \(changeSummary.hasChanges ? "Yes" : "No")",
447                "Severity: \(changeSummary.severity.title)",
448                "Impact: \(changeSummary.impactClassification.title)",
449                "Inferred Summary: \(changeSummary.message)",
450                "Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))"
451            ]
452            if let riskScoreDelta = changeSummary.riskScoreDelta {
453                values.append("Risk Delta: \(riskScoreDelta >= 0 ? "+" : "")\(riskScoreDelta)")
454            }
455            if !changeSummary.insights.isEmpty {
456                values.append("Insights: \(changeSummary.insights.joined(separator: " | "))")
457            }
458            if !changeSummary.observedFacts.isEmpty {
459                values.append("Observed: \(changeSummary.observedFacts.joined(separator: " | "))")
460            }
461            if let contextNote = changeSummary.contextNote {
462                values.append("Context: \(contextNote)")
463            }
464            return values
465        }
466
467        return lines.joined(separator: "\n")
468    }
469
470    static func batchText(for reports: [DomainReport], title: String) -> String {
471        guard !reports.isEmpty else {
472            return "\(title)\nNo results available."
473        }
474
475        var lines = [title, String(repeating: "=", count: title.count), ""]
476        for (index, report) in reports.enumerated() {
477            if index > 0 {
478                lines.append("")
479                lines.append(String(repeating: "=", count: 48))
480                lines.append("")
481            }
482            lines.append(text(for: report))
483        }
484        return lines.joined(separator: "\n")
485    }
486
487    static func csv(for reports: [DomainReport]) -> String {
488        csv(for: reports, workflowInsights: [])
489    }
490
491    static func csv(for reports: [DomainReport], workflowInsights: [WorkflowInsight]) -> String {
492        let workflowInsightSummary = workflowInsights.map(\.description).joined(separator: " | ")
493        let headers = [
494            "domain",
495            "timestamp",
496            "app_version",
497            "result_source",
498            "resolver",
499            "availability",
500            "risk_score",
501            "risk_level",
502            "risk_factors",
503            "insights",
504            "availability_confidence",
505            "registrar",
506            "ownership_confidence",
507            "ownership_expires",
508            "nameservers",
509            "primary_ip",
510            "ptr_record",
511            "dnssec_signed",
512            "dns_patterns",
513            "tls_status",
514            "tls_grade",
515            "tls_highlights",
516            "certificate_warning_level",
517            "hsts_preloaded",
518            "http_status",
519            "http_security_grade",
520            "final_url",
521            "email_summary",
522            "email_grade",
523            "email_confidence",
524            "subdomain_count",
525            "extended_subdomain_count",
526            "subdomain_groups",
527            "subdomain_confidence",
528            "subdomains",
529            "extended_subdomains",
530            "ownership_history",
531            "dns_history",
532            "pricing_estimated",
533            "pricing_premium",
534            "pricing_resale_signal",
535            "pricing_auction_signal",
536            "pricing_source",
537            "reputation_status",
538            "reputation_listed_sources",
539            "open_ports",
540            "reachability_summary",
541            "geolocation_summary",
542            "geolocation_confidence",
543            "data_sources",
544            "audit_note",
545            "partial_snapshot",
546            "workflow_name",
547            "workflow_source",
548            "cached_sections",
549            "change_summary",
550            "change_impact",
551            "workflow_insights"
552        ]
553
554        let rows = reports.map { report in
555            let expirationDate = report.ownership?.expirationDate.map(csvDateFormatter.string(from:)) ?? ""
556            let nameservers = joined(report.ownership?.nameservers) ?? ""
557            let dnssecSigned = report.dns.dnssecSigned.map { $0 ? "true" : "false" } ?? ""
558            let hstsPreloaded = report.web.hstsPreloaded.map { $0 ? "true" : "false" } ?? ""
559            let httpStatus = report.web.statusCode.map(String.init) ?? ""
560            let subdomainCount = String(report.subdomains.count)
561            let subdomains = report.subdomains.joined(separator: " | ")
562            let extendedSubdomains = report.extendedSubdomains.joined(separator: " | ")
563            let openPorts = report.network.openPorts.map(String.init).joined(separator: " | ")
564            let riskFactors = report.riskAssessment.factors.map(\.description).joined(separator: " | ")
565            let insights = report.insights.joined(separator: " | ")
566            let dnsPatterns = report.dns.patternSummary.patterns.joined(separator: " | ")
567            let tlsHighlights = report.web.tlsHighlights.joined(separator: " | ")
568            let subdomainGroups = report.subdomainGroups.map { "\($0.label):\($0.subdomains.count)" }.joined(separator: " | ")
569            let ownershipHistory = report.ownershipHistory.map { "\($0.date.ISO8601Format()) \($0.summary)" }.joined(separator: " | ")
570            let dnsHistory = report.dnsHistory.map { "\($0.date.ISO8601Format()) \($0.summary)" }.joined(separator: " | ")
571            let reputationStatus = report.reputation?.status.rawValue ?? ""
572            let reputationListedSources = report.reputation?.listedSources.joined(separator: " | ") ?? ""
573
574            return [
575                report.domain,
576                csvDateFormatter.string(from: report.timestamp),
577                report.appVersion,
578                report.resultSource.rawValue,
579                report.resolverDisplayName,
580                availabilityLabel(report.availability),
581                "\(report.riskAssessment.score)",
582                report.riskAssessment.level.rawValue,
583                riskFactors,
584                insights,
585                report.availabilityConfidence?.rawValue ?? "",
586                report.ownership?.registrar ?? "",
587                report.ownershipConfidence?.rawValue ?? "",
588                expirationDate,
589                nameservers,
590                report.dns.primaryIP ?? "",
591                report.dns.ptrRecord ?? "",
592                dnssecSigned,
593                dnsPatterns,
594                report.web.tlsStatus,
595                report.web.tlsGrade.rawValue,
596                tlsHighlights,
597                report.web.certificateWarningLevel.rawValue,
598                hstsPreloaded,
599                httpStatus,
600                report.web.securityGrade ?? "",
601                report.web.finalURL ?? "",
602                report.email.summary,
603                report.email.grade?.rawValue ?? "",
604                report.emailConfidence?.rawValue ?? "",
605                subdomainCount,
606                String(report.extendedSubdomains.count),
607                subdomainGroups,
608                report.subdomainConfidence?.rawValue ?? "",
609                subdomains,
610                extendedSubdomains,
611                ownershipHistory,
612                dnsHistory,
613                report.domainPricing?.estimatedPrice ?? "",
614                report.domainPricing?.premiumIndicator == true ? "true" : "false",
615                report.domainPricing?.resaleSignal ?? "",
616                report.domainPricing?.auctionSignal ?? "",
617                report.domainPricing?.source ?? "",
618                reputationStatus,
619                reputationListedSources,
620                openPorts,
621                report.network.reachabilitySummary,
622                report.network.geolocationSummary,
623                report.geolocationConfidence?.rawValue ?? "",
624                report.provenance.dataSources.joined(separator: " | "),
625                report.metadata.auditNote ?? "",
626                report.metadata.isPartialSnapshot ? "true" : "false",
627                report.workflowContext?.workflowName ?? "",
628                report.workflowContext?.source ?? "",
629                report.metadata.cachedSections.map(\.rawValue).joined(separator: " | "),
630                report.changeSummary?.message ?? "",
631                report.changeSummary?.impactClassification.rawValue ?? "",
632                workflowInsightSummary
633            ]
634        }
635
636        return ([headers] + rows)
637            .map { row in row.map(csvEscaped).joined(separator: ",") }
638            .joined(separator: "\n")
639    }
640
641    static func timelineText(for reports: [DomainReport], domain: String, includeDiffSummary: Bool) -> String {
642        guard !reports.isEmpty else {
643            return "Timeline Export\nNo snapshots available for \(domain)."
644        }
645
646        var lines = [
647            "DomainDig Timeline Export",
648            "Domain: \(domain)",
649            "Snapshots: \(reports.count)"
650        ]
651
652        for report in reports.sorted(by: { $0.timestamp > $1.timestamp }) {
653            lines.append("")
654            lines.append("\(textDateFormatter.string(from: report.timestamp))")
655            lines.append("Summary: \(report.changeSummary?.message ?? "No change summary")")
656            lines.append("Severity: \(report.changeSummary?.severity.title ?? "N/A")")
657            if includeDiffSummary, let changeSummary = report.changeSummary {
658                lines.append("Changed Sections: \(changeSummary.changedSections.joined(separator: ", ").nilIfEmpty ?? "None")")
659            }
660        }
661
662        return lines.joined(separator: "\n")
663    }
664
665    static func timelineData(for reports: [DomainReport], domain: String, includeDiffSummary: Bool) throws -> Data {
666        struct TimelineExportEntry: Codable {
667            let timestamp: Date
668            let summary: String?
669            let severity: String?
670            let changedSections: [String]?
671        }
672
673        struct TimelineExportPayload: Codable {
674            let domain: String
675            let exportedAt: Date
676            let snapshots: [TimelineExportEntry]
677        }
678
679        let payload = TimelineExportPayload(
680            domain: domain,
681            exportedAt: Date(),
682            snapshots: reports
683                .sorted(by: { $0.timestamp > $1.timestamp })
684                .map { report in
685                    TimelineExportEntry(
686                        timestamp: report.timestamp,
687                        summary: report.changeSummary?.message,
688                        severity: report.changeSummary?.severity.title,
689                        changedSections: includeDiffSummary ? report.changeSummary?.changedSections : nil
690                    )
691                }
692        )
693
694        return try jsonEncoder.encode(payload)
695    }
696
697    private static func appendSection(_ title: String, to lines: inout [String], body: () -> [String]) {
698        lines.append("")
699        lines.append(title)
700        lines.append(String(repeating: "-", count: title.count))
701        lines.append(contentsOf: body())
702    }
703
704    private static func availabilityLabel(_ status: DomainAvailabilityStatus) -> String {
705        switch status {
706        case .available:
707            return "Available"
708        case .registered:
709            return "Registered"
710        case .unknown:
711            return "Unknown"
712        }
713    }
714
715    private static func joined(_ values: [String]?) -> String? {
716        guard let values, !values.isEmpty else { return nil }
717        return values.joined(separator: " | ")
718    }
719
720    private static func dnssecLabel(_ value: Bool?) -> String {
721        switch value {
722        case true:
723            return "Signed"
724        case false:
725            return "Unsigned"
726        case nil:
727            return "Unavailable"
728        }
729    }
730
731    private static func ownershipDateLabel(_ date: Date?) -> String {
732        guard let date else { return "Unavailable" }
733        return textDateFormatter.string(from: date)
734    }
735
736    private static func durationLabel(_ durationMs: Int?) -> String {
737        durationMs.map { "\($0) ms" } ?? "Unavailable"
738    }
739
740    private static func httpSummary(for report: DomainReport) -> String {
741        let parts = [report.web.statusCode.map(String.init), report.web.securityGrade].compactMap { $0 }
742        return parts.isEmpty ? report.web.headersError ?? "Unavailable" : parts.joined(separator: " / ")
743    }
744
745    private static func booleanLabel(_ value: Bool?) -> String {
746        guard let value else { return "Unavailable" }
747        return value ? "Yes" : "No"
748    }
749
750    private static func recordLabel(_ record: EmailSecurityRecord) -> String {
751        if record.found {
752            return record.value ?? "Present"
753        }
754        return "Unavailable"
755    }
756
757    private static func provenanceLabel(_ provenance: SectionProvenance) -> String {
758        [
759            provenance.source,
760            provenance.provider,
761            provenance.resolver.map { "resolver=\($0)" },
762            provenance.resultSource.label.lowercased(),
763            textDateFormatter.string(from: provenance.collectedAt)
764        ]
765        .compactMap { $0 }
766        .joined(separator: " | ")
767    }
768
769    private static let textDateFormatter: DateFormatter = {
770        let formatter = DateFormatter()
771        formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
772        return formatter
773    }()
774
775    private static let csvDateFormatter: ISO8601DateFormatter = {
776        let formatter = ISO8601DateFormatter()
777        formatter.formatOptions = [.withInternetDateTime]
778        return formatter
779    }()
780
781    private static let jsonEncoder: JSONEncoder = {
782        let encoder = JSONEncoder()
783        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
784        encoder.dateEncodingStrategy = .iso8601
785        return encoder
786    }()
787
788    nonisolated private static func csvEscaped(_ value: String) -> String {
789        let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
790        return "\"\(escaped)\""
791    }
792}
793
794private extension String {
795    var nilIfEmpty: String? {
796        isEmpty ? nil : self
797    }
798}