krz/domain-dig

an ios app for DNS & SSL analysis

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

v4.7.0: 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
1548    init(
1549        id: UUID = UUID(),
1550        domain: String,
1551        historyEntryID: UUID?,
1552        checkedAt: Date,
1553        didChange: Bool,
1554        summaryMessage: String,
1555        alertSeverity: MonitoringAlertSeverity?,
1556        certificateWarningLevel: CertificateWarningLevel,
1557        resultSource: LookupResultSource,
1558        errorMessage: String? = nil
1559    ) {
1560        self.id = id
1561        self.domain = domain
1562        self.historyEntryID = historyEntryID
1563        self.checkedAt = checkedAt
1564        self.didChange = didChange
1565        self.summaryMessage = summaryMessage
1566        self.alertSeverity = alertSeverity
1567        self.certificateWarningLevel = certificateWarningLevel
1568        self.resultSource = resultSource
1569        self.errorMessage = errorMessage
1570    }
1571}
1572
1573struct MonitoringLog: Codable, Identifiable, Equatable {
1574    let id: UUID
1575    let timestamp: Date
1576    let trigger: MonitoringRunTrigger
1577    let domainsChecked: Int
1578    let changesFound: Int
1579    let alertsTriggered: Int
1580    let checkedDomains: [MonitoringDomainResult]
1581    let errors: [String]
1582
1583    init(
1584        id: UUID = UUID(),
1585        timestamp: Date,
1586        trigger: MonitoringRunTrigger,
1587        domainsChecked: Int,
1588        changesFound: Int,
1589        alertsTriggered: Int,
1590        checkedDomains: [MonitoringDomainResult],
1591        errors: [String] = []
1592    ) {
1593        self.id = id
1594        self.timestamp = timestamp
1595        self.trigger = trigger
1596        self.domainsChecked = domainsChecked
1597        self.changesFound = changesFound
1598        self.alertsTriggered = alertsTriggered
1599        self.checkedDomains = checkedDomains
1600        self.errors = errors
1601    }
1602
1603    var summary: String {
1604        if errors.isEmpty {
1605            return "\(changesFound) changes across \(domainsChecked) domains"
1606        }
1607        return "\(changesFound) changes, \(errors.count) errors"
1608    }
1609}
1610
1611enum EventSeverity: String, Codable, CaseIterable, Comparable, Identifiable, Sendable {
1612    case info
1613    case warning
1614    case critical
1615
1616    var id: String { rawValue }
1617
1618    static func < (lhs: EventSeverity, rhs: EventSeverity) -> Bool {
1619        lhs.rank < rhs.rank
1620    }
1621
1622    var title: String {
1623        rawValue.capitalized
1624    }
1625
1626    private var rank: Int {
1627        switch self {
1628        case .info:
1629            return 0
1630        case .warning:
1631            return 1
1632        case .critical:
1633            return 2
1634        }
1635    }
1636
1637    init(monitoringSeverity: MonitoringAlertSeverity) {
1638        switch monitoringSeverity {
1639        case .info:
1640            self = .info
1641        case .warning:
1642            self = .warning
1643        case .critical:
1644            self = .critical
1645        }
1646    }
1647}
1648
1649enum MonitoringEventType: String, Codable, CaseIterable, Identifiable, Sendable {
1650    case dnsChanged
1651    case certificateUpdated
1652    case certificateExpiring
1653    case redirectChanged
1654    case headersChanged
1655    case endpointUnreachable
1656    case monitoringFailure
1657    case changeDetected
1658    case test
1659
1660    var id: String { rawValue }
1661
1662    var title: String {
1663        switch self {
1664        case .dnsChanged:
1665            return "DNS Changed"
1666        case .certificateUpdated:
1667            return "Certificate Updated"
1668        case .certificateExpiring:
1669            return "Certificate Expiring"
1670        case .redirectChanged:
1671            return "Redirect Changed"
1672        case .headersChanged:
1673            return "Headers Changed"
1674        case .endpointUnreachable:
1675            return "Endpoint Unreachable"
1676        case .monitoringFailure:
1677            return "Monitoring Failure"
1678        case .changeDetected:
1679            return "Change Detected"
1680        case .test:
1681            return "Test Event"
1682        }
1683    }
1684}
1685
1686struct MonitoringEvent: Codable, Identifiable, Equatable, Sendable {
1687    var id: UUID
1688    var type: MonitoringEventType
1689    var severity: EventSeverity
1690    var domain: String
1691    var timestamp: Date
1692    var summary: String
1693    var details: [String: String]
1694
1695    init(
1696        id: UUID = UUID(),
1697        type: MonitoringEventType,
1698        severity: EventSeverity,
1699        domain: String,
1700        timestamp: Date = Date(),
1701        summary: String,
1702        details: [String: String] = [:]
1703    ) {
1704        self.id = id
1705        self.type = type
1706        self.severity = severity
1707        self.domain = domain
1708        self.timestamp = timestamp
1709        self.summary = summary
1710        self.details = details
1711    }
1712}
1713
1714enum IntegrationType: String, Codable, CaseIterable, Identifiable, Sendable {
1715    case webhook
1716    case slack
1717    case email
1718
1719    var id: String { rawValue }
1720
1721    var title: String {
1722        rawValue.capitalized
1723    }
1724}
1725
1726enum SMTPSecurityMode: String, Codable, CaseIterable, Identifiable, Sendable {
1727    case plain
1728    case directTLS
1729
1730    var id: String { rawValue }
1731
1732    var title: String {
1733        switch self {
1734        case .plain:
1735            return "Plain"
1736        case .directTLS:
1737            return "Direct TLS"
1738        }
1739    }
1740}
1741
1742struct WebhookIntegrationConfiguration: Codable, Equatable, Sendable {
1743    var endpointDisplayHost: String
1744    var timeoutSeconds: Double
1745    var additionalHeaders: [String: String]
1746    var credentialReference: String?
1747
1748    init(
1749        endpointDisplayHost: String = "",
1750        timeoutSeconds: Double = 15,
1751        additionalHeaders: [String: String] = [:],
1752        credentialReference: String? = nil
1753    ) {
1754        self.endpointDisplayHost = endpointDisplayHost
1755        self.timeoutSeconds = timeoutSeconds
1756        self.additionalHeaders = additionalHeaders
1757        self.credentialReference = credentialReference
1758    }
1759}
1760
1761struct SlackIntegrationConfiguration: Codable, Equatable, Sendable {
1762    var destinationLabel: String
1763    var credentialReference: String?
1764
1765    init(
1766        destinationLabel: String = "Slack",
1767        credentialReference: String? = nil
1768    ) {
1769        self.destinationLabel = destinationLabel
1770        self.credentialReference = credentialReference
1771    }
1772}
1773
1774struct EmailIntegrationConfiguration: Codable, Equatable, Sendable {
1775    var smtpHost: String
1776    var port: Int
1777    var username: String
1778    var senderAddress: String
1779    var recipientAddresses: [String]
1780    var securityMode: SMTPSecurityMode
1781    var credentialReference: String?
1782
1783    init(
1784        smtpHost: String = "",
1785        port: Int = 465,
1786        username: String = "",
1787        senderAddress: String = "",
1788        recipientAddresses: [String] = [],
1789        securityMode: SMTPSecurityMode = .directTLS,
1790        credentialReference: String? = nil
1791    ) {
1792        self.smtpHost = smtpHost
1793        self.port = port
1794        self.username = username
1795        self.senderAddress = senderAddress
1796        self.recipientAddresses = recipientAddresses
1797        self.securityMode = securityMode
1798        self.credentialReference = credentialReference
1799    }
1800}
1801
1802enum IntegrationConfiguration: Codable, Equatable, Sendable {
1803    case webhook(WebhookIntegrationConfiguration)
1804    case slack(SlackIntegrationConfiguration)
1805    case email(EmailIntegrationConfiguration)
1806
1807    private enum CodingKeys: String, CodingKey {
1808        case type
1809        case webhook
1810        case slack
1811        case email
1812    }
1813
1814    init(from decoder: Decoder) throws {
1815        let container = try decoder.container(keyedBy: CodingKeys.self)
1816        let type = try container.decode(IntegrationType.self, forKey: .type)
1817        switch type {
1818        case .webhook:
1819            self = .webhook(try container.decode(WebhookIntegrationConfiguration.self, forKey: .webhook))
1820        case .slack:
1821            self = .slack(try container.decode(SlackIntegrationConfiguration.self, forKey: .slack))
1822        case .email:
1823            self = .email(try container.decode(EmailIntegrationConfiguration.self, forKey: .email))
1824        }
1825    }
1826
1827    func encode(to encoder: Encoder) throws {
1828        var container = encoder.container(keyedBy: CodingKeys.self)
1829        switch self {
1830        case .webhook(let configuration):
1831            try container.encode(IntegrationType.webhook, forKey: .type)
1832            try container.encode(configuration, forKey: .webhook)
1833        case .slack(let configuration):
1834            try container.encode(IntegrationType.slack, forKey: .type)
1835            try container.encode(configuration, forKey: .slack)
1836        case .email(let configuration):
1837            try container.encode(IntegrationType.email, forKey: .type)
1838            try container.encode(configuration, forKey: .email)
1839        }
1840    }
1841
1842    var type: IntegrationType {
1843        switch self {
1844        case .webhook:
1845            return .webhook
1846        case .slack:
1847            return .slack
1848        case .email:
1849            return .email
1850        }
1851    }
1852}
1853
1854struct IntegrationFilterSet: Codable, Equatable, Sendable {
1855    var minimumSeverity: EventSeverity
1856    var eventTypes: Set<MonitoringEventType>
1857    var domains: [String]
1858
1859    init(
1860        minimumSeverity: EventSeverity = .warning,
1861        eventTypes: Set<MonitoringEventType> = Set(MonitoringEventType.allCases.filter { $0 != .test }),
1862        domains: [String] = []
1863    ) {
1864        self.minimumSeverity = minimumSeverity
1865        self.eventTypes = eventTypes
1866        self.domains = domains
1867    }
1868
1869    func matches(_ event: MonitoringEvent) -> Bool {
1870        guard event.severity >= minimumSeverity else {
1871            return false
1872        }
1873        guard eventTypes.isEmpty || eventTypes.contains(event.type) else {
1874            return false
1875        }
1876        guard domains.isEmpty || domains.map({ $0.lowercased() }).contains(event.domain.lowercased()) else {
1877            return false
1878        }
1879        return true
1880    }
1881}
1882
1883struct IntegrationTarget: Codable, Identifiable, Equatable, Sendable {
1884    var id: UUID
1885    var type: IntegrationType
1886    var name: String
1887    var isEnabled: Bool
1888    var configuration: IntegrationConfiguration
1889    var filters: IntegrationFilterSet
1890
1891    init(
1892        id: UUID = UUID(),
1893        type: IntegrationType,
1894        name: String,
1895        isEnabled: Bool = true,
1896        configuration: IntegrationConfiguration,
1897        filters: IntegrationFilterSet = IntegrationFilterSet()
1898    ) {
1899        self.id = id
1900        self.type = type
1901        self.name = name
1902        self.isEnabled = isEnabled
1903        self.configuration = configuration
1904        self.filters = filters
1905    }
1906}
1907
1908enum DeliveryStatus: String, Codable, CaseIterable, Identifiable, Sendable {
1909    case pending
1910    case retrying
1911    case delivered
1912    case failed
1913    case expired
1914    case skipped
1915
1916    var id: String { rawValue }
1917
1918    var title: String {
1919        rawValue.capitalized
1920    }
1921}
1922
1923struct DeliveryRecord: Codable, Identifiable, Equatable, Sendable {
1924    var id: UUID
1925    var integrationID: UUID
1926    var eventID: UUID
1927    var timestamp: Date
1928    var status: DeliveryStatus
1929    var destination: String
1930    var summary: String
1931    var failureReason: String?
1932    var attemptCount: Int
1933
1934    init(
1935        id: UUID = UUID(),
1936        integrationID: UUID,
1937        eventID: UUID,
1938        timestamp: Date = Date(),
1939        status: DeliveryStatus,
1940        destination: String,
1941        summary: String,
1942        failureReason: String? = nil,
1943        attemptCount: Int = 0
1944    ) {
1945        self.id = id
1946        self.integrationID = integrationID
1947        self.eventID = eventID
1948        self.timestamp = timestamp
1949        self.status = status
1950        self.destination = destination
1951        self.summary = summary
1952        self.failureReason = failureReason
1953        self.attemptCount = attemptCount
1954    }
1955}
1956
1957struct QueuedDelivery: Codable, Identifiable, Equatable, Sendable {
1958    var id: UUID
1959    var integrationID: UUID
1960    var event: MonitoringEvent
1961    var createdAt: Date
1962    var attemptCount: Int
1963    var nextAttemptAt: Date
1964    var lastError: String?
1965    var expiresAt: Date
1966
1967    init(
1968        id: UUID = UUID(),
1969        integrationID: UUID,
1970        event: MonitoringEvent,
1971        createdAt: Date = Date(),
1972        attemptCount: Int = 0,
1973        nextAttemptAt: Date = Date(),
1974        lastError: String? = nil,
1975        expiresAt: Date = Date().addingTimeInterval(3 * 24 * 60 * 60)
1976    ) {
1977        self.id = id
1978        self.integrationID = integrationID
1979        self.event = event
1980        self.createdAt = createdAt
1981        self.attemptCount = attemptCount
1982        self.nextAttemptAt = nextAttemptAt
1983        self.lastError = lastError
1984        self.expiresAt = expiresAt
1985    }
1986}
1987
1988// MARK: - DNS Models
1989
1990enum DNSRecordType: String, CaseIterable, Codable {
1991    case A
1992    case AAAA
1993    case MX
1994    case NS
1995    case TXT
1996    case CNAME
1997    case SOA
1998    case SRV
1999    case CAA
2000    case DS
2001    case PTR
2002
2003    var queryType: Int {
2004        switch self {
2005        case .A: return 1
2006        case .AAAA: return 28
2007        case .MX: return 15
2008        case .NS: return 2
2009        case .TXT: return 16
2010        case .CNAME: return 5
2011        case .SOA: return 6
2012        case .SRV: return 33
2013        case .CAA: return 257
2014        case .DS: return 43
2015        case .PTR: return 12
2016        }
2017    }
2018
2019    var usesRawDataValue: Bool {
2020        switch self {
2021        case .TXT, .SOA, .DS:
2022            return true
2023        default:
2024            return false
2025        }
2026    }
2027}
2028
2029struct DNSRecord: Identifiable, Codable {
2030    var id = UUID()
2031    let value: String
2032    let ttl: Int
2033}
2034
2035struct DNSSection: Identifiable, Codable {
2036    var id = UUID()
2037    let recordType: DNSRecordType
2038    var records: [DNSRecord]
2039    var wildcardRecords: [DNSRecord] = []
2040    var dnssecSigned: Bool?
2041    var error: String?
2042}
2043
2044// MARK: - SSL Models
2045
2046struct SSLCertificateInfo: Codable {
2047    struct CertChainEntry: Codable {
2048        let subject: String
2049        let issuer: String
2050    }
2051
2052    let commonName: String
2053    let subjectAltNames: [String]
2054    let issuer: String
2055    let validFrom: Date
2056    let validUntil: Date
2057    let daysUntilExpiry: Int
2058    let chainDepth: Int
2059    let tlsVersion: String?
2060    let cipherSuite: String?
2061    let chain: [CertChainEntry]
2062
2063    init(
2064        commonName: String,
2065        subjectAltNames: [String],
2066        issuer: String,
2067        validFrom: Date,
2068        validUntil: Date,
2069        daysUntilExpiry: Int,
2070        chainDepth: Int,
2071        tlsVersion: String? = nil,
2072        cipherSuite: String? = nil,
2073        chain: [CertChainEntry] = []
2074    ) {
2075        self.commonName = commonName
2076        self.subjectAltNames = subjectAltNames
2077        self.issuer = issuer
2078        self.validFrom = validFrom
2079        self.validUntil = validUntil
2080        self.daysUntilExpiry = daysUntilExpiry
2081        self.chainDepth = chainDepth
2082        self.tlsVersion = tlsVersion
2083        self.cipherSuite = cipherSuite
2084        self.chain = chain
2085    }
2086
2087    init(from decoder: Decoder) throws {
2088        let container = try decoder.container(keyedBy: CodingKeys.self)
2089        commonName = try container.decode(String.self, forKey: .commonName)
2090        subjectAltNames = try container.decode([String].self, forKey: .subjectAltNames)
2091        issuer = try container.decode(String.self, forKey: .issuer)
2092        validFrom = try container.decode(Date.self, forKey: .validFrom)
2093        validUntil = try container.decode(Date.self, forKey: .validUntil)
2094        daysUntilExpiry = try container.decode(Int.self, forKey: .daysUntilExpiry)
2095        chainDepth = try container.decode(Int.self, forKey: .chainDepth)
2096        tlsVersion = try container.decodeIfPresent(String.self, forKey: .tlsVersion)
2097        cipherSuite = try container.decodeIfPresent(String.self, forKey: .cipherSuite)
2098        chain = try container.decodeIfPresent([CertChainEntry].self, forKey: .chain) ?? []
2099    }
2100}
2101
2102// MARK: - HTTP Headers Models
2103
2104struct HTTPHeader: Identifiable, Codable {
2105    var id = UUID()
2106    let name: String
2107    let value: String
2108
2109    static let securityHeaders: Set<String> = [
2110        "strict-transport-security",
2111        "x-frame-options",
2112        "x-content-type-options",
2113        "content-security-policy",
2114        "referrer-policy"
2115    ]
2116
2117    var isSecurityHeader: Bool {
2118        Self.securityHeaders.contains(name.lowercased())
2119    }
2120}
2121
2122// MARK: - Reachability Models
2123
2124struct PortReachability: Identifiable, Codable {
2125    var id = UUID()
2126    let port: UInt16
2127    let reachable: Bool
2128    let latencyMs: Int?
2129}
2130
2131// MARK: - IP Geolocation Models
2132
2133struct IPGeolocation: Codable {
2134    let ip: String
2135    let city: String?
2136    let region: String?
2137    let country_name: String?
2138    let org: String?
2139    let latitude: Double?
2140    let longitude: Double?
2141}
2142
2143// MARK: - Ownership Models
2144
2145struct DomainOwnership: Codable, Equatable {
2146    let registrar: String?
2147    let registrant: String?
2148    let createdDate: Date?
2149    let expirationDate: Date?
2150    let status: [String]
2151    let nameservers: [String]
2152    let abuseEmail: String?
2153
2154    init(
2155        registrar: String? = nil,
2156        registrant: String? = nil,
2157        createdDate: Date? = nil,
2158        expirationDate: Date? = nil,
2159        status: [String] = [],
2160        nameservers: [String] = [],
2161        abuseEmail: String? = nil
2162    ) {
2163        self.registrar = registrar
2164        self.registrant = registrant
2165        self.createdDate = createdDate
2166        self.expirationDate = expirationDate
2167        self.status = status
2168        self.nameservers = nameservers
2169        self.abuseEmail = abuseEmail
2170    }
2171
2172    init(from decoder: Decoder) throws {
2173        let container = try decoder.container(keyedBy: CodingKeys.self)
2174        registrar = try container.decodeIfPresent(String.self, forKey: .registrar)
2175        registrant = try container.decodeIfPresent(String.self, forKey: .registrant)
2176        createdDate = try container.decodeIfPresent(Date.self, forKey: .createdDate)
2177        expirationDate = try container.decodeIfPresent(Date.self, forKey: .expirationDate)
2178        status = try container.decodeIfPresent([String].self, forKey: .status) ?? []
2179        nameservers = try container.decodeIfPresent([String].self, forKey: .nameservers) ?? []
2180        abuseEmail = try container.decodeIfPresent(String.self, forKey: .abuseEmail)
2181    }
2182}
2183
2184struct DiscoveredSubdomain: Codable, Equatable, Hashable, Identifiable, Sendable {
2185    var id: String { hostname }
2186    let hostname: String
2187    let source: String?
2188    let isExtended: Bool
2189
2190    nonisolated init(hostname: String, source: String? = nil, isExtended: Bool = false) {
2191        self.hostname = hostname
2192        self.source = source
2193        self.isExtended = isExtended
2194    }
2195}
2196
2197// MARK: - Email Security Models
2198
2199struct EmailSecurityResult: Codable {
2200    let spf: EmailSecurityRecord
2201    let dmarc: EmailSecurityRecord
2202    let dkim: EmailSecurityRecord
2203    let bimi: EmailSecurityRecord
2204    let mtaSts: MTASTSResult?
2205
2206    init(
2207        spf: EmailSecurityRecord,
2208        dmarc: EmailSecurityRecord,
2209        dkim: EmailSecurityRecord,
2210        bimi: EmailSecurityRecord = EmailSecurityRecord(found: false, value: nil),
2211        mtaSts: MTASTSResult? = nil
2212    ) {
2213        self.spf = spf
2214        self.dmarc = dmarc
2215        self.dkim = dkim
2216        self.bimi = bimi
2217        self.mtaSts = mtaSts
2218    }
2219
2220    init(from decoder: Decoder) throws {
2221        let container = try decoder.container(keyedBy: CodingKeys.self)
2222        spf = try container.decode(EmailSecurityRecord.self, forKey: .spf)
2223        dmarc = try container.decode(EmailSecurityRecord.self, forKey: .dmarc)
2224        dkim = try container.decode(EmailSecurityRecord.self, forKey: .dkim)
2225        bimi = try container.decodeIfPresent(EmailSecurityRecord.self, forKey: .bimi)
2226            ?? EmailSecurityRecord(found: false, value: nil)
2227        mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts)
2228    }
2229}
2230
2231struct EmailSecurityRecord: Codable {
2232    let found: Bool
2233    let value: String?
2234    let matchedSelector: String?
2235
2236    init(found: Bool, value: String?, matchedSelector: String? = nil) {
2237        self.found = found
2238        self.value = value
2239        self.matchedSelector = matchedSelector
2240    }
2241
2242    init(from decoder: Decoder) throws {
2243        let container = try decoder.container(keyedBy: CodingKeys.self)
2244        found = try container.decode(Bool.self, forKey: .found)
2245        value = try container.decodeIfPresent(String.self, forKey: .value)
2246        matchedSelector = try container.decodeIfPresent(String.self, forKey: .matchedSelector)
2247    }
2248}
2249
2250struct MTASTSResult: Codable {
2251    let txtFound: Bool
2252    let policyMode: String?
2253}
2254
2255// MARK: - Redirect Chain Models
2256
2257struct RedirectHop: Identifiable, Codable {
2258    var id = UUID()
2259    let stepNumber: Int
2260    let statusCode: Int
2261    let url: String
2262    let isFinal: Bool
2263}
2264
2265// MARK: - Port Scan Models
2266
2267enum PortScanKind: String, Codable {
2268    case standard
2269    case custom
2270}
2271
2272struct PortScanResult: Identifiable, Codable {
2273    var id = UUID()
2274    let port: UInt16
2275    let service: String
2276    let open: Bool
2277    var banner: String?
2278    let kind: PortScanKind
2279    let durationMs: Int?
2280
2281    nonisolated init(
2282        port: UInt16,
2283        service: String,
2284        open: Bool,
2285        banner: String? = nil,
2286        kind: PortScanKind = .standard,
2287        durationMs: Int? = nil
2288    ) {
2289        self.port = port
2290        self.service = service
2291        self.open = open
2292        self.banner = banner
2293        self.kind = kind
2294        self.durationMs = durationMs
2295    }
2296
2297    init(from decoder: Decoder) throws {
2298        let container = try decoder.container(keyedBy: CodingKeys.self)
2299        id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
2300        port = try container.decode(UInt16.self, forKey: .port)
2301        service = try container.decode(String.self, forKey: .service)
2302        open = try container.decode(Bool.self, forKey: .open)
2303        banner = try container.decodeIfPresent(String.self, forKey: .banner)
2304        kind = try container.decodeIfPresent(PortScanKind.self, forKey: .kind) ?? .standard
2305        durationMs = try container.decodeIfPresent(Int.self, forKey: .durationMs)
2306    }
2307}
2308
2309// MARK: - History Models
2310
2311struct HistoryEntry: Identifiable, Codable {
2312    var id = UUID()
2313    let domain: String
2314    let timestamp: Date
2315    var trackedDomainID: UUID?
2316    var note: String?
2317    let dnsSections: [DNSSection]
2318    let sslInfo: SSLCertificateInfo?
2319    let httpHeaders: [HTTPHeader]
2320    let reachabilityResults: [PortReachability]
2321    let ipGeolocation: IPGeolocation?
2322    var emailSecurity: EmailSecurityResult?
2323    var mtaSts: MTASTSResult?
2324    var ownership: DomainOwnership?
2325    var ownershipHistory: [DomainOwnershipHistoryEvent]
2326    var inferredProvider: InferredProviderFingerprint?
2327    var priorProviders: [String]
2328    var domainClassification: DomainClassificationSummary?
2329    var ownershipTransitions: [OwnershipTransitionEvent]
2330    var hostingTransitions: [HostingTransitionEvent]
2331    var subdomainHistory: [SubdomainHistoryEntry]
2332    var riskSignals: [IntelligenceRiskSignal]
2333    var intelligenceTimeline: [IntelligenceTimelineEvent]
2334    var ptrRecord: String?
2335    var redirectChain: [RedirectHop]
2336    var subdomains: [DiscoveredSubdomain]
2337    var extendedSubdomains: [DiscoveredSubdomain]
2338    var dnsHistory: [DNSHistoryEvent]
2339    var domainPricing: DomainPricingInsight?
2340    var reputation: DomainReputationResult?
2341    var portScanResults: [PortScanResult]
2342    var hstsPreloaded: Bool?
2343    var availabilityResult: DomainAvailabilityResult?
2344    var suggestions: [DomainSuggestionResult]
2345    var appVersion: String
2346    var resultSource: LookupResultSource
2347    var dataSources: [String]
2348    var provenanceBySection: [LookupSectionKind: SectionProvenance]
2349    var availabilityConfidence: ConfidenceLevel?
2350    var ownershipConfidence: ConfidenceLevel?
2351    var subdomainConfidence: ConfidenceLevel?
2352    var emailSecurityConfidence: ConfidenceLevel?
2353    var geolocationConfidence: ConfidenceLevel?
2354    var errorDetails: [LookupSectionKind: InspectionFailure]
2355    var isPartialSnapshot: Bool
2356    var validationIssues: [String]
2357    var resolverDisplayName: String
2358    var resolverURLString: String
2359    var totalLookupDurationMs: Int?
2360    var primaryIP: String?
2361    var finalRedirectURL: String?
2362    var tlsStatusSummary: String?
2363    var emailSecuritySummary: String?
2364    var httpGradeSummary: String?
2365    var changeSummary: DomainChangeSummary?
2366    var snapshotIndex: Int?
2367    var previousSnapshotID: UUID?
2368    var changeCount: Int
2369    var severitySummary: ChangeSeverity?
2370    var sslError: String?
2371    var httpHeadersError: String?
2372    var reachabilityError: String?
2373    var ipGeolocationError: String?
2374    var emailSecurityError: String?
2375    var ownershipError: String?
2376    var ownershipHistoryError: String?
2377    var ptrError: String?
2378    var redirectChainError: String?
2379    var subdomainsError: String?
2380    var extendedSubdomainsError: String?
2381    var dnsHistoryError: String?
2382    var domainPricingError: String?
2383    var reputationError: String?
2384    var portScanError: String?
2385
2386    init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, note: String? = nil, dnsSections: [DNSSection],
2387         sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader],
2388         reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
2389         emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil,
2390         ownershipHistory: [DomainOwnershipHistoryEvent] = [],
2391         inferredProvider: InferredProviderFingerprint? = nil, priorProviders: [String] = [],
2392         domainClassification: DomainClassificationSummary? = nil,
2393         ownershipTransitions: [OwnershipTransitionEvent] = [],
2394         hostingTransitions: [HostingTransitionEvent] = [],
2395         subdomainHistory: [SubdomainHistoryEntry] = [],
2396         riskSignals: [IntelligenceRiskSignal] = [],
2397         intelligenceTimeline: [IntelligenceTimelineEvent] = [],
2398         ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [],
2399         extendedSubdomains: [DiscoveredSubdomain] = [], dnsHistory: [DNSHistoryEvent] = [],
2400         domainPricing: DomainPricingInsight? = nil,
2401         reputation: DomainReputationResult? = nil,
2402         portScanResults: [PortScanResult] = [],
2403         hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil,
2404         suggestions: [DomainSuggestionResult] = [], appVersion: String = "2.7.0",
2405         resultSource: LookupResultSource = .snapshot, dataSources: [String] = [],
2406         provenanceBySection: [LookupSectionKind: SectionProvenance] = [:],
2407         availabilityConfidence: ConfidenceLevel? = nil, ownershipConfidence: ConfidenceLevel? = nil,
2408         subdomainConfidence: ConfidenceLevel? = nil, emailSecurityConfidence: ConfidenceLevel? = nil,
2409         geolocationConfidence: ConfidenceLevel? = nil,
2410         errorDetails: [LookupSectionKind: InspectionFailure] = [:], isPartialSnapshot: Bool = false,
2411         validationIssues: [String] = [], resolverDisplayName: String, resolverURLString: String,
2412         totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil,
2413         tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil,
2414         changeSummary: DomainChangeSummary? = nil, snapshotIndex: Int? = nil, previousSnapshotID: UUID? = nil,
2415         changeCount: Int = 0, severitySummary: ChangeSeverity? = nil, sslError: String? = nil, httpHeadersError: String? = nil,
2416         reachabilityError: String? = nil, ipGeolocationError: String? = nil,
2417         emailSecurityError: String? = nil, ownershipError: String? = nil, ownershipHistoryError: String? = nil,
2418         ptrError: String? = nil, redirectChainError: String? = nil, subdomainsError: String? = nil,
2419         extendedSubdomainsError: String? = nil, dnsHistoryError: String? = nil,
2420         domainPricingError: String? = nil, reputationError: String? = nil, portScanError: String? = nil) {
2421        self.domain = domain
2422        self.timestamp = timestamp
2423        self.trackedDomainID = trackedDomainID
2424        self.note = note
2425        self.dnsSections = dnsSections
2426        self.sslInfo = sslInfo
2427        self.httpHeaders = httpHeaders
2428        self.reachabilityResults = reachabilityResults
2429        self.ipGeolocation = ipGeolocation
2430        self.emailSecurity = emailSecurity
2431        self.mtaSts = mtaSts ?? emailSecurity?.mtaSts
2432        self.ownership = ownership
2433        self.ownershipHistory = ownershipHistory
2434        self.inferredProvider = inferredProvider
2435        self.priorProviders = priorProviders
2436        self.domainClassification = domainClassification
2437        self.ownershipTransitions = ownershipTransitions
2438        self.hostingTransitions = hostingTransitions
2439        self.subdomainHistory = subdomainHistory
2440        self.riskSignals = riskSignals
2441        self.intelligenceTimeline = intelligenceTimeline
2442        self.ptrRecord = ptrRecord
2443        self.redirectChain = redirectChain
2444        self.subdomains = subdomains
2445        self.extendedSubdomains = extendedSubdomains
2446        self.dnsHistory = dnsHistory
2447        self.domainPricing = domainPricing
2448        self.reputation = reputation
2449        self.portScanResults = portScanResults
2450        self.hstsPreloaded = hstsPreloaded
2451        self.availabilityResult = availabilityResult
2452        self.suggestions = suggestions
2453        self.appVersion = appVersion
2454        self.resultSource = resultSource
2455        self.dataSources = dataSources
2456        self.provenanceBySection = provenanceBySection
2457        self.availabilityConfidence = availabilityConfidence
2458        self.ownershipConfidence = ownershipConfidence
2459        self.subdomainConfidence = subdomainConfidence
2460        self.emailSecurityConfidence = emailSecurityConfidence
2461        self.geolocationConfidence = geolocationConfidence
2462        self.errorDetails = errorDetails
2463        self.isPartialSnapshot = isPartialSnapshot
2464        self.validationIssues = validationIssues
2465        self.resolverDisplayName = resolverDisplayName
2466        self.resolverURLString = resolverURLString
2467        self.totalLookupDurationMs = totalLookupDurationMs
2468        self.primaryIP = primaryIP
2469        self.finalRedirectURL = finalRedirectURL
2470        self.tlsStatusSummary = tlsStatusSummary
2471        self.emailSecuritySummary = emailSecuritySummary
2472        self.httpGradeSummary = httpGradeSummary
2473        self.changeSummary = changeSummary
2474        self.snapshotIndex = snapshotIndex
2475        self.previousSnapshotID = previousSnapshotID
2476        self.changeCount = changeCount
2477        self.severitySummary = severitySummary
2478        self.sslError = sslError
2479        self.httpHeadersError = httpHeadersError
2480        self.reachabilityError = reachabilityError
2481        self.ipGeolocationError = ipGeolocationError
2482        self.emailSecurityError = emailSecurityError
2483        self.ownershipError = ownershipError
2484        self.ownershipHistoryError = ownershipHistoryError
2485        self.ptrError = ptrError
2486        self.redirectChainError = redirectChainError
2487        self.subdomainsError = subdomainsError
2488        self.extendedSubdomainsError = extendedSubdomainsError
2489        self.dnsHistoryError = dnsHistoryError
2490        self.domainPricingError = domainPricingError
2491        self.reputationError = reputationError
2492        self.portScanError = portScanError
2493    }
2494
2495    init(from decoder: Decoder) throws {
2496        let container = try decoder.container(keyedBy: CodingKeys.self)
2497        id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
2498        domain = try container.decodeIfPresent(String.self, forKey: .domain) ?? "unknown-domain"
2499        timestamp = try container.decodeIfPresent(Date.self, forKey: .timestamp) ?? .distantPast
2500        trackedDomainID = try container.decodeIfPresent(UUID.self, forKey: .trackedDomainID)
2501        note = try container.decodeIfPresent(String.self, forKey: .note)
2502        dnsSections = try container.decodeIfPresent([DNSSection].self, forKey: .dnsSections) ?? []
2503        sslInfo = try container.decodeIfPresent(SSLCertificateInfo.self, forKey: .sslInfo)
2504        httpHeaders = try container.decodeIfPresent([HTTPHeader].self, forKey: .httpHeaders) ?? []
2505        reachabilityResults = try container.decodeIfPresent([PortReachability].self, forKey: .reachabilityResults) ?? []
2506        ipGeolocation = try container.decodeIfPresent(IPGeolocation.self, forKey: .ipGeolocation)
2507        emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity)
2508        mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts
2509        ownership = try container.decodeIfPresent(DomainOwnership.self, forKey: .ownership)
2510        ownershipHistory = try container.decodeIfPresent([DomainOwnershipHistoryEvent].self, forKey: .ownershipHistory) ?? []
2511        inferredProvider = try container.decodeIfPresent(InferredProviderFingerprint.self, forKey: .inferredProvider)
2512        priorProviders = try container.decodeIfPresent([String].self, forKey: .priorProviders) ?? []
2513        domainClassification = try container.decodeIfPresent(DomainClassificationSummary.self, forKey: .domainClassification)
2514        ownershipTransitions = try container.decodeIfPresent([OwnershipTransitionEvent].self, forKey: .ownershipTransitions) ?? []
2515        hostingTransitions = try container.decodeIfPresent([HostingTransitionEvent].self, forKey: .hostingTransitions) ?? []
2516        subdomainHistory = try container.decodeIfPresent([SubdomainHistoryEntry].self, forKey: .subdomainHistory) ?? []
2517        riskSignals = try container.decodeIfPresent([IntelligenceRiskSignal].self, forKey: .riskSignals) ?? []
2518        intelligenceTimeline = try container.decodeIfPresent([IntelligenceTimelineEvent].self, forKey: .intelligenceTimeline) ?? []
2519        ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord)
2520        redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? []
2521        subdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .subdomains) ?? []
2522        extendedSubdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .extendedSubdomains) ?? []
2523        dnsHistory = try container.decodeIfPresent([DNSHistoryEvent].self, forKey: .dnsHistory) ?? []
2524        domainPricing = try container.decodeIfPresent(DomainPricingInsight.self, forKey: .domainPricing)
2525        reputation = try container.decodeIfPresent(DomainReputationResult.self, forKey: .reputation)
2526        portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? []
2527        hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded)
2528        availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult)
2529        suggestions = try container.decodeIfPresent([DomainSuggestionResult].self, forKey: .suggestions) ?? []
2530        appVersion = try container.decodeIfPresent(String.self, forKey: .appVersion) ?? "2.6.0"
2531        resultSource = try container.decodeIfPresent(LookupResultSource.self, forKey: .resultSource) ?? .snapshot
2532        dataSources = try container.decodeIfPresent([String].self, forKey: .dataSources) ?? []
2533        provenanceBySection = try container.decodeIfPresent([LookupSectionKind: SectionProvenance].self, forKey: .provenanceBySection) ?? [:]
2534        availabilityConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .availabilityConfidence)
2535        ownershipConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .ownershipConfidence)
2536        subdomainConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .subdomainConfidence)
2537        emailSecurityConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .emailSecurityConfidence)
2538        geolocationConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .geolocationConfidence)
2539        errorDetails = try container.decodeIfPresent([LookupSectionKind: InspectionFailure].self, forKey: .errorDetails) ?? [:]
2540        validationIssues = try container.decodeIfPresent([String].self, forKey: .validationIssues) ?? HistoryEntry.defaultValidationIssues(domain: domain, timestamp: timestamp)
2541        isPartialSnapshot = try container.decodeIfPresent(Bool.self, forKey: .isPartialSnapshot) ?? !validationIssues.isEmpty
2542        resolverDisplayName = try container.decodeIfPresent(String.self, forKey: .resolverDisplayName) ?? "Cloudflare"
2543        resolverURLString = try container.decodeIfPresent(String.self, forKey: .resolverURLString) ?? DNSResolverOption.defaultURLString
2544        totalLookupDurationMs = try container.decodeIfPresent(Int.self, forKey: .totalLookupDurationMs)
2545        primaryIP = try container.decodeIfPresent(String.self, forKey: .primaryIP)
2546        finalRedirectURL = try container.decodeIfPresent(String.self, forKey: .finalRedirectURL)
2547        tlsStatusSummary = try container.decodeIfPresent(String.self, forKey: .tlsStatusSummary)
2548        emailSecuritySummary = try container.decodeIfPresent(String.self, forKey: .emailSecuritySummary)
2549        httpGradeSummary = try container.decodeIfPresent(String.self, forKey: .httpGradeSummary)
2550        changeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .changeSummary)
2551        snapshotIndex = try container.decodeIfPresent(Int.self, forKey: .snapshotIndex)
2552        previousSnapshotID = try container.decodeIfPresent(UUID.self, forKey: .previousSnapshotID)
2553        changeCount = try container.decodeIfPresent(Int.self, forKey: .changeCount)
2554            ?? changeSummary?.changedSections.count
2555            ?? 0
2556        severitySummary = try container.decodeIfPresent(ChangeSeverity.self, forKey: .severitySummary)
2557            ?? changeSummary?.severity
2558        sslError = try container.decodeIfPresent(String.self, forKey: .sslError)
2559        httpHeadersError = try container.decodeIfPresent(String.self, forKey: .httpHeadersError)
2560        reachabilityError = try container.decodeIfPresent(String.self, forKey: .reachabilityError)
2561        ipGeolocationError = try container.decodeIfPresent(String.self, forKey: .ipGeolocationError)
2562        emailSecurityError = try container.decodeIfPresent(String.self, forKey: .emailSecurityError)
2563        ownershipError = try container.decodeIfPresent(String.self, forKey: .ownershipError)
2564        ownershipHistoryError = try container.decodeIfPresent(String.self, forKey: .ownershipHistoryError)
2565        ptrError = try container.decodeIfPresent(String.self, forKey: .ptrError)
2566        redirectChainError = try container.decodeIfPresent(String.self, forKey: .redirectChainError)
2567        subdomainsError = try container.decodeIfPresent(String.self, forKey: .subdomainsError)
2568        extendedSubdomainsError = try container.decodeIfPresent(String.self, forKey: .extendedSubdomainsError)
2569        dnsHistoryError = try container.decodeIfPresent(String.self, forKey: .dnsHistoryError)
2570        domainPricingError = try container.decodeIfPresent(String.self, forKey: .domainPricingError)
2571        reputationError = try container.decodeIfPresent(String.self, forKey: .reputationError)
2572        portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError)
2573    }
2574
2575    private static func defaultValidationIssues(domain: String, timestamp: Date) -> [String] {
2576        var issues: [String] = []
2577        if domain == "unknown-domain" {
2578            issues.append("Missing domain in stored snapshot")
2579        }
2580        if timestamp == .distantPast {
2581            issues.append("Missing collection timestamp in stored snapshot")
2582        }
2583        return issues
2584    }
2585
2586    var snapshotSummary: SnapshotSummary {
2587        SnapshotSummary(
2588            id: id,
2589            domain: domain,
2590            timestamp: timestamp,
2591            trackedDomainID: trackedDomainID,
2592            snapshotIndex: snapshotIndex,
2593            previousSnapshotID: previousSnapshotID,
2594            changeCount: changeCount,
2595            severitySummary: severitySummary ?? changeSummary?.severity,
2596            changeSummaryMessage: changeSummary?.message,
2597            availability: availabilityResult?.status,
2598            primaryIP: primaryIP,
2599            tlsStatus: tlsStatusSummary,
2600            riskScore: changeSummary?.riskAssessment?.score,
2601            historyEntryID: id
2602        )
2603    }
2604}
2605
2606// MARK: - Cloudflare DNS-over-HTTPS Response
2607
2608struct CloudflareDNSResponse: Decodable {
2609    let Status: Int
2610    let AD: Bool?
2611    let Answer: [CloudflareDNSAnswer]?
2612
2613    struct CloudflareDNSAnswer: Decodable {
2614        let name: String
2615        let type: Int
2616        let TTL: Int
2617        let data: String
2618    }
2619}