krz/domain-dig

an ios app for DNS & SSL analysis

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

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