krz/domain-dig

an ios app for DNS & SSL analysis

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

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