krz/domain-dig

an ios app for DNS & SSL analysis

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

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