krz/domain-dig

an ios app for DNS & SSL analysis

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

main: DomainDig/Models.swift · raw

   1import Foundation
   2
   3enum ServiceResult<Value> {
   4    case success(Value)
   5    case empty(String)
   6    case error(String)
   7}
   8
   9enum ConfidenceLevel: String, Codable {
  10    case high
  11    case medium
  12    case low
  13
  14    var title: String {
  15        rawValue.capitalized
  16    }
  17}
  18
  19enum InspectionErrorKind: String, Codable {
  20    case network
  21    case timeout
  22    case rateLimited
  23    case parsing
  24    case unsupported
  25    case unavailable
  26    case unknown
  27
  28    var title: String {
  29        switch self {
  30        case .network:
  31            return "Network"
  32        case .timeout:
  33            return "Timeout"
  34        case .rateLimited:
  35            return "Rate limited"
  36        case .parsing:
  37            return "Parsing"
  38        case .unsupported:
  39            return "Unsupported"
  40        case .unavailable:
  41            return "Unavailable"
  42        case .unknown:
  43            return "Unknown"
  44        }
  45    }
  46}
  47
  48struct InspectionFailure: Codable, Equatable {
  49    let kind: InspectionErrorKind
  50    let message: String
  51    let details: String?
  52}
  53
  54struct SectionProvenance: Codable, Equatable {
  55    let source: String
  56    let collectedAt: Date
  57    let provider: String?
  58    let resolver: String?
  59    let resultSource: LookupResultSource
  60}
  61
  62enum LookupResultSource: String, Codable {
  63    case live
  64    case cached
  65    case mixed
  66    case snapshot
  67
  68    var label: String {
  69        switch self {
  70        case .live:
  71            return "Live"
  72        case .cached:
  73            return "Cached"
  74        case .mixed:
  75            return "Mixed"
  76        case .snapshot:
  77            return "Snapshot"
  78        }
  79    }
  80}
  81
  82enum LookupSectionKind: String, Codable, CaseIterable {
  83    case dns
  84    case availability
  85    case ssl
  86    case hsts
  87    case httpHeaders
  88    case reachability
  89    case ipGeolocation
  90    case emailSecurity
  91    case ownership
  92    case ptr
  93    case redirectChain
  94    case subdomains
  95    case portScan
  96    case suggestions
  97}
  98
  99enum DomainAvailabilityStatus: String, Codable {
 100    case available
 101    case registered
 102    case unknown
 103}
 104
 105struct DomainAvailabilityResult: Codable {
 106    let domain: String
 107    let status: DomainAvailabilityStatus
 108}
 109
 110struct DomainSuggestionResult: Identifiable, Codable {
 111    let id: UUID
 112    let domain: String
 113    let status: DomainAvailabilityStatus
 114
 115    init(id: UUID = UUID(), domain: String, status: DomainAvailabilityStatus) {
 116        self.id = id
 117        self.domain = domain
 118        self.status = status
 119    }
 120}
 121
 122struct WatchedDomain: Codable, Identifiable {
 123    let id: UUID
 124    let domain: String
 125    let createdAt: Date
 126    var lastKnownAvailability: DomainAvailabilityStatus?
 127
 128    init(
 129        id: UUID = UUID(),
 130        domain: String,
 131        createdAt: Date = Date(),
 132        lastKnownAvailability: DomainAvailabilityStatus? = nil
 133    ) {
 134        self.id = id
 135        self.domain = domain
 136        self.createdAt = createdAt
 137        self.lastKnownAvailability = lastKnownAvailability
 138    }
 139}
 140
 141enum ChangeSeverity: Int, Codable, CaseIterable, Comparable {
 142    case low
 143    case medium
 144    case high
 145
 146    static func < (lhs: ChangeSeverity, rhs: ChangeSeverity) -> Bool {
 147        lhs.rawValue < rhs.rawValue
 148    }
 149
 150    var title: String {
 151        switch self {
 152        case .low:
 153            return "Low"
 154        case .medium:
 155            return "Medium"
 156        case .high:
 157            return "High"
 158        }
 159    }
 160}
 161
 162enum CertificateWarningLevel: String, Codable {
 163    case none
 164    case warning
 165    case critical
 166
 167    var title: String {
 168        switch self {
 169        case .none:
 170            return "Healthy"
 171        case .warning:
 172            return "Warning"
 173        case .critical:
 174            return "Critical"
 175        }
 176    }
 177}
 178
 179enum DomainHealth: String, Codable, Sendable, CaseIterable {
 180    case healthy
 181    case warning
 182    case critical
 183
 184    var title: String {
 185        rawValue.capitalized
 186    }
 187}
 188
 189struct PortfolioSnapshot: Codable, Sendable {
 190    var totalDomains: Int
 191    var healthyCount: Int
 192    var warningCount: Int
 193    var criticalCount: Int
 194    var changedLast24h: Int
 195    var expiringSoonCount: Int
 196    var unreachableCount: Int
 197}
 198
 199struct DomainChangeSummary: Codable, Equatable {
 200    let hasChanges: Bool
 201    let changedSections: [String]
 202    let message: String
 203    let severity: ChangeSeverity
 204    let impactClassification: ChangeImpactClassification
 205    let generatedAt: Date
 206    let observedFacts: [String]
 207    let inferredConclusions: [String]
 208    let contextNote: String?
 209    let riskAssessment: DomainRiskAssessment?
 210    let insights: [String]
 211    let riskScoreDelta: Int?
 212
 213    init(
 214        hasChanges: Bool,
 215        changedSections: [String],
 216        message: String,
 217        severity: ChangeSeverity,
 218        impactClassification: ChangeImpactClassification,
 219        generatedAt: Date,
 220        observedFacts: [String] = [],
 221        inferredConclusions: [String] = [],
 222        contextNote: String? = nil,
 223        riskAssessment: DomainRiskAssessment? = nil,
 224        insights: [String] = [],
 225        riskScoreDelta: Int? = nil
 226    ) {
 227        self.hasChanges = hasChanges
 228        self.changedSections = changedSections
 229        self.message = message
 230        self.severity = severity
 231        self.impactClassification = impactClassification
 232        self.generatedAt = generatedAt
 233        self.observedFacts = observedFacts
 234        self.inferredConclusions = inferredConclusions
 235        self.contextNote = contextNote
 236        self.riskAssessment = riskAssessment
 237        self.insights = insights
 238        self.riskScoreDelta = riskScoreDelta
 239    }
 240
 241    init(from decoder: Decoder) throws {
 242        let container = try decoder.container(keyedBy: CodingKeys.self)
 243        hasChanges = try container.decodeIfPresent(Bool.self, forKey: .hasChanges) ?? false
 244        changedSections = try container.decodeIfPresent([String].self, forKey: .changedSections) ?? []
 245        generatedAt = try container.decode(Date.self, forKey: .generatedAt)
 246        severity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .severity) ?? (hasChanges ? .medium : .low)
 247        let defaultImpact: ChangeImpactClassification
 248        if severity == .high {
 249            defaultImpact = .critical
 250        } else {
 251            defaultImpact = hasChanges ? .warning : .informational
 252        }
 253        impactClassification = try container.decodeIfPresent(ChangeImpactClassification.self, forKey: .impactClassification)
 254            ?? defaultImpact
 255        message = try container.decodeIfPresent(String.self, forKey: .message)
 256            ?? (changedSections.isEmpty ? "No meaningful changes" : changedSections.joined(separator: ""))
 257        observedFacts = try container.decodeIfPresent([String].self, forKey: .observedFacts) ?? []
 258        inferredConclusions = try container.decodeIfPresent([String].self, forKey: .inferredConclusions) ?? []
 259        contextNote = try container.decodeIfPresent(String.self, forKey: .contextNote)
 260        riskAssessment = try container.decodeIfPresent(DomainRiskAssessment.self, forKey: .riskAssessment)
 261        insights = try container.decodeIfPresent([String].self, forKey: .insights) ?? []
 262        riskScoreDelta = try container.decodeIfPresent(Int.self, forKey: .riskScoreDelta)
 263    }
 264}
 265
 266enum BatchLookupSource: String, Codable {
 267    case manual
 268    case watchlistRefresh
 269    case workflow
 270}
 271
 272enum BatchLookupStatus: String, Codable {
 273    case pending
 274    case running
 275    case completed
 276    case failed
 277}
 278
 279struct BatchLookupResult: Identifiable, Codable, Equatable {
 280    let id: UUID
 281    let domain: String
 282    let historyEntryID: UUID?
 283    let resultSource: LookupResultSource
 284    let availability: DomainAvailabilityStatus?
 285    let primaryIP: String?
 286    let quickStatus: String
 287    let summaryMessage: String?
 288    let changeSeverity: ChangeSeverity?
 289    let changeClassification: ChangeImpactClassification?
 290    let certificateWarningLevel: CertificateWarningLevel
 291    let riskScore: Int?
 292    let riskLevel: RiskLevel?
 293    let timestamp: Date
 294    let status: BatchLookupStatus
 295    let errorMessage: String?
 296
 297    init(
 298        id: UUID = UUID(),
 299        domain: String,
 300        historyEntryID: UUID?,
 301        resultSource: LookupResultSource = .live,
 302        availability: DomainAvailabilityStatus?,
 303        primaryIP: String?,
 304        quickStatus: String,
 305        summaryMessage: String? = nil,
 306        changeSeverity: ChangeSeverity? = nil,
 307        changeClassification: ChangeImpactClassification? = nil,
 308        certificateWarningLevel: CertificateWarningLevel = .none,
 309        riskScore: Int? = nil,
 310        riskLevel: RiskLevel? = nil,
 311        timestamp: Date,
 312        status: BatchLookupStatus,
 313        errorMessage: String? = nil
 314    ) {
 315        self.id = id
 316        self.domain = domain
 317        self.historyEntryID = historyEntryID
 318        self.resultSource = resultSource
 319        self.availability = availability
 320        self.primaryIP = primaryIP
 321        self.quickStatus = quickStatus
 322        self.summaryMessage = summaryMessage
 323        self.changeSeverity = changeSeverity
 324        self.changeClassification = changeClassification
 325        self.certificateWarningLevel = certificateWarningLevel
 326        self.riskScore = riskScore
 327        self.riskLevel = riskLevel
 328        self.timestamp = timestamp
 329        self.status = status
 330        self.errorMessage = errorMessage
 331    }
 332
 333    var hasMeaningfulChange: Bool {
 334        quickStatus == "Changed"
 335            || quickStatus == "High"
 336            || quickStatus == "Critical"
 337            || certificateWarningLevel != .none
 338            || riskLevel == .high
 339            || status == .failed
 340    }
 341}
 342
 343struct BatchSweepSummary: Identifiable, Equatable {
 344    let id = UUID()
 345    let source: BatchLookupSource
 346    let totalDomains: Int
 347    let changedDomains: Int
 348    let unchangedDomains: Int
 349    let warningDomains: Int
 350    let results: [BatchLookupResult]
 351    let generatedAt: Date
 352}
 353
 354struct DomainWorkflow: Codable, Identifiable, Equatable {
 355    let id: UUID
 356    var name: String
 357    var domains: [String]
 358    var createdAt: Date
 359    var updatedAt: Date
 360    var notes: String?
 361    var collaboration: CollaborationMetadata?
 362
 363    init(
 364        id: UUID = UUID(),
 365        name: String,
 366        domains: [String],
 367        createdAt: Date = Date(),
 368        updatedAt: Date = Date(),
 369        notes: String? = nil,
 370        collaboration: CollaborationMetadata? = nil
 371    ) {
 372        self.id = id
 373        self.name = name
 374        self.domains = domains
 375        self.createdAt = createdAt
 376        self.updatedAt = updatedAt
 377        self.notes = notes
 378        self.collaboration = collaboration
 379    }
 380}
 381
 382struct WorkflowRunSummary: Identifiable, Equatable {
 383    let id = UUID()
 384    let workflowID: UUID
 385    let workflowName: String
 386    let totalDomains: Int
 387    let changedDomains: Int
 388    let unchangedDomains: Int
 389    let warningDomains: Int
 390    let results: [BatchLookupResult]
 391    let workflowInsights: [WorkflowInsight]
 392    let generatedAt: Date
 393}
 394
 395enum DataCapability: String, Codable {
 396    case ownershipHistory
 397    case dnsHistory
 398    case extendedSubdomains
 399    case domainPricing
 400    case reputation
 401}
 402
 403enum UsageCreditFeature: String, Codable, CaseIterable, Identifiable, Sendable {
 404    case ownershipHistory
 405    case dnsHistory
 406    case extendedSubdomains
 407
 408    nonisolated var id: String { rawValue }
 409
 410    nonisolated var title: String {
 411        switch self {
 412        case .ownershipHistory:
 413            return "Ownership history"
 414        case .dnsHistory:
 415            return "DNS history"
 416        case .extendedSubdomains:
 417            return "Extended subdomains"
 418        }
 419    }
 420
 421    nonisolated var defaultAllowance: Int {
 422        switch self {
 423        case .ownershipHistory, .dnsHistory:
 424            return 8
 425        case .extendedSubdomains:
 426            return 12
 427        }
 428    }
 429}
 430
 431struct UsageCreditStatus: Codable, Equatable, Sendable {
 432    let feature: UsageCreditFeature
 433    let remaining: Int
 434    let total: Int
 435    let resetContext: String
 436
 437    nonisolated var summary: String {
 438        "\(remaining) uses remaining"
 439    }
 440
 441    nonisolated var isExhausted: Bool {
 442        remaining <= 0
 443    }
 444}
 445
 446struct DomainOwnershipHistoryEvent: Identifiable, Codable, Equatable, Sendable {
 447    let id: UUID
 448    let date: Date
 449    let summary: String
 450    let registrar: String?
 451    let registrant: String?
 452    let nameservers: [String]
 453    let source: String
 454    let isExternal: Bool
 455
 456    nonisolated init(
 457        id: UUID = UUID(),
 458        date: Date,
 459        summary: String,
 460        registrar: String? = nil,
 461        registrant: String? = nil,
 462        nameservers: [String] = [],
 463        source: String,
 464        isExternal: Bool
 465    ) {
 466        self.id = id
 467        self.date = date
 468        self.summary = summary
 469        self.registrar = registrar
 470        self.registrant = registrant
 471        self.nameservers = nameservers
 472        self.source = source
 473        self.isExternal = isExternal
 474    }
 475}
 476
 477struct DNSHistoryEvent: Identifiable, Codable, Equatable, Sendable {
 478    let id: UUID
 479    let date: Date
 480    let summary: String
 481    let aRecords: [String]
 482    let nameservers: [String]
 483    let recordSnapshots: [DNSHistoryRecordSnapshot]
 484    let changedRecordTypes: [DNSRecordType]
 485    let source: String
 486    let isExternal: Bool
 487
 488    nonisolated init(
 489        id: UUID = UUID(),
 490        date: Date,
 491        summary: String,
 492        aRecords: [String] = [],
 493        nameservers: [String] = [],
 494        recordSnapshots: [DNSHistoryRecordSnapshot] = [],
 495        changedRecordTypes: [DNSRecordType] = [],
 496        source: String,
 497        isExternal: Bool
 498    ) {
 499        self.id = id
 500        self.date = date
 501        self.summary = summary
 502        self.aRecords = aRecords
 503        self.nameservers = nameservers
 504        self.recordSnapshots = recordSnapshots
 505        self.changedRecordTypes = changedRecordTypes
 506        self.source = source
 507        self.isExternal = isExternal
 508    }
 509
 510    init(from decoder: Decoder) throws {
 511        let container = try decoder.container(keyedBy: CodingKeys.self)
 512        id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
 513        date = try container.decode(Date.self, forKey: .date)
 514        summary = try container.decodeIfPresent(String.self, forKey: .summary) ?? "DNS change observed"
 515        aRecords = try container.decodeIfPresent([String].self, forKey: .aRecords) ?? []
 516        nameservers = try container.decodeIfPresent([String].self, forKey: .nameservers) ?? []
 517        let decodedRecordSnapshots = try container.decodeIfPresent([DNSHistoryRecordSnapshot].self, forKey: .recordSnapshots) ?? []
 518        if decodedRecordSnapshots.isEmpty {
 519            var synthesizedSnapshots: [DNSHistoryRecordSnapshot] = []
 520            if !aRecords.isEmpty {
 521                synthesizedSnapshots.append(DNSHistoryRecordSnapshot(recordType: .A, values: aRecords))
 522            }
 523            if !nameservers.isEmpty {
 524                synthesizedSnapshots.append(DNSHistoryRecordSnapshot(recordType: .NS, values: nameservers))
 525            }
 526            recordSnapshots = synthesizedSnapshots
 527        } else {
 528            recordSnapshots = decodedRecordSnapshots
 529        }
 530        changedRecordTypes = try container.decodeIfPresent([DNSRecordType].self, forKey: .changedRecordTypes)
 531            ?? recordSnapshots.map(\.recordType)
 532        source = try container.decodeIfPresent(String.self, forKey: .source) ?? "Unknown"
 533        isExternal = try container.decodeIfPresent(Bool.self, forKey: .isExternal) ?? false
 534    }
 535}
 536
 537struct DNSHistoryRecordSnapshot: Identifiable, Codable, Sendable, Equatable {
 538    let id: UUID
 539    let recordType: DNSRecordType
 540    let values: [String]
 541
 542    nonisolated init(id: UUID = UUID(), recordType: DNSRecordType, values: [String]) {
 543        self.id = id
 544        self.recordType = recordType
 545        self.values = values
 546    }
 547
 548    static func == (lhs: DNSHistoryRecordSnapshot, rhs: DNSHistoryRecordSnapshot) -> Bool {
 549        lhs.recordType == rhs.recordType && lhs.values == rhs.values
 550    }
 551}
 552
 553struct InferredProviderFingerprint: Codable, Equatable, Sendable {
 554    let name: String
 555    let confidence: ConfidenceLevel
 556    let evidence: [String]
 557}
 558
 559enum DomainClassificationKind: String, Codable, CaseIterable, Sendable {
 560    case marketing
 561    case app
 562    case api
 563    case auth
 564    case docs
 565    case staticSite = "static"
 566    case infrastructure
 567    case status
 568    case unknown
 569
 570    var title: String {
 571        if self == .staticSite {
 572            return "Static"
 573        }
 574        return rawValue.capitalized
 575    }
 576}
 577
 578struct DomainClassificationSummary: Codable, Equatable, Sendable {
 579    let kind: DomainClassificationKind
 580    let confidence: ConfidenceLevel
 581    let reasons: [String]
 582}
 583
 584struct OwnershipTransitionEvent: Identifiable, Codable, Equatable, Sendable {
 585    let id: UUID
 586    let date: Date
 587    let summary: String
 588    let previousRegistrar: String?
 589    let currentRegistrar: String?
 590    let previousRegistrant: String?
 591    let currentRegistrant: String?
 592    let previousNameservers: [String]
 593    let currentNameservers: [String]
 594
 595    nonisolated init(
 596        id: UUID = UUID(),
 597        date: Date,
 598        summary: String,
 599        previousRegistrar: String? = nil,
 600        currentRegistrar: String? = nil,
 601        previousRegistrant: String? = nil,
 602        currentRegistrant: String? = nil,
 603        previousNameservers: [String] = [],
 604        currentNameservers: [String] = []
 605    ) {
 606        self.id = id
 607        self.date = date
 608        self.summary = summary
 609        self.previousRegistrar = previousRegistrar
 610        self.currentRegistrar = currentRegistrar
 611        self.previousRegistrant = previousRegistrant
 612        self.currentRegistrant = currentRegistrant
 613        self.previousNameservers = previousNameservers
 614        self.currentNameservers = currentNameservers
 615    }
 616}
 617
 618struct HostingTransitionEvent: Identifiable, Codable, Equatable, Sendable {
 619    let id: UUID
 620    let date: Date
 621    let fromProvider: String
 622    let toProvider: String
 623    let summary: String
 624
 625    nonisolated init(id: UUID = UUID(), date: Date, fromProvider: String, toProvider: String, summary: String) {
 626        self.id = id
 627        self.date = date
 628        self.fromProvider = fromProvider
 629        self.toProvider = toProvider
 630        self.summary = summary
 631    }
 632}
 633
 634struct SubdomainHistoryEntry: Identifiable, Codable, Equatable, Sendable {
 635    let id: String
 636    let hostname: String
 637    let firstSeen: Date
 638    let lastSeen: Date
 639    let recurrenceCount: Int
 640    let statusChangeCount: Int
 641    let lastKnownStatus: String
 642    let isEphemeral: Bool
 643
 644    nonisolated init(
 645        hostname: String,
 646        firstSeen: Date,
 647        lastSeen: Date,
 648        recurrenceCount: Int,
 649        statusChangeCount: Int,
 650        lastKnownStatus: String,
 651        isEphemeral: Bool
 652    ) {
 653        id = hostname.lowercased()
 654        self.hostname = hostname
 655        self.firstSeen = firstSeen
 656        self.lastSeen = lastSeen
 657        self.recurrenceCount = recurrenceCount
 658        self.statusChangeCount = statusChangeCount
 659        self.lastKnownStatus = lastKnownStatus
 660        self.isEphemeral = isEphemeral
 661    }
 662}
 663
 664struct IntelligenceRiskSignal: Identifiable, Codable, Equatable, Sendable {
 665    let id: String
 666    let title: String
 667    let detail: String
 668    let severity: ChangeSeverity
 669    let firstObserved: Date?
 670    let lastObserved: Date?
 671
 672    nonisolated init(
 673        id: String,
 674        title: String,
 675        detail: String,
 676        severity: ChangeSeverity,
 677        firstObserved: Date? = nil,
 678        lastObserved: Date? = nil
 679    ) {
 680        self.id = id
 681        self.title = title
 682        self.detail = detail
 683        self.severity = severity
 684        self.firstObserved = firstObserved
 685        self.lastObserved = lastObserved
 686    }
 687}
 688
 689enum IntelligenceTimelineEventCategory: String, Codable, Sendable {
 690    case ownership
 691    case dns
 692    case hosting
 693    case subdomain
 694    case classification
 695    case risk
 696}
 697
 698struct IntelligenceTimelineEvent: Identifiable, Codable, Equatable, Sendable {
 699    let id: UUID
 700    let date: Date
 701    let category: IntelligenceTimelineEventCategory
 702    let title: String
 703    let detail: String
 704    let severity: ChangeSeverity
 705
 706    nonisolated init(
 707        id: UUID = UUID(),
 708        date: Date,
 709        category: IntelligenceTimelineEventCategory,
 710        title: String,
 711        detail: String,
 712        severity: ChangeSeverity
 713    ) {
 714        self.id = id
 715        self.date = date
 716        self.category = category
 717        self.title = title
 718        self.detail = detail
 719        self.severity = severity
 720    }
 721}
 722
 723struct DomainPricingInsight: Codable, Equatable, Sendable {
 724    let estimatedPrice: String?
 725    let premiumIndicator: Bool?
 726    let resaleSignal: String?
 727    let auctionSignal: String?
 728    let source: String
 729    let collectedAt: Date
 730}
 731
 732enum DomainReputationStatus: String, Codable, Sendable {
 733    case clean
 734    case listed
 735    case unknown
 736
 737    var title: String {
 738        switch self {
 739        case .clean:
 740            return "Clean"
 741        case .listed:
 742            return "Listed"
 743        case .unknown:
 744            return "Unknown"
 745        }
 746    }
 747}
 748
 749struct DomainReputationResult: Codable, Equatable, Sendable {
 750    let status: DomainReputationStatus
 751    let listedSources: [String]
 752    let checkedAt: Date
 753}
 754
 755enum HistoryDateFilter: String, CaseIterable, Identifiable {
 756    case today
 757    case last7Days
 758    case all
 759
 760    var id: String { rawValue }
 761
 762    var title: String {
 763        switch self {
 764        case .today:
 765            return "Today"
 766        case .last7Days:
 767            return "Last 7 Days"
 768        case .all:
 769            return "All"
 770        }
 771    }
 772}
 773
 774enum ChangeFilterOption: String, CaseIterable, Identifiable {
 775    case all
 776    case changed
 777    case unchanged
 778
 779    var id: String { rawValue }
 780
 781    var title: String {
 782        switch self {
 783        case .all:
 784            return "All"
 785        case .changed:
 786            return "Changed"
 787        case .unchanged:
 788            return "Unchanged"
 789        }
 790    }
 791}
 792
 793enum HistorySortOption: String, CaseIterable, Identifiable {
 794    case newest
 795    case oldest
 796    case domain
 797
 798    var id: String { rawValue }
 799
 800    var title: String {
 801        switch self {
 802        case .newest:
 803            return "Newest"
 804        case .oldest:
 805            return "Oldest"
 806        case .domain:
 807            return "Domain A-Z"
 808        }
 809    }
 810}
 811
 812enum TimelineGroupingOption: String, CaseIterable, Identifiable {
 813    case none
 814    case relativeDay
 815
 816    var id: String { rawValue }
 817
 818    var title: String {
 819        switch self {
 820        case .none:
 821            return "Ungrouped"
 822        case .relativeDay:
 823            return "Today / Yesterday / Older"
 824        }
 825    }
 826}
 827
 828enum HistoryAutoPruneOption: String, CaseIterable, Codable, Identifiable {
 829    case keep50
 830    case keep100
 831    case unlimited
 832
 833    var id: String { rawValue }
 834
 835    var title: String {
 836        switch self {
 837        case .keep50:
 838            return "Keep Last 50"
 839        case .keep100:
 840            return "Keep Last 100"
 841        case .unlimited:
 842            return "Unlimited"
 843        }
 844    }
 845
 846    var keepCount: Int? {
 847        switch self {
 848        case .keep50:
 849            return 50
 850        case .keep100:
 851            return 100
 852        case .unlimited:
 853            return nil
 854        }
 855    }
 856}
 857
 858struct SnapshotSummary: Identifiable, Codable, Equatable {
 859    let id: UUID
 860    let domain: String
 861    let timestamp: Date
 862    let trackedDomainID: UUID?
 863    let snapshotIndex: Int?
 864    let previousSnapshotID: UUID?
 865    let changeCount: Int
 866    let severitySummary: ChangeSeverity?
 867    let changeSummaryMessage: String?
 868    let availability: DomainAvailabilityStatus?
 869    let primaryIP: String?
 870    let tlsStatus: String?
 871    let riskScore: Int?
 872    let historyEntryID: UUID
 873
 874    var hasChanges: Bool {
 875        changeCount > 0
 876    }
 877}
 878
 879struct FullSnapshot: Codable {
 880    let historyEntry: HistoryEntry
 881}
 882
 883struct TimelineSection: Identifiable, Equatable {
 884    let id: String
 885    let title: String
 886    let entries: [SnapshotSummary]
 887}
 888
 889enum WatchlistFilterOption: String, CaseIterable, Identifiable, Codable {
 890    case all
 891    case pinnedOnly
 892    case changedOnly
 893
 894    var id: String { rawValue }
 895
 896    var title: String {
 897        switch self {
 898        case .all:
 899            return "All"
 900        case .pinnedOnly:
 901            return "Pinned Only"
 902        case .changedOnly:
 903            return "Changed Only"
 904        }
 905    }
 906}
 907
 908enum WatchlistSortOption: String, CaseIterable, Identifiable, Codable {
 909    case pinned
 910    case recentlyUpdated
 911    case alphabetical
 912
 913    var id: String { rawValue }
 914
 915    var title: String {
 916        switch self {
 917        case .pinned:
 918            return "Pinned"
 919        case .recentlyUpdated:
 920            return "Recently Updated"
 921        case .alphabetical:
 922            return "Alphabetical"
 923        }
 924    }
 925}
 926
 927/// A named watchlist filter preset (tag, filter, sort) the user can save and
 928/// reapply. Stored locally in UserDefaults; not included in backup/restore.
 929struct WatchlistSavedView: Codable, Identifiable, Equatable {
 930    let id: UUID
 931    var name: String
 932    var tag: String?
 933    var filter: WatchlistFilterOption
 934    var sort: WatchlistSortOption
 935
 936    init(
 937        id: UUID = UUID(),
 938        name: String,
 939        tag: String?,
 940        filter: WatchlistFilterOption,
 941        sort: WatchlistSortOption
 942    ) {
 943        self.id = id
 944        self.name = name
 945        self.tag = tag
 946        self.filter = filter
 947        self.sort = sort
 948    }
 949}
 950
 951enum PortfolioFilterOption: String, CaseIterable, Identifiable {
 952    case all
 953    case healthy
 954    case warning
 955    case critical
 956    case changed
 957    case expiring
 958    case unreachable
 959
 960    var id: String { rawValue }
 961
 962    var title: String {
 963        switch self {
 964        case .all:
 965            return "All"
 966        case .healthy:
 967            return "Healthy"
 968        case .warning:
 969            return "Warning"
 970        case .critical:
 971            return "Critical"
 972        case .changed:
 973            return "Changed"
 974        case .expiring:
 975            return "Expiring"
 976        case .unreachable:
 977            return "Unreachable"
 978        }
 979    }
 980}
 981
 982extension DomainHealth {
 983    static func classify(
 984        certificateExpiryState: CertificateWarningLevel,
 985        isReachable: Bool,
 986        recentMonitoringFailureCount: Int,
 987        hasRecentDNSChange: Bool,
 988        instabilityScore: Int,
 989        hasRecentCriticalChange: Bool,
 990        hasInvalidTLS: Bool
 991    ) -> DomainHealth {
 992        if !isReachable
 993            || hasInvalidTLS
 994            || certificateExpiryState == .critical
 995            || recentMonitoringFailureCount >= 2
 996            || instabilityScore >= 70
 997            || hasRecentCriticalChange {
 998            return .critical
 999        }
1000
1001        if certificateExpiryState == .warning
1002            || recentMonitoringFailureCount == 1
1003            || hasRecentDNSChange
1004            || instabilityScore >= 35 {
1005            return .warning
1006        }
1007
1008        return .healthy
1009    }
1010
1011    static func instabilityScore(
1012        recentChangeCount: Int,
1013        recentFailureCount: Int,
1014        pendingAlertCount: Int,
1015        hasRecentDNSChange: Bool
1016    ) -> Int {
1017        let rawScore = (recentChangeCount * 12)
1018            + (recentFailureCount * 24)
1019            + (pendingAlertCount * 8)
1020            + (hasRecentDNSChange ? 14 : 0)
1021        return min(rawScore, 100)
1022    }
1023}
1024
1025enum PremiumCapability: String, Codable {
1026    case unlimitedTrackedDomains
1027    case automatedMonitoring
1028    case pushAlerts
1029    case batchTracking
1030    case advancedExports
1031}
1032
1033enum CollaborationPermission: String, Codable, Equatable {
1034    case readOnly
1035    case editable
1036
1037    var title: String {
1038        switch self {
1039        case .readOnly:
1040            return "Read only"
1041        case .editable:
1042            return "Editable"
1043        }
1044    }
1045}
1046
1047enum CollaborationOwnership: String, Codable, Equatable {
1048    case owner
1049    case participant
1050
1051    var title: String {
1052        switch self {
1053        case .owner:
1054            return "Owned by you"
1055        case .participant:
1056            return "Shared with you"
1057        }
1058    }
1059}
1060
1061enum CollaborationScope: String, Codable, Equatable {
1062    case privateDatabase
1063    case sharedDatabase
1064}
1065
1066struct CollaborationMetadata: Codable, Equatable {
1067    var scope: CollaborationScope
1068    var ownership: CollaborationOwnership
1069    var permission: CollaborationPermission
1070    var shareRecordName: String?
1071
1072    init(
1073        scope: CollaborationScope,
1074        ownership: CollaborationOwnership,
1075        permission: CollaborationPermission,
1076        shareRecordName: String? = nil
1077    ) {
1078        self.scope = scope
1079        self.ownership = ownership
1080        self.permission = permission
1081        self.shareRecordName = shareRecordName
1082    }
1083
1084    var isShared: Bool {
1085        shareRecordName != nil || scope == .sharedDatabase
1086    }
1087
1088    var canEdit: Bool {
1089        permission == .editable
1090    }
1091
1092    var isOwner: Bool {
1093        ownership == .owner
1094    }
1095}
1096
1097enum MonitoringSensitivity: String, Codable, CaseIterable, Identifiable, Sendable {
1098    case low
1099    case medium
1100    case high
1101
1102    var id: String { rawValue }
1103
1104    var title: String {
1105        rawValue.capitalized
1106    }
1107
1108    var minimumInterval: TimeInterval {
1109        switch self {
1110        case .low:
1111            return 30 * 60
1112        case .medium:
1113            return 15 * 60
1114        case .high:
1115            return 5 * 60
1116        }
1117    }
1118
1119    var intervalMultiplier: Double {
1120        switch self {
1121        case .low:
1122            return 1.5
1123        case .medium:
1124            return 1.25
1125        case .high:
1126            return 1.1
1127        }
1128    }
1129}
1130
1131struct QuietHours: Codable, Equatable, Sendable {
1132    var startHour: Int
1133    var endHour: Int
1134
1135    init(startHour: Int, endHour: Int) {
1136        self.startHour = max(0, min(23, startHour))
1137        self.endHour = max(0, min(23, endHour))
1138    }
1139
1140    func contains(_ date: Date, calendar: Calendar = .current) -> Bool {
1141        let hour = calendar.component(.hour, from: date)
1142        if startHour <= endHour {
1143            return hour >= startHour && hour < endHour
1144        }
1145        return hour >= startHour || hour < endHour
1146    }
1147}
1148
1149enum MonitoringBaseInterval: String, Codable, CaseIterable, Identifiable {
1150    case thirtyMinutes
1151    case hourly
1152    case sixHours
1153    case twelveHours
1154    case daily
1155
1156    var id: String { rawValue }
1157
1158    var title: String {
1159        switch self {
1160        case .thirtyMinutes:
1161            return "Every 30 Minutes"
1162        case .hourly:
1163            return "Hourly"
1164        case .sixHours:
1165            return "Every 6 Hours"
1166        case .twelveHours:
1167            return "Every 12 Hours"
1168        case .daily:
1169            return "Daily"
1170        }
1171    }
1172
1173    var interval: TimeInterval {
1174        switch self {
1175        case .thirtyMinutes:
1176            return 30 * 60
1177        case .hourly:
1178            return 60 * 60
1179        case .sixHours:
1180            return 6 * 60 * 60
1181        case .twelveHours:
1182            return 12 * 60 * 60
1183        case .daily:
1184            return 24 * 60 * 60
1185        }
1186    }
1187
1188    static func nearest(to interval: TimeInterval) -> MonitoringBaseInterval {
1189        allCases.min { abs($0.interval - interval) < abs($1.interval - interval) } ?? .twelveHours
1190    }
1191}
1192
1193struct MonitoringConfig: Codable, Equatable, Sendable {
1194    var isEnabled: Bool
1195    var baseInterval: TimeInterval
1196    var adaptiveEnabled: Bool
1197    var sensitivity: MonitoringSensitivity
1198    var quietHours: QuietHours?
1199
1200    init(
1201        isEnabled: Bool = false,
1202        baseInterval: TimeInterval = MonitoringBaseInterval.twelveHours.interval,
1203        adaptiveEnabled: Bool = true,
1204        sensitivity: MonitoringSensitivity = .medium,
1205        quietHours: QuietHours? = nil
1206    ) {
1207        self.isEnabled = isEnabled
1208        self.baseInterval = baseInterval
1209        self.adaptiveEnabled = adaptiveEnabled
1210        self.sensitivity = sensitivity
1211        self.quietHours = quietHours
1212    }
1213
1214    var maxInterval: TimeInterval {
1215        24 * 60 * 60
1216    }
1217
1218    var sanitizedBaseInterval: TimeInterval {
1219        max(5 * 60, min(baseInterval, maxInterval))
1220    }
1221}
1222
1223struct MonitoringState: Codable, Equatable, Sendable {
1224    var lastCheck: Date?
1225    var lastChangeHash: String?
1226    var lastAlertDate: Date?
1227    var consecutiveStableChecks: Int
1228    var currentInterval: TimeInterval
1229    var lastChangeDate: Date?
1230
1231    init(
1232        lastCheck: Date? = nil,
1233        lastChangeHash: String? = nil,
1234        lastAlertDate: Date? = nil,
1235        consecutiveStableChecks: Int = 0,
1236        currentInterval: TimeInterval = 0,
1237        lastChangeDate: Date? = nil
1238    ) {
1239        self.lastCheck = lastCheck
1240        self.lastChangeHash = lastChangeHash
1241        self.lastAlertDate = lastAlertDate
1242        self.consecutiveStableChecks = consecutiveStableChecks
1243        self.currentInterval = currentInterval
1244        self.lastChangeDate = lastChangeDate
1245    }
1246}
1247
1248struct MonitoringPendingAlert: Codable, Identifiable, Equatable, Sendable {
1249    let id: UUID
1250    var detectedAt: Date
1251    var message: String
1252    var severity: MonitoringAlertSeverity
1253    var changeHash: String
1254
1255    init(
1256        id: UUID = UUID(),
1257        detectedAt: Date,
1258        message: String,
1259        severity: MonitoringAlertSeverity,
1260        changeHash: String
1261    ) {
1262        self.id = id
1263        self.detectedAt = detectedAt
1264        self.message = message
1265        self.severity = severity
1266        self.changeHash = changeHash
1267    }
1268}
1269
1270
1271// Declared in an extension so the struct keeps its synthesized memberwise
1272// initializer: adding one inside the body would suppress it.
1273extension HistoryEntry.Inspection {
1274    /// Every field here is copied straight off the snapshot, and both call
1275    /// sites did exactly that. Naming it once keeps them from drifting.
1276    init(snapshot: LookupSnapshot) {
1277        self.init(
1278            dnsSections: snapshot.dnsSections,
1279            sslInfo: snapshot.sslInfo,
1280            httpHeaders: snapshot.httpHeaders,
1281            reachabilityResults: snapshot.reachabilityResults,
1282            ipGeolocation: snapshot.ipGeolocation,
1283            emailSecurity: snapshot.emailSecurity,
1284            mtaSts: snapshot.emailSecurity?.mtaSts,
1285            ptrRecord: snapshot.ptrRecord,
1286            redirectChain: snapshot.redirectChain,
1287            subdomains: snapshot.subdomains,
1288            extendedSubdomains: snapshot.extendedSubdomains,
1289            dnsHistory: snapshot.dnsHistory,
1290            portScanResults: snapshot.portScanResults,
1291            hstsPreloaded: snapshot.hstsPreloaded,
1292            availabilityResult: snapshot.availabilityResult,
1293            suggestions: snapshot.suggestions
1294        )
1295    }
1296}
1297
1298// Declared in an extension so the struct keeps its synthesized memberwise
1299// initializer: adding one inside the body would suppress it.
1300extension HistoryEntry.Provenance {
1301    /// Every field here is copied straight off the snapshot, and both call
1302    /// sites did exactly that. Naming it once keeps them from drifting.
1303    init(snapshot: LookupSnapshot) {
1304        self.init(
1305            appVersion: snapshot.appVersion,
1306            resultSource: snapshot.resultSource,
1307            dataSources: snapshot.dataSources,
1308            provenanceBySection: snapshot.provenanceBySection,
1309            availabilityConfidence: snapshot.availabilityConfidence,
1310            ownershipConfidence: snapshot.ownershipConfidence,
1311            subdomainConfidence: snapshot.subdomainConfidence,
1312            emailSecurityConfidence: snapshot.emailSecurityConfidence,
1313            geolocationConfidence: snapshot.geolocationConfidence,
1314            isPartialSnapshot: snapshot.isPartialSnapshot,
1315            validationIssues: snapshot.validationIssues,
1316            resolverDisplayName: snapshot.resolverDisplayName,
1317            resolverURLString: snapshot.resolverURLString,
1318            totalLookupDurationMs: snapshot.totalLookupDurationMs
1319        )
1320    }
1321}
1322
1323// Declared in an extension so the struct keeps its synthesized memberwise
1324// initializer: adding one inside the body would suppress it.
1325extension HistoryEntry.Failures {
1326    /// Every field here is copied straight off the snapshot, and both call
1327    /// sites did exactly that. Naming it once keeps them from drifting.
1328    init(snapshot: LookupSnapshot) {
1329        self.init(
1330            errorDetails: snapshot.errorDetails,
1331            sslError: snapshot.sslError,
1332            httpHeadersError: snapshot.httpHeadersError,
1333            reachabilityError: snapshot.reachabilityError,
1334            ipGeolocationError: snapshot.ipGeolocationError,
1335            emailSecurityError: snapshot.emailSecurityError,
1336            ownershipError: snapshot.ownershipError,
1337            ownershipHistoryError: snapshot.ownershipHistoryError,
1338            ptrError: snapshot.ptrError,
1339            redirectChainError: snapshot.redirectChainError,
1340            subdomainsError: snapshot.subdomainsError,
1341            extendedSubdomainsError: snapshot.extendedSubdomainsError,
1342            dnsHistoryError: snapshot.dnsHistoryError,
1343            domainPricingError: snapshot.domainPricingError,
1344            reputationError: snapshot.reputationError,
1345            portScanError: snapshot.portScanError
1346        )
1347    }
1348}
1349
1350struct TrackedDomain: Codable, Identifiable, Equatable {
1351    let id: UUID
1352    var domain: String
1353    var createdAt: Date
1354    var updatedAt: Date
1355    var note: String?
1356    var isPinned: Bool
1357    var monitoringEnabled: Bool
1358    var lastKnownAvailability: DomainAvailabilityStatus?
1359    var lastSnapshotID: UUID?
1360    var lastChangeSummary: DomainChangeSummary?
1361    var lastChangeSeverity: ChangeSeverity?
1362    var certificateWarningLevel: CertificateWarningLevel
1363    var certificateDaysRemaining: Int?
1364    var lastMonitoredAt: Date?
1365    var lastAlertAt: Date?
1366    var monitoringState: MonitoringState
1367    var pendingMonitoringAlerts: [MonitoringPendingAlert]
1368    var collaboration: CollaborationMetadata?
1369    var tags: [String]
1370
1371    init(
1372        id: UUID = UUID(),
1373        domain: String,
1374        createdAt: Date = Date(),
1375        updatedAt: Date = Date(),
1376        note: String? = nil,
1377        isPinned: Bool = false,
1378        monitoringEnabled: Bool = true,
1379        lastKnownAvailability: DomainAvailabilityStatus? = nil,
1380        lastSnapshotID: UUID? = nil,
1381        lastChangeSummary: DomainChangeSummary? = nil,
1382        lastChangeSeverity: ChangeSeverity? = nil,
1383        certificateWarningLevel: CertificateWarningLevel = .none,
1384        certificateDaysRemaining: Int? = nil,
1385        lastMonitoredAt: Date? = nil,
1386        lastAlertAt: Date? = nil,
1387        monitoringState: MonitoringState = MonitoringState(),
1388        pendingMonitoringAlerts: [MonitoringPendingAlert] = [],
1389        collaboration: CollaborationMetadata? = nil,
1390        tags: [String] = []
1391    ) {
1392        self.id = id
1393        self.domain = domain
1394        self.createdAt = createdAt
1395        self.updatedAt = updatedAt
1396        self.note = note
1397        self.isPinned = isPinned
1398        self.monitoringEnabled = monitoringEnabled
1399        self.lastKnownAvailability = lastKnownAvailability
1400        self.lastSnapshotID = lastSnapshotID
1401        self.lastChangeSummary = lastChangeSummary
1402        self.lastChangeSeverity = lastChangeSeverity
1403        self.certificateWarningLevel = certificateWarningLevel
1404        self.certificateDaysRemaining = certificateDaysRemaining
1405        self.lastMonitoredAt = lastMonitoredAt
1406        self.lastAlertAt = lastAlertAt
1407        self.monitoringState = monitoringState
1408        self.pendingMonitoringAlerts = pendingMonitoringAlerts
1409        self.collaboration = collaboration
1410        self.tags = tags
1411    }
1412
1413    init(from decoder: Decoder) throws {
1414        let container = try decoder.container(keyedBy: CodingKeys.self)
1415        id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
1416        domain = try container.decode(String.self, forKey: .domain)
1417        createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
1418        updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt
1419        note = try container.decodeIfPresent(String.self, forKey: .note)
1420        isPinned = try container.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false
1421        monitoringEnabled = try container.decodeIfPresent(Bool.self, forKey: .monitoringEnabled) ?? true
1422        lastKnownAvailability = try container.decodeIfPresent(DomainAvailabilityStatus.self, forKey: .lastKnownAvailability)
1423        lastSnapshotID = try container.decodeIfPresent(UUID.self, forKey: .lastSnapshotID)
1424        lastChangeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .lastChangeSummary)
1425        lastChangeSeverity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .lastChangeSeverity) ?? lastChangeSummary?.severity
1426        certificateWarningLevel = try container.decodeIfPresent(CertificateWarningLevel.self, forKey: .certificateWarningLevel) ?? .none
1427        certificateDaysRemaining = try container.decodeIfPresent(Int.self, forKey: .certificateDaysRemaining)
1428        lastMonitoredAt = try container.decodeIfPresent(Date.self, forKey: .lastMonitoredAt)
1429        lastAlertAt = try container.decodeIfPresent(Date.self, forKey: .lastAlertAt)
1430        monitoringState = try container.decodeIfPresent(MonitoringState.self, forKey: .monitoringState) ?? MonitoringState()
1431        pendingMonitoringAlerts = try container.decodeIfPresent([MonitoringPendingAlert].self, forKey: .pendingMonitoringAlerts) ?? []
1432        collaboration = try container.decodeIfPresent(CollaborationMetadata.self, forKey: .collaboration)
1433        tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
1434    }
1435}
1436
1437enum MonitoringScope: String, Codable, CaseIterable, Identifiable {
1438    case allTracked
1439    case selectedOnly
1440
1441    var id: String { rawValue }
1442
1443    var title: String {
1444        switch self {
1445        case .allTracked:
1446            return "All Tracked"
1447        case .selectedOnly:
1448            return "Selected Only"
1449        }
1450    }
1451}
1452
1453enum MonitoringAlertSeverity: Int, Codable, CaseIterable, Comparable {
1454    case info
1455    case warning
1456    case critical
1457
1458    static func < (lhs: MonitoringAlertSeverity, rhs: MonitoringAlertSeverity) -> Bool {
1459        lhs.rawValue < rhs.rawValue
1460    }
1461
1462    var title: String {
1463        switch self {
1464        case .info:
1465            return "Info"
1466        case .warning:
1467            return "Warning"
1468        case .critical:
1469            return "Critical"
1470        }
1471    }
1472}
1473
1474enum MonitoringAlertFilter: String, Codable, CaseIterable, Identifiable {
1475    case criticalOnly
1476    case criticalAndWarnings
1477    case allChanges
1478
1479    var id: String { rawValue }
1480
1481    var title: String {
1482        switch self {
1483        case .criticalOnly:
1484            return "Critical Only"
1485        case .criticalAndWarnings:
1486            return "Critical + Warnings"
1487        case .allChanges:
1488            return "All Changes"
1489        }
1490    }
1491
1492    var minimumSeverity: MonitoringAlertSeverity {
1493        switch self {
1494        case .criticalOnly:
1495            return .critical
1496        case .criticalAndWarnings:
1497            return .warning
1498        case .allChanges:
1499            return .info
1500        }
1501    }
1502}
1503
1504enum MonitoringRunTrigger: String, Codable {
1505    case manual
1506    case background
1507    case cli
1508
1509    var title: String {
1510        switch self {
1511        case .manual:
1512            return "Manual"
1513        case .background:
1514            return "Background"
1515        case .cli:
1516            return "CLI"
1517        }
1518    }
1519}
1520
1521struct MonitoringSettings: Codable, Equatable {
1522    private enum CodingKeys: String, CodingKey {
1523        case config
1524        case isEnabled
1525        case frequency
1526        case scope
1527        case selectedDomainIDs
1528        case alertFilter
1529        case alertsEnabled
1530    }
1531
1532    var config: MonitoringConfig
1533    var scope: MonitoringScope
1534    var selectedDomainIDs: [UUID]
1535    var alertFilter: MonitoringAlertFilter
1536    var alertsEnabled: Bool
1537
1538    init(
1539        config: MonitoringConfig = MonitoringConfig(),
1540        scope: MonitoringScope = .allTracked,
1541        selectedDomainIDs: [UUID] = [],
1542        alertFilter: MonitoringAlertFilter = .criticalAndWarnings,
1543        alertsEnabled: Bool = false
1544    ) {
1545        self.config = config
1546        self.scope = scope
1547        self.selectedDomainIDs = selectedDomainIDs
1548        self.alertFilter = alertFilter
1549        self.alertsEnabled = alertsEnabled
1550    }
1551
1552    init(from decoder: Decoder) throws {
1553        let container = try decoder.container(keyedBy: CodingKeys.self)
1554        let legacyEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? false
1555        let legacyFrequencyRaw = try container.decodeIfPresent(String.self, forKey: .frequency)
1556
1557        if let decodedConfig = try container.decodeIfPresent(MonitoringConfig.self, forKey: .config) {
1558            config = decodedConfig
1559        } else {
1560            let mappedInterval: TimeInterval
1561            switch legacyFrequencyRaw {
1562            case "daily":
1563                mappedInterval = MonitoringBaseInterval.daily.interval
1564            case "twiceDaily":
1565                mappedInterval = MonitoringBaseInterval.twelveHours.interval
1566            default:
1567                mappedInterval = MonitoringBaseInterval.twelveHours.interval
1568            }
1569
1570            config = MonitoringConfig(
1571                isEnabled: legacyEnabled,
1572                baseInterval: mappedInterval,
1573                adaptiveEnabled: true,
1574                sensitivity: .medium,
1575                quietHours: nil
1576            )
1577        }
1578
1579        scope = try container.decodeIfPresent(MonitoringScope.self, forKey: .scope) ?? .allTracked
1580        selectedDomainIDs = try container.decodeIfPresent([UUID].self, forKey: .selectedDomainIDs) ?? []
1581        alertFilter = try container.decodeIfPresent(MonitoringAlertFilter.self, forKey: .alertFilter) ?? .criticalAndWarnings
1582        alertsEnabled = try container.decodeIfPresent(Bool.self, forKey: .alertsEnabled) ?? false
1583    }
1584
1585    func encode(to encoder: Encoder) throws {
1586        var container = encoder.container(keyedBy: CodingKeys.self)
1587        try container.encode(config, forKey: .config)
1588        try container.encode(scope, forKey: .scope)
1589        try container.encode(selectedDomainIDs, forKey: .selectedDomainIDs)
1590        try container.encode(alertFilter, forKey: .alertFilter)
1591        try container.encode(alertsEnabled, forKey: .alertsEnabled)
1592    }
1593
1594    var isEnabled: Bool {
1595        get { config.isEnabled }
1596        set { config.isEnabled = newValue }
1597    }
1598
1599    var baseInterval: TimeInterval {
1600        get { config.baseInterval }
1601        set { config.baseInterval = newValue }
1602    }
1603
1604    var adaptiveEnabled: Bool {
1605        get { config.adaptiveEnabled }
1606        set { config.adaptiveEnabled = newValue }
1607    }
1608
1609    var sensitivity: MonitoringSensitivity {
1610        get { config.sensitivity }
1611        set { config.sensitivity = newValue }
1612    }
1613
1614    var quietHours: QuietHours? {
1615        get { config.quietHours }
1616        set { config.quietHours = newValue }
1617    }
1618}
1619
1620struct MonitoringDomainResult: Codable, Identifiable, Equatable {
1621    let id: UUID
1622    let domain: String
1623    let historyEntryID: UUID?
1624    let checkedAt: Date
1625    let didChange: Bool
1626    let summaryMessage: String
1627    let alertSeverity: MonitoringAlertSeverity?
1628    let certificateWarningLevel: CertificateWarningLevel
1629    let resultSource: LookupResultSource
1630    let errorMessage: String?
1631    /// Non-nil when the lookup failed and the previous snapshot was reused, so
1632    /// this run compared the old data against itself and cannot claim the
1633    /// domain is unchanged. Optional so already-persisted logs still decode.
1634    let unreachableReason: String?
1635
1636    init(
1637        id: UUID = UUID(),
1638        domain: String,
1639        historyEntryID: UUID?,
1640        checkedAt: Date,
1641        didChange: Bool,
1642        summaryMessage: String,
1643        alertSeverity: MonitoringAlertSeverity?,
1644        certificateWarningLevel: CertificateWarningLevel,
1645        resultSource: LookupResultSource,
1646        errorMessage: String? = nil,
1647        unreachableReason: String? = nil
1648    ) {
1649        self.id = id
1650        self.domain = domain
1651        self.historyEntryID = historyEntryID
1652        self.checkedAt = checkedAt
1653        self.didChange = didChange
1654        self.summaryMessage = summaryMessage
1655        self.alertSeverity = alertSeverity
1656        self.certificateWarningLevel = certificateWarningLevel
1657        self.resultSource = resultSource
1658        self.errorMessage = errorMessage
1659        self.unreachableReason = unreachableReason
1660    }
1661}
1662
1663struct MonitoringLog: Codable, Identifiable, Equatable {
1664    let id: UUID
1665    let timestamp: Date
1666    let trigger: MonitoringRunTrigger
1667    let domainsChecked: Int
1668    let changesFound: Int
1669    let alertsTriggered: Int
1670    let checkedDomains: [MonitoringDomainResult]
1671    let errors: [String]
1672
1673    init(
1674        id: UUID = UUID(),
1675        timestamp: Date,
1676        trigger: MonitoringRunTrigger,
1677        domainsChecked: Int,
1678        changesFound: Int,
1679        alertsTriggered: Int,
1680        checkedDomains: [MonitoringDomainResult],
1681        errors: [String] = []
1682    ) {
1683        self.id = id
1684        self.timestamp = timestamp
1685        self.trigger = trigger
1686        self.domainsChecked = domainsChecked
1687        self.changesFound = changesFound
1688        self.alertsTriggered = alertsTriggered
1689        self.checkedDomains = checkedDomains
1690        self.errors = errors
1691    }
1692
1693    var summary: String {
1694        if errors.isEmpty {
1695            return "\(changesFound) changes across \(domainsChecked) domains"
1696        }
1697        return "\(changesFound) changes, \(errors.count) errors"
1698    }
1699}
1700
1701enum EventSeverity: String, Codable, CaseIterable, Comparable, Identifiable, Sendable {
1702    case info
1703    case warning
1704    case critical
1705
1706    var id: String { rawValue }
1707
1708    static func < (lhs: EventSeverity, rhs: EventSeverity) -> Bool {
1709        lhs.rank < rhs.rank
1710    }
1711
1712    var title: String {
1713        rawValue.capitalized
1714    }
1715
1716    private var rank: Int {
1717        switch self {
1718        case .info:
1719            return 0
1720        case .warning:
1721            return 1
1722        case .critical:
1723            return 2
1724        }
1725    }
1726
1727    init(monitoringSeverity: MonitoringAlertSeverity) {
1728        switch monitoringSeverity {
1729        case .info:
1730            self = .info
1731        case .warning:
1732            self = .warning
1733        case .critical:
1734            self = .critical
1735        }
1736    }
1737}
1738
1739enum MonitoringEventType: String, Codable, CaseIterable, Identifiable, Sendable {
1740    case dnsChanged
1741    case certificateUpdated
1742    case certificateExpiring
1743    case redirectChanged
1744    case headersChanged
1745    case endpointUnreachable
1746    case monitoringFailure
1747    case changeDetected
1748    case test
1749
1750    var id: String { rawValue }
1751
1752    var title: String {
1753        switch self {
1754        case .dnsChanged:
1755            return "DNS Changed"
1756        case .certificateUpdated:
1757            return "Certificate Updated"
1758        case .certificateExpiring:
1759            return "Certificate Expiring"
1760        case .redirectChanged:
1761            return "Redirect Changed"
1762        case .headersChanged:
1763            return "Headers Changed"
1764        case .endpointUnreachable:
1765            return "Endpoint Unreachable"
1766        case .monitoringFailure:
1767            return "Monitoring Failure"
1768        case .changeDetected:
1769            return "Change Detected"
1770        case .test:
1771            return "Test Event"
1772        }
1773    }
1774}
1775
1776struct MonitoringEvent: Codable, Identifiable, Equatable, Sendable {
1777    var id: UUID
1778    var type: MonitoringEventType
1779    var severity: EventSeverity
1780    var domain: String
1781    var timestamp: Date
1782    var summary: String
1783    var details: [String: String]
1784
1785    init(
1786        id: UUID = UUID(),
1787        type: MonitoringEventType,
1788        severity: EventSeverity,
1789        domain: String,
1790        timestamp: Date = Date(),
1791        summary: String,
1792        details: [String: String] = [:]
1793    ) {
1794        self.id = id
1795        self.type = type
1796        self.severity = severity
1797        self.domain = domain
1798        self.timestamp = timestamp
1799        self.summary = summary
1800        self.details = details
1801    }
1802}
1803
1804enum IntegrationType: String, Codable, CaseIterable, Identifiable, Sendable {
1805    case webhook
1806    case slack
1807    case email
1808
1809    var id: String { rawValue }
1810
1811    var title: String {
1812        rawValue.capitalized
1813    }
1814}
1815
1816enum SMTPSecurityMode: String, Codable, CaseIterable, Identifiable, Sendable {
1817    case plain
1818    case directTLS
1819
1820    var id: String { rawValue }
1821
1822    var title: String {
1823        switch self {
1824        case .plain:
1825            return "Plain"
1826        case .directTLS:
1827            return "Direct TLS"
1828        }
1829    }
1830}
1831
1832struct WebhookIntegrationConfiguration: Codable, Equatable, Sendable {
1833    var endpointDisplayHost: String
1834    var timeoutSeconds: Double
1835    var additionalHeaders: [String: String]
1836    var credentialReference: String?
1837
1838    init(
1839        endpointDisplayHost: String = "",
1840        timeoutSeconds: Double = 15,
1841        additionalHeaders: [String: String] = [:],
1842        credentialReference: String? = nil
1843    ) {
1844        self.endpointDisplayHost = endpointDisplayHost
1845        self.timeoutSeconds = timeoutSeconds
1846        self.additionalHeaders = additionalHeaders
1847        self.credentialReference = credentialReference
1848    }
1849}
1850
1851struct SlackIntegrationConfiguration: Codable, Equatable, Sendable {
1852    var destinationLabel: String
1853    var credentialReference: String?
1854
1855    init(
1856        destinationLabel: String = "Slack",
1857        credentialReference: String? = nil
1858    ) {
1859        self.destinationLabel = destinationLabel
1860        self.credentialReference = credentialReference
1861    }
1862}
1863
1864struct EmailIntegrationConfiguration: Codable, Equatable, Sendable {
1865    var smtpHost: String
1866    var port: Int
1867    var username: String
1868    var senderAddress: String
1869    var recipientAddresses: [String]
1870    var securityMode: SMTPSecurityMode
1871    var credentialReference: String?
1872
1873    init(
1874        smtpHost: String = "",
1875        port: Int = 465,
1876        username: String = "",
1877        senderAddress: String = "",
1878        recipientAddresses: [String] = [],
1879        securityMode: SMTPSecurityMode = .directTLS,
1880        credentialReference: String? = nil
1881    ) {
1882        self.smtpHost = smtpHost
1883        self.port = port
1884        self.username = username
1885        self.senderAddress = senderAddress
1886        self.recipientAddresses = recipientAddresses
1887        self.securityMode = securityMode
1888        self.credentialReference = credentialReference
1889    }
1890}
1891
1892enum IntegrationConfiguration: Codable, Equatable, Sendable {
1893    case webhook(WebhookIntegrationConfiguration)
1894    case slack(SlackIntegrationConfiguration)
1895    case email(EmailIntegrationConfiguration)
1896
1897    private enum CodingKeys: String, CodingKey {
1898        case type
1899        case webhook
1900        case slack
1901        case email
1902    }
1903
1904    init(from decoder: Decoder) throws {
1905        let container = try decoder.container(keyedBy: CodingKeys.self)
1906        let type = try container.decode(IntegrationType.self, forKey: .type)
1907        switch type {
1908        case .webhook:
1909            self = .webhook(try container.decode(WebhookIntegrationConfiguration.self, forKey: .webhook))
1910        case .slack:
1911            self = .slack(try container.decode(SlackIntegrationConfiguration.self, forKey: .slack))
1912        case .email:
1913            self = .email(try container.decode(EmailIntegrationConfiguration.self, forKey: .email))
1914        }
1915    }
1916
1917    func encode(to encoder: Encoder) throws {
1918        var container = encoder.container(keyedBy: CodingKeys.self)
1919        switch self {
1920        case .webhook(let configuration):
1921            try container.encode(IntegrationType.webhook, forKey: .type)
1922            try container.encode(configuration, forKey: .webhook)
1923        case .slack(let configuration):
1924            try container.encode(IntegrationType.slack, forKey: .type)
1925            try container.encode(configuration, forKey: .slack)
1926        case .email(let configuration):
1927            try container.encode(IntegrationType.email, forKey: .type)
1928            try container.encode(configuration, forKey: .email)
1929        }
1930    }
1931
1932    var type: IntegrationType {
1933        switch self {
1934        case .webhook:
1935            return .webhook
1936        case .slack:
1937            return .slack
1938        case .email:
1939            return .email
1940        }
1941    }
1942}
1943
1944struct IntegrationFilterSet: Codable, Equatable, Sendable {
1945    var minimumSeverity: EventSeverity
1946    var eventTypes: Set<MonitoringEventType>
1947    var domains: [String]
1948
1949    init(
1950        minimumSeverity: EventSeverity = .warning,
1951        eventTypes: Set<MonitoringEventType> = Set(MonitoringEventType.allCases.filter { $0 != .test }),
1952        domains: [String] = []
1953    ) {
1954        self.minimumSeverity = minimumSeverity
1955        self.eventTypes = eventTypes
1956        self.domains = domains
1957    }
1958
1959    func matches(_ event: MonitoringEvent) -> Bool {
1960        guard event.severity >= minimumSeverity else {
1961            return false
1962        }
1963        guard eventTypes.isEmpty || eventTypes.contains(event.type) else {
1964            return false
1965        }
1966        guard domains.isEmpty || domains.map({ $0.lowercased() }).contains(event.domain.lowercased()) else {
1967            return false
1968        }
1969        return true
1970    }
1971}
1972
1973struct IntegrationTarget: Codable, Identifiable, Equatable, Sendable {
1974    var id: UUID
1975    var type: IntegrationType
1976    var name: String
1977    var isEnabled: Bool
1978    var configuration: IntegrationConfiguration
1979    var filters: IntegrationFilterSet
1980
1981    init(
1982        id: UUID = UUID(),
1983        type: IntegrationType,
1984        name: String,
1985        isEnabled: Bool = true,
1986        configuration: IntegrationConfiguration,
1987        filters: IntegrationFilterSet = IntegrationFilterSet()
1988    ) {
1989        self.id = id
1990        self.type = type
1991        self.name = name
1992        self.isEnabled = isEnabled
1993        self.configuration = configuration
1994        self.filters = filters
1995    }
1996}
1997
1998enum DeliveryStatus: String, Codable, CaseIterable, Identifiable, Sendable {
1999    case pending
2000    case retrying
2001    case delivered
2002    case failed
2003    case expired
2004    case skipped
2005
2006    var id: String { rawValue }
2007
2008    var title: String {
2009        rawValue.capitalized
2010    }
2011}
2012
2013struct DeliveryRecord: Codable, Identifiable, Equatable, Sendable {
2014    var id: UUID
2015    var integrationID: UUID
2016    var eventID: UUID
2017    var timestamp: Date
2018    var status: DeliveryStatus
2019    var destination: String
2020    var summary: String
2021    var failureReason: String?
2022    var attemptCount: Int
2023
2024    init(
2025        id: UUID = UUID(),
2026        integrationID: UUID,
2027        eventID: UUID,
2028        timestamp: Date = Date(),
2029        status: DeliveryStatus,
2030        destination: String,
2031        summary: String,
2032        failureReason: String? = nil,
2033        attemptCount: Int = 0
2034    ) {
2035        self.id = id
2036        self.integrationID = integrationID
2037        self.eventID = eventID
2038        self.timestamp = timestamp
2039        self.status = status
2040        self.destination = destination
2041        self.summary = summary
2042        self.failureReason = failureReason
2043        self.attemptCount = attemptCount
2044    }
2045}
2046
2047struct QueuedDelivery: Codable, Identifiable, Equatable, Sendable {
2048    var id: UUID
2049    var integrationID: UUID
2050    var event: MonitoringEvent
2051    var createdAt: Date
2052    var attemptCount: Int
2053    var nextAttemptAt: Date
2054    var lastError: String?
2055    var expiresAt: Date
2056
2057    init(
2058        id: UUID = UUID(),
2059        integrationID: UUID,
2060        event: MonitoringEvent,
2061        createdAt: Date = Date(),
2062        attemptCount: Int = 0,
2063        nextAttemptAt: Date = Date(),
2064        lastError: String? = nil,
2065        expiresAt: Date = Date().addingTimeInterval(3 * 24 * 60 * 60)
2066    ) {
2067        self.id = id
2068        self.integrationID = integrationID
2069        self.event = event
2070        self.createdAt = createdAt
2071        self.attemptCount = attemptCount
2072        self.nextAttemptAt = nextAttemptAt
2073        self.lastError = lastError
2074        self.expiresAt = expiresAt
2075    }
2076}
2077
2078// MARK: - DNS Models
2079
2080enum DNSRecordType: String, CaseIterable, Codable {
2081    case A
2082    case AAAA
2083    case MX
2084    case NS
2085    case TXT
2086    case CNAME
2087    case SOA
2088    case SRV
2089    case CAA
2090    case DS
2091    case PTR
2092
2093    var queryType: Int {
2094        switch self {
2095        case .A: return 1
2096        case .AAAA: return 28
2097        case .MX: return 15
2098        case .NS: return 2
2099        case .TXT: return 16
2100        case .CNAME: return 5
2101        case .SOA: return 6
2102        case .SRV: return 33
2103        case .CAA: return 257
2104        case .DS: return 43
2105        case .PTR: return 12
2106        }
2107    }
2108
2109    var usesRawDataValue: Bool {
2110        switch self {
2111        case .TXT, .SOA, .DS:
2112            return true
2113        default:
2114            return false
2115        }
2116    }
2117}
2118
2119struct DNSRecord: Identifiable, Codable {
2120    var id = UUID()
2121    let value: String
2122    let ttl: Int
2123}
2124
2125struct DNSSection: Identifiable, Codable {
2126    var id = UUID()
2127    let recordType: DNSRecordType
2128    var records: [DNSRecord]
2129    var wildcardRecords: [DNSRecord] = []
2130    var dnssecSigned: Bool?
2131    var error: String?
2132}
2133
2134// MARK: - SSL Models
2135
2136struct SSLCertificateInfo: Codable {
2137    struct CertChainEntry: Codable {
2138        let subject: String
2139        let issuer: String
2140    }
2141
2142    let commonName: String
2143    let subjectAltNames: [String]
2144    let issuer: String
2145    let validFrom: Date
2146    let validUntil: Date
2147    let daysUntilExpiry: Int
2148    let chainDepth: Int
2149    let tlsVersion: String?
2150    let cipherSuite: String?
2151    let chain: [CertChainEntry]
2152
2153    init(
2154        commonName: String,
2155        subjectAltNames: [String],
2156        issuer: String,
2157        validFrom: Date,
2158        validUntil: Date,
2159        daysUntilExpiry: Int,
2160        chainDepth: Int,
2161        tlsVersion: String? = nil,
2162        cipherSuite: String? = nil,
2163        chain: [CertChainEntry] = []
2164    ) {
2165        self.commonName = commonName
2166        self.subjectAltNames = subjectAltNames
2167        self.issuer = issuer
2168        self.validFrom = validFrom
2169        self.validUntil = validUntil
2170        self.daysUntilExpiry = daysUntilExpiry
2171        self.chainDepth = chainDepth
2172        self.tlsVersion = tlsVersion
2173        self.cipherSuite = cipherSuite
2174        self.chain = chain
2175    }
2176
2177    init(from decoder: Decoder) throws {
2178        let container = try decoder.container(keyedBy: CodingKeys.self)
2179        commonName = try container.decode(String.self, forKey: .commonName)
2180        subjectAltNames = try container.decode([String].self, forKey: .subjectAltNames)
2181        issuer = try container.decode(String.self, forKey: .issuer)
2182        validFrom = try container.decode(Date.self, forKey: .validFrom)
2183        validUntil = try container.decode(Date.self, forKey: .validUntil)
2184        daysUntilExpiry = try container.decode(Int.self, forKey: .daysUntilExpiry)
2185        chainDepth = try container.decode(Int.self, forKey: .chainDepth)
2186        tlsVersion = try container.decodeIfPresent(String.self, forKey: .tlsVersion)
2187        cipherSuite = try container.decodeIfPresent(String.self, forKey: .cipherSuite)
2188        chain = try container.decodeIfPresent([CertChainEntry].self, forKey: .chain) ?? []
2189    }
2190}
2191
2192// MARK: - HTTP Headers Models
2193
2194struct HTTPHeader: Identifiable, Codable {
2195    var id = UUID()
2196    let name: String
2197    let value: String
2198
2199    static let securityHeaders: Set<String> = [
2200        "strict-transport-security",
2201        "x-frame-options",
2202        "x-content-type-options",
2203        "content-security-policy",
2204        "referrer-policy"
2205    ]
2206
2207    var isSecurityHeader: Bool {
2208        Self.securityHeaders.contains(name.lowercased())
2209    }
2210}
2211
2212// MARK: - Reachability Models
2213
2214struct PortReachability: Identifiable, Codable {
2215    var id = UUID()
2216    let port: UInt16
2217    let reachable: Bool
2218    let latencyMs: Int?
2219}
2220
2221// MARK: - IP Geolocation Models
2222
2223struct IPGeolocation: Codable {
2224    let ip: String
2225    let city: String?
2226    let region: String?
2227    let countryName: String?
2228    let org: String?
2229    let latitude: Double?
2230    let longitude: Double?
2231
2232    enum CodingKeys: String, CodingKey {
2233        case ip, city, region
2234        case countryName = "country_name"
2235        case org, latitude, longitude
2236    }
2237}
2238
2239// MARK: - Ownership Models
2240
2241struct DomainOwnership: Codable, Equatable {
2242    let registrar: String?
2243    let registrant: String?
2244    let createdDate: Date?
2245    let expirationDate: Date?
2246    let status: [String]
2247    let nameservers: [String]
2248    let abuseEmail: String?
2249
2250    init(
2251        registrar: String? = nil,
2252        registrant: String? = nil,
2253        createdDate: Date? = nil,
2254        expirationDate: Date? = nil,
2255        status: [String] = [],
2256        nameservers: [String] = [],
2257        abuseEmail: String? = nil
2258    ) {
2259        self.registrar = registrar
2260        self.registrant = registrant
2261        self.createdDate = createdDate
2262        self.expirationDate = expirationDate
2263        self.status = status
2264        self.nameservers = nameservers
2265        self.abuseEmail = abuseEmail
2266    }
2267
2268    init(from decoder: Decoder) throws {
2269        let container = try decoder.container(keyedBy: CodingKeys.self)
2270        registrar = try container.decodeIfPresent(String.self, forKey: .registrar)
2271        registrant = try container.decodeIfPresent(String.self, forKey: .registrant)
2272        createdDate = try container.decodeIfPresent(Date.self, forKey: .createdDate)
2273        expirationDate = try container.decodeIfPresent(Date.self, forKey: .expirationDate)
2274        status = try container.decodeIfPresent([String].self, forKey: .status) ?? []
2275        nameservers = try container.decodeIfPresent([String].self, forKey: .nameservers) ?? []
2276        abuseEmail = try container.decodeIfPresent(String.self, forKey: .abuseEmail)
2277    }
2278}
2279
2280struct DiscoveredSubdomain: Codable, Equatable, Hashable, Identifiable, Sendable {
2281    var id: String { hostname }
2282    let hostname: String
2283    let source: String?
2284    let isExtended: Bool
2285
2286    nonisolated init(hostname: String, source: String? = nil, isExtended: Bool = false) {
2287        self.hostname = hostname
2288        self.source = source
2289        self.isExtended = isExtended
2290    }
2291}
2292
2293// MARK: - Email Security Models
2294
2295struct EmailSecurityResult: Codable {
2296    let spf: EmailSecurityRecord
2297    let dmarc: EmailSecurityRecord
2298    let dkim: EmailSecurityRecord
2299    let bimi: EmailSecurityRecord
2300    let mtaSts: MTASTSResult?
2301
2302    init(
2303        spf: EmailSecurityRecord,
2304        dmarc: EmailSecurityRecord,
2305        dkim: EmailSecurityRecord,
2306        bimi: EmailSecurityRecord = EmailSecurityRecord(found: false, value: nil),
2307        mtaSts: MTASTSResult? = nil
2308    ) {
2309        self.spf = spf
2310        self.dmarc = dmarc
2311        self.dkim = dkim
2312        self.bimi = bimi
2313        self.mtaSts = mtaSts
2314    }
2315
2316    init(from decoder: Decoder) throws {
2317        let container = try decoder.container(keyedBy: CodingKeys.self)
2318        spf = try container.decode(EmailSecurityRecord.self, forKey: .spf)
2319        dmarc = try container.decode(EmailSecurityRecord.self, forKey: .dmarc)
2320        dkim = try container.decode(EmailSecurityRecord.self, forKey: .dkim)
2321        bimi = try container.decodeIfPresent(EmailSecurityRecord.self, forKey: .bimi)
2322            ?? EmailSecurityRecord(found: false, value: nil)
2323        mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts)
2324    }
2325}
2326
2327struct EmailSecurityRecord: Codable {
2328    let found: Bool
2329    let value: String?
2330    let matchedSelector: String?
2331
2332    init(found: Bool, value: String?, matchedSelector: String? = nil) {
2333        self.found = found
2334        self.value = value
2335        self.matchedSelector = matchedSelector
2336    }
2337
2338    init(from decoder: Decoder) throws {
2339        let container = try decoder.container(keyedBy: CodingKeys.self)
2340        found = try container.decode(Bool.self, forKey: .found)
2341        value = try container.decodeIfPresent(String.self, forKey: .value)
2342        matchedSelector = try container.decodeIfPresent(String.self, forKey: .matchedSelector)
2343    }
2344}
2345
2346struct MTASTSResult: Codable {
2347    let txtFound: Bool
2348    let policyMode: String?
2349}
2350
2351// MARK: - Redirect Chain Models
2352
2353struct RedirectHop: Identifiable, Codable {
2354    var id = UUID()
2355    let stepNumber: Int
2356    let statusCode: Int
2357    let url: String
2358    let isFinal: Bool
2359}
2360
2361// MARK: - Port Scan Models
2362
2363enum PortScanKind: String, Codable {
2364    case standard
2365    case custom
2366}
2367
2368struct PortScanResult: Identifiable, Codable {
2369    var id = UUID()
2370    let port: UInt16
2371    let service: String
2372    let open: Bool
2373    var banner: String?
2374    let kind: PortScanKind
2375    let durationMs: Int?
2376
2377    nonisolated init(
2378        port: UInt16,
2379        service: String,
2380        open: Bool,
2381        banner: String? = nil,
2382        kind: PortScanKind = .standard,
2383        durationMs: Int? = nil
2384    ) {
2385        self.port = port
2386        self.service = service
2387        self.open = open
2388        self.banner = banner
2389        self.kind = kind
2390        self.durationMs = durationMs
2391    }
2392
2393    init(from decoder: Decoder) throws {
2394        let container = try decoder.container(keyedBy: CodingKeys.self)
2395        id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
2396        port = try container.decode(UInt16.self, forKey: .port)
2397        service = try container.decode(String.self, forKey: .service)
2398        open = try container.decode(Bool.self, forKey: .open)
2399        banner = try container.decodeIfPresent(String.self, forKey: .banner)
2400        kind = try container.decodeIfPresent(PortScanKind.self, forKey: .kind) ?? .standard
2401        durationMs = try container.decodeIfPresent(Int.self, forKey: .durationMs)
2402    }
2403}
2404
2405// MARK: - History Models
2406
2407struct HistoryEntry: Identifiable, Codable {
2408    var id = UUID()
2409    let domain: String
2410    let timestamp: Date
2411    var trackedDomainID: UUID?
2412    var note: String?
2413    let dnsSections: [DNSSection]
2414    let sslInfo: SSLCertificateInfo?
2415    let httpHeaders: [HTTPHeader]
2416    let reachabilityResults: [PortReachability]
2417    let ipGeolocation: IPGeolocation?
2418    var emailSecurity: EmailSecurityResult?
2419    var mtaSts: MTASTSResult?
2420    var ownership: DomainOwnership?
2421    var ownershipHistory: [DomainOwnershipHistoryEvent]
2422    var inferredProvider: InferredProviderFingerprint?
2423    var priorProviders: [String]
2424    var domainClassification: DomainClassificationSummary?
2425    var ownershipTransitions: [OwnershipTransitionEvent]
2426    var hostingTransitions: [HostingTransitionEvent]
2427    var subdomainHistory: [SubdomainHistoryEntry]
2428    var riskSignals: [IntelligenceRiskSignal]
2429    var intelligenceTimeline: [IntelligenceTimelineEvent]
2430    var ptrRecord: String?
2431    var redirectChain: [RedirectHop]
2432    var subdomains: [DiscoveredSubdomain]
2433    var extendedSubdomains: [DiscoveredSubdomain]
2434    var dnsHistory: [DNSHistoryEvent]
2435    var domainPricing: DomainPricingInsight?
2436    var reputation: DomainReputationResult?
2437    var portScanResults: [PortScanResult]
2438    var hstsPreloaded: Bool?
2439    var availabilityResult: DomainAvailabilityResult?
2440    var suggestions: [DomainSuggestionResult]
2441    var appVersion: String
2442    var resultSource: LookupResultSource
2443    var dataSources: [String]
2444    var provenanceBySection: [LookupSectionKind: SectionProvenance]
2445    var availabilityConfidence: ConfidenceLevel?
2446    var ownershipConfidence: ConfidenceLevel?
2447    var subdomainConfidence: ConfidenceLevel?
2448    var emailSecurityConfidence: ConfidenceLevel?
2449    var geolocationConfidence: ConfidenceLevel?
2450    var errorDetails: [LookupSectionKind: InspectionFailure]
2451    var isPartialSnapshot: Bool
2452    var validationIssues: [String]
2453    var resolverDisplayName: String
2454    var resolverURLString: String
2455    var totalLookupDurationMs: Int?
2456    var primaryIP: String?
2457    var finalRedirectURL: String?
2458    var tlsStatusSummary: String?
2459    var emailSecuritySummary: String?
2460    var httpGradeSummary: String?
2461    var changeSummary: DomainChangeSummary?
2462    var snapshotIndex: Int?
2463    var previousSnapshotID: UUID?
2464    var changeCount: Int
2465    var severitySummary: ChangeSeverity?
2466    var sslError: String?
2467    var httpHeadersError: String?
2468    var reachabilityError: String?
2469    var ipGeolocationError: String?
2470    var emailSecurityError: String?
2471    var ownershipError: String?
2472    var ownershipHistoryError: String?
2473    var ptrError: String?
2474    var redirectChainError: String?
2475    var subdomainsError: String?
2476    var extendedSubdomainsError: String?
2477    var dnsHistoryError: String?
2478    var domainPricingError: String?
2479    var reputationError: String?
2480    var portScanError: String?
2481
2482    // A full inspection produces 73 stored properties, and passing them as one
2483    // flat argument list made a 72-parameter initializer no call site could read
2484    // (SonarCloud swift:S107). They are grouped below by what they describe.
2485    //
2486    // The stored properties stay flat, deliberately. This type is persisted to
2487    // UserDefaults and read back out of backup files, and both decode paths drop
2488    // entries that will not parse rather than raising  so nesting a value would
2489    // change the encoded shape and silently discard history written by an older
2490    // build. These groups exist at the call boundary only; the JSON is unchanged,
2491    // which HistoryEntryCodableTests pins.
2492
2493    /// What was inspected, and when.
2494    struct Identity {
2495        var domain: String
2496        var timestamp: Date
2497        var trackedDomainID: UUID? = nil
2498        var note: String? = nil
2499    }
2500
2501    /// The live probe results for this snapshot.
2502    struct Inspection {
2503        var dnsSections: [DNSSection]
2504        var sslInfo: SSLCertificateInfo?
2505        var httpHeaders: [HTTPHeader]
2506        var reachabilityResults: [PortReachability]
2507        var ipGeolocation: IPGeolocation?
2508        var emailSecurity: EmailSecurityResult? = nil
2509        var mtaSts: MTASTSResult? = nil
2510        var ptrRecord: String? = nil
2511        var redirectChain: [RedirectHop] = []
2512        var subdomains: [DiscoveredSubdomain] = []
2513        var extendedSubdomains: [DiscoveredSubdomain] = []
2514        var dnsHistory: [DNSHistoryEvent] = []
2515        var portScanResults: [PortScanResult] = []
2516        var hstsPreloaded: Bool? = nil
2517        var availabilityResult: DomainAvailabilityResult? = nil
2518        var suggestions: [DomainSuggestionResult] = []
2519
2520    }
2521
2522    /// Who owns the domain and where it is hosted.
2523    struct Registration {
2524        var ownership: DomainOwnership? = nil
2525        var ownershipHistory: [DomainOwnershipHistoryEvent] = []
2526        var inferredProvider: InferredProviderFingerprint? = nil
2527        var priorProviders: [String] = []
2528        var domainClassification: DomainClassificationSummary? = nil
2529        var ownershipTransitions: [OwnershipTransitionEvent] = []
2530        var hostingTransitions: [HostingTransitionEvent] = []
2531        var domainPricing: DomainPricingInsight? = nil
2532    }
2533
2534    /// Derived signals rather than direct observations.
2535    struct Intelligence {
2536        var subdomainHistory: [SubdomainHistoryEntry] = []
2537        var riskSignals: [IntelligenceRiskSignal] = []
2538        var intelligenceTimeline: [IntelligenceTimelineEvent] = []
2539        var reputation: DomainReputationResult? = nil
2540    }
2541
2542    /// Where the data came from, and how much to trust it.
2543    struct Provenance {
2544        var appVersion: String = "2.7.0"
2545        var resultSource: LookupResultSource = .snapshot
2546        var dataSources: [String] = []
2547        var provenanceBySection: [LookupSectionKind: SectionProvenance] = [:]
2548        var availabilityConfidence: ConfidenceLevel? = nil
2549        var ownershipConfidence: ConfidenceLevel? = nil
2550        var subdomainConfidence: ConfidenceLevel? = nil
2551        var emailSecurityConfidence: ConfidenceLevel? = nil
2552        var geolocationConfidence: ConfidenceLevel? = nil
2553        var isPartialSnapshot: Bool = false
2554        var validationIssues: [String] = []
2555        var resolverDisplayName: String
2556        var resolverURLString: String
2557        var totalLookupDurationMs: Int? = nil
2558
2559    }
2560
2561    /// Precomputed display values and change tracking.
2562    struct Summary {
2563        var primaryIP: String? = nil
2564        var finalRedirectURL: String? = nil
2565        var tlsStatusSummary: String? = nil
2566        var emailSecuritySummary: String? = nil
2567        var httpGradeSummary: String? = nil
2568        var changeSummary: DomainChangeSummary? = nil
2569        var snapshotIndex: Int? = nil
2570        var previousSnapshotID: UUID? = nil
2571        var changeCount: Int = 0
2572        var severitySummary: ChangeSeverity? = nil
2573    }
2574
2575    /// Per-section failures: a snapshot records what it could not collect.
2576    struct Failures {
2577        var errorDetails: [LookupSectionKind: InspectionFailure] = [:]
2578        var sslError: String? = nil
2579        var httpHeadersError: String? = nil
2580        var reachabilityError: String? = nil
2581        var ipGeolocationError: String? = nil
2582        var emailSecurityError: String? = nil
2583        var ownershipError: String? = nil
2584        var ownershipHistoryError: String? = nil
2585        var ptrError: String? = nil
2586        var redirectChainError: String? = nil
2587        var subdomainsError: String? = nil
2588        var extendedSubdomainsError: String? = nil
2589        var dnsHistoryError: String? = nil
2590        var domainPricingError: String? = nil
2591        var reputationError: String? = nil
2592        var portScanError: String? = nil
2593
2594    }
2595
2596    init(identity: Identity,
2597         inspection: Inspection,
2598         registration: Registration = Registration(),
2599         intelligence: Intelligence = Intelligence(),
2600         provenance: Provenance,
2601         summary: Summary = Summary(),
2602         failures: Failures = Failures()) {
2603        self.domain = identity.domain
2604        self.timestamp = identity.timestamp
2605        self.trackedDomainID = identity.trackedDomainID
2606        self.note = identity.note
2607        self.dnsSections = inspection.dnsSections
2608        self.sslInfo = inspection.sslInfo
2609        self.httpHeaders = inspection.httpHeaders
2610        self.reachabilityResults = inspection.reachabilityResults
2611        self.ipGeolocation = inspection.ipGeolocation
2612        self.emailSecurity = inspection.emailSecurity
2613        self.mtaSts = inspection.mtaSts ?? inspection.emailSecurity?.mtaSts
2614        self.ptrRecord = inspection.ptrRecord
2615        self.redirectChain = inspection.redirectChain
2616        self.subdomains = inspection.subdomains
2617        self.extendedSubdomains = inspection.extendedSubdomains
2618        self.dnsHistory = inspection.dnsHistory
2619        self.portScanResults = inspection.portScanResults
2620        self.hstsPreloaded = inspection.hstsPreloaded
2621        self.availabilityResult = inspection.availabilityResult
2622        self.suggestions = inspection.suggestions
2623        self.ownership = registration.ownership
2624        self.ownershipHistory = registration.ownershipHistory
2625        self.inferredProvider = registration.inferredProvider
2626        self.priorProviders = registration.priorProviders
2627        self.domainClassification = registration.domainClassification
2628        self.ownershipTransitions = registration.ownershipTransitions
2629        self.hostingTransitions = registration.hostingTransitions
2630        self.domainPricing = registration.domainPricing
2631        self.subdomainHistory = intelligence.subdomainHistory
2632        self.riskSignals = intelligence.riskSignals
2633        self.intelligenceTimeline = intelligence.intelligenceTimeline
2634        self.reputation = intelligence.reputation
2635        self.appVersion = provenance.appVersion
2636        self.resultSource = provenance.resultSource
2637        self.dataSources = provenance.dataSources
2638        self.provenanceBySection = provenance.provenanceBySection
2639        self.availabilityConfidence = provenance.availabilityConfidence
2640        self.ownershipConfidence = provenance.ownershipConfidence
2641        self.subdomainConfidence = provenance.subdomainConfidence
2642        self.emailSecurityConfidence = provenance.emailSecurityConfidence
2643        self.geolocationConfidence = provenance.geolocationConfidence
2644        self.isPartialSnapshot = provenance.isPartialSnapshot
2645        self.validationIssues = provenance.validationIssues
2646        self.resolverDisplayName = provenance.resolverDisplayName
2647        self.resolverURLString = provenance.resolverURLString
2648        self.totalLookupDurationMs = provenance.totalLookupDurationMs
2649        self.primaryIP = summary.primaryIP
2650        self.finalRedirectURL = summary.finalRedirectURL
2651        self.tlsStatusSummary = summary.tlsStatusSummary
2652        self.emailSecuritySummary = summary.emailSecuritySummary
2653        self.httpGradeSummary = summary.httpGradeSummary
2654        self.changeSummary = summary.changeSummary
2655        self.snapshotIndex = summary.snapshotIndex
2656        self.previousSnapshotID = summary.previousSnapshotID
2657        self.changeCount = summary.changeCount
2658        self.severitySummary = summary.severitySummary
2659        self.errorDetails = failures.errorDetails
2660        self.sslError = failures.sslError
2661        self.httpHeadersError = failures.httpHeadersError
2662        self.reachabilityError = failures.reachabilityError
2663        self.ipGeolocationError = failures.ipGeolocationError
2664        self.emailSecurityError = failures.emailSecurityError
2665        self.ownershipError = failures.ownershipError
2666        self.ownershipHistoryError = failures.ownershipHistoryError
2667        self.ptrError = failures.ptrError
2668        self.redirectChainError = failures.redirectChainError
2669        self.subdomainsError = failures.subdomainsError
2670        self.extendedSubdomainsError = failures.extendedSubdomainsError
2671        self.dnsHistoryError = failures.dnsHistoryError
2672        self.domainPricingError = failures.domainPricingError
2673        self.reputationError = failures.reputationError
2674        self.portScanError = failures.portScanError
2675    }
2676
2677    init(from decoder: Decoder) throws {
2678        let container = try decoder.container(keyedBy: CodingKeys.self)
2679        id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
2680        domain = try container.decodeIfPresent(String.self, forKey: .domain) ?? "unknown-domain"
2681        timestamp = try container.decodeIfPresent(Date.self, forKey: .timestamp) ?? .distantPast
2682        trackedDomainID = try container.decodeIfPresent(UUID.self, forKey: .trackedDomainID)
2683        note = try container.decodeIfPresent(String.self, forKey: .note)
2684        dnsSections = try container.decodeIfPresent([DNSSection].self, forKey: .dnsSections) ?? []
2685        sslInfo = try container.decodeIfPresent(SSLCertificateInfo.self, forKey: .sslInfo)
2686        httpHeaders = try container.decodeIfPresent([HTTPHeader].self, forKey: .httpHeaders) ?? []
2687        reachabilityResults = try container.decodeIfPresent([PortReachability].self, forKey: .reachabilityResults) ?? []
2688        ipGeolocation = try container.decodeIfPresent(IPGeolocation.self, forKey: .ipGeolocation)
2689        emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity)
2690        mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts
2691        ownership = try container.decodeIfPresent(DomainOwnership.self, forKey: .ownership)
2692        ownershipHistory = try container.decodeIfPresent([DomainOwnershipHistoryEvent].self, forKey: .ownershipHistory) ?? []
2693        inferredProvider = try container.decodeIfPresent(InferredProviderFingerprint.self, forKey: .inferredProvider)
2694        priorProviders = try container.decodeIfPresent([String].self, forKey: .priorProviders) ?? []
2695        domainClassification = try container.decodeIfPresent(DomainClassificationSummary.self, forKey: .domainClassification)
2696        ownershipTransitions = try container.decodeIfPresent([OwnershipTransitionEvent].self, forKey: .ownershipTransitions) ?? []
2697        hostingTransitions = try container.decodeIfPresent([HostingTransitionEvent].self, forKey: .hostingTransitions) ?? []
2698        subdomainHistory = try container.decodeIfPresent([SubdomainHistoryEntry].self, forKey: .subdomainHistory) ?? []
2699        riskSignals = try container.decodeIfPresent([IntelligenceRiskSignal].self, forKey: .riskSignals) ?? []
2700        intelligenceTimeline = try container.decodeIfPresent([IntelligenceTimelineEvent].self, forKey: .intelligenceTimeline) ?? []
2701        ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord)
2702        redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? []
2703        subdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .subdomains) ?? []
2704        extendedSubdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .extendedSubdomains) ?? []
2705        dnsHistory = try container.decodeIfPresent([DNSHistoryEvent].self, forKey: .dnsHistory) ?? []
2706        domainPricing = try container.decodeIfPresent(DomainPricingInsight.self, forKey: .domainPricing)
2707        reputation = try container.decodeIfPresent(DomainReputationResult.self, forKey: .reputation)
2708        portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? []
2709        hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded)
2710        availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult)
2711        suggestions = try container.decodeIfPresent([DomainSuggestionResult].self, forKey: .suggestions) ?? []
2712        appVersion = try container.decodeIfPresent(String.self, forKey: .appVersion) ?? "2.6.0"
2713        resultSource = try container.decodeIfPresent(LookupResultSource.self, forKey: .resultSource) ?? .snapshot
2714        dataSources = try container.decodeIfPresent([String].self, forKey: .dataSources) ?? []
2715        provenanceBySection = try container.decodeIfPresent([LookupSectionKind: SectionProvenance].self, forKey: .provenanceBySection) ?? [:]
2716        availabilityConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .availabilityConfidence)
2717        ownershipConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .ownershipConfidence)
2718        subdomainConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .subdomainConfidence)
2719        emailSecurityConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .emailSecurityConfidence)
2720        geolocationConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .geolocationConfidence)
2721        errorDetails = try container.decodeIfPresent([LookupSectionKind: InspectionFailure].self, forKey: .errorDetails) ?? [:]
2722        validationIssues = try container.decodeIfPresent([String].self, forKey: .validationIssues) ?? HistoryEntry.defaultValidationIssues(domain: domain, timestamp: timestamp)
2723        isPartialSnapshot = try container.decodeIfPresent(Bool.self, forKey: .isPartialSnapshot) ?? !validationIssues.isEmpty
2724        resolverDisplayName = try container.decodeIfPresent(String.self, forKey: .resolverDisplayName) ?? "Cloudflare"
2725        resolverURLString = try container.decodeIfPresent(String.self, forKey: .resolverURLString) ?? DNSResolverOption.defaultURLString
2726        totalLookupDurationMs = try container.decodeIfPresent(Int.self, forKey: .totalLookupDurationMs)
2727        primaryIP = try container.decodeIfPresent(String.self, forKey: .primaryIP)
2728        finalRedirectURL = try container.decodeIfPresent(String.self, forKey: .finalRedirectURL)
2729        tlsStatusSummary = try container.decodeIfPresent(String.self, forKey: .tlsStatusSummary)
2730        emailSecuritySummary = try container.decodeIfPresent(String.self, forKey: .emailSecuritySummary)
2731        httpGradeSummary = try container.decodeIfPresent(String.self, forKey: .httpGradeSummary)
2732        changeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .changeSummary)
2733        snapshotIndex = try container.decodeIfPresent(Int.self, forKey: .snapshotIndex)
2734        previousSnapshotID = try container.decodeIfPresent(UUID.self, forKey: .previousSnapshotID)
2735        changeCount = try container.decodeIfPresent(Int.self, forKey: .changeCount)
2736            ?? changeSummary?.changedSections.count
2737            ?? 0
2738        severitySummary = try container.decodeIfPresent(ChangeSeverity.self, forKey: .severitySummary)
2739            ?? changeSummary?.severity
2740        sslError = try container.decodeIfPresent(String.self, forKey: .sslError)
2741        httpHeadersError = try container.decodeIfPresent(String.self, forKey: .httpHeadersError)
2742        reachabilityError = try container.decodeIfPresent(String.self, forKey: .reachabilityError)
2743        ipGeolocationError = try container.decodeIfPresent(String.self, forKey: .ipGeolocationError)
2744        emailSecurityError = try container.decodeIfPresent(String.self, forKey: .emailSecurityError)
2745        ownershipError = try container.decodeIfPresent(String.self, forKey: .ownershipError)
2746        ownershipHistoryError = try container.decodeIfPresent(String.self, forKey: .ownershipHistoryError)
2747        ptrError = try container.decodeIfPresent(String.self, forKey: .ptrError)
2748        redirectChainError = try container.decodeIfPresent(String.self, forKey: .redirectChainError)
2749        subdomainsError = try container.decodeIfPresent(String.self, forKey: .subdomainsError)
2750        extendedSubdomainsError = try container.decodeIfPresent(String.self, forKey: .extendedSubdomainsError)
2751        dnsHistoryError = try container.decodeIfPresent(String.self, forKey: .dnsHistoryError)
2752        domainPricingError = try container.decodeIfPresent(String.self, forKey: .domainPricingError)
2753        reputationError = try container.decodeIfPresent(String.self, forKey: .reputationError)
2754        portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError)
2755    }
2756
2757    private static func defaultValidationIssues(domain: String, timestamp: Date) -> [String] {
2758        var issues: [String] = []
2759        if domain == "unknown-domain" {
2760            issues.append("Missing domain in stored snapshot")
2761        }
2762        if timestamp == .distantPast {
2763            issues.append("Missing collection timestamp in stored snapshot")
2764        }
2765        return issues
2766    }
2767
2768    var snapshotSummary: SnapshotSummary {
2769        SnapshotSummary(
2770            id: id,
2771            domain: domain,
2772            timestamp: timestamp,
2773            trackedDomainID: trackedDomainID,
2774            snapshotIndex: snapshotIndex,
2775            previousSnapshotID: previousSnapshotID,
2776            changeCount: changeCount,
2777            severitySummary: severitySummary ?? changeSummary?.severity,
2778            changeSummaryMessage: changeSummary?.message,
2779            availability: availabilityResult?.status,
2780            primaryIP: primaryIP,
2781            tlsStatus: tlsStatusSummary,
2782            riskScore: changeSummary?.riskAssessment?.score,
2783            historyEntryID: id
2784        )
2785    }
2786}
2787
2788// MARK: - Cloudflare DNS-over-HTTPS Response
2789
2790struct CloudflareDNSResponse: Decodable {
2791    let status: Int
2792    let authenticatedData: Bool?
2793    let answer: [CloudflareDNSAnswer]?
2794
2795    enum CodingKeys: String, CodingKey {
2796        case status = "Status"
2797        case authenticatedData = "AD"
2798        case answer = "Answer"
2799    }
2800
2801    struct CloudflareDNSAnswer: Decodable {
2802        let name: String
2803        let type: Int
2804        let ttl: Int
2805        let data: String
2806
2807        enum CodingKeys: String, CodingKey {
2808            case name, type
2809            case ttl = "TTL"
2810            case data
2811        }
2812    }
2813}