krz/domain-dig

an ios app for DNS & SSL analysis

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

v4.5.0: DomainDig/ContentView.swift · raw

   1import MapKit
   2import SwiftUI
   3import UniformTypeIdentifiers
   4
   5enum LookupInputMode: String, CaseIterable, Identifiable {
   6    case single
   7    case bulk
   8
   9    var id: String { rawValue }
  10}
  11
  12enum ResultSection: String, Hashable {
  13    case domain
  14    case intelligence
  15    case ownership
  16    case dns
  17    case web
  18    case email
  19    case network
  20    case subdomains
  21}
  22
  23private enum LookupInputField: Hashable {
  24    case singleDomain
  25    case bulkDomains
  26}
  27
  28private struct WorkflowNavigationTarget: Hashable {
  29    let workflowID: UUID
  30}
  31
  32struct ContentView: View {
  33    @Environment(\.appDensity) private var appDensity
  34    @Bindable var viewModel: DomainViewModel
  35    @State private var purchaseService = PurchaseService.shared
  36    @State private var navigationPath = NavigationPath()
  37    @FocusState private var focusedInputField: LookupInputField?
  38    @State private var customPortInput = ""
  39    @State private var customPortsExpanded = false
  40    @State private var trackingNoteDraft = ""
  41    @State private var editingTrackedDomain: TrackedDomain?
  42    @State private var inputMode: LookupInputMode = .single
  43    @State private var collapsedSections: Set<ResultSection> = [.network]
  44    @State private var showingCurrentDomainWorkflowSheet = false
  45    @State private var showingBatchWorkflowSheet = false
  46    @State private var showingTimeline = false
  47    @State private var showingAuditTimeline = false
  48    @State private var auditStartInFlight = false
  49
  50    var body: some View {
  51        let _ = purchaseService.currentTier
  52
  53        NavigationStack(path: $navigationPath) {
  54            ScrollView(.vertical) {
  55                VStack(spacing: 0) {
  56                    inputSection
  57                    if !viewModel.batchResults.isEmpty || viewModel.batchLookupRunning {
  58                        batchSection
  59                            .padding(.top, appDensity.metrics.cardSpacing)
  60                    }
  61                    if viewModel.hasRun {
  62                        actionButtons
  63                        if !viewModel.resultsLoaded {
  64                            LookupProgressOverviewView(steps: viewModel.activeLoadingLabels)
  65                                .padding(.top, appDensity.metrics.cardSpacing)
  66                        } else if let statusMessage = resultStatusMessage {
  67                            LookupStatusBannerView(message: statusMessage, resultSource: viewModel.currentResultSource)
  68                                .padding(.top, appDensity.metrics.cardSpacing)
  69                        }
  70                        if viewModel.resultsLoaded {
  71                            SummaryView(fields: viewModel.summaryFields)
  72                                .padding(.top, appDensity.metrics.cardSpacing)
  73                            if let report = viewModel.currentReport {
  74                                RiskSummaryCardView(report: report)
  75                                    .padding(.top, appDensity.metrics.cardSpacing)
  76                                InsightsSummaryCardView(insights: report.insights)
  77                                    .padding(.top, appDensity.metrics.cardSpacing)
  78                            }
  79                            if let changeSummary = viewModel.currentChangeSummary {
  80                                DomainChangeSummaryView(summary: changeSummary)
  81                                    .padding(.top, appDensity.metrics.cardSpacing)
  82                            }
  83                        }
  84                        if let report = viewModel.currentReport {
  85                            intelligenceSection(report: report)
  86                                .padding(.top, appDensity.metrics.sectionSpacing)
  87                        }
  88                        domainOverviewSection
  89                            .padding(.top, appDensity.metrics.sectionSpacing)
  90                        ownershipSection
  91                        .padding(.top, appDensity.metrics.sectionSpacing)
  92                        subdomainsSection
  93                        .padding(.top, appDensity.metrics.sectionSpacing)
  94                        if !viewModel.currentDiffSections.isEmpty {
  95                            DomainDiffView(
  96                                title: "Latest Changes",
  97                                sections: viewModel.currentDiffSections,
  98                                contextNote: viewModel.currentChangeSummary?.contextNote,
  99                                showsUnchanged: false,
 100                                highlightedSectionID: nil
 101                            )
 102                            .padding(.top, appDensity.metrics.sectionSpacing)
 103                        }
 104                        dnsSection
 105                        .padding(.top, appDensity.metrics.sectionSpacing)
 106                        WebSectionView(
 107                            isCollapsed: sectionCollapsedBinding(.web),
 108                            certificateRows: viewModel.webCertificateRows,
 109                            sslInfo: viewModel.sslInfo,
 110                            tlsSummary: viewModel.currentTLSSummary,
 111                            sslLoading: viewModel.sslLoading || viewModel.hstsLoading,
 112                            sslError: viewModel.sslError,
 113                            tlsProvenance: viewModel.currentSnapshot.provenanceBySection[.ssl],
 114                            responseRows: viewModel.webResponseRows,
 115                            headers: viewModel.httpHeaders,
 116                            headersLoading: viewModel.httpHeadersLoading,
 117                            headersError: viewModel.httpHeadersError,
 118                            httpProvenance: viewModel.currentSnapshot.provenanceBySection[.httpHeaders],
 119                            redirects: viewModel.redirectRows,
 120                            redirectLoading: viewModel.redirectChainLoading,
 121                            redirectError: viewModel.redirectChainError,
 122                            redirectProvenance: viewModel.currentSnapshot.provenanceBySection[.redirectChain],
 123                            finalURL: viewModel.currentSnapshot.redirectChain.last?.url
 124                        )
 125                        .padding(.top, appDensity.metrics.sectionSpacing)
 126                        EmailSectionView(
 127                            isCollapsed: sectionCollapsedBinding(.email),
 128                            rows: viewModel.emailRows,
 129                            assessment: viewModel.currentEmailAssessment,
 130                            loading: viewModel.emailSecurityLoading,
 131                            provenance: viewModel.currentSnapshot.provenanceBySection[.emailSecurity],
 132                            confidence: viewModel.currentSnapshot.emailSecurityConfidence,
 133                            error: viewModel.emailSecurityError
 134                        )
 135                        .padding(.top, appDensity.metrics.sectionSpacing)
 136                        NetworkSectionView(
 137                            isCollapsed: sectionCollapsedBinding(.network),
 138                            reachabilityRows: viewModel.reachabilityRows,
 139                            reachabilityLoading: viewModel.reachabilityLoading,
 140                            reachabilityError: viewModel.reachabilityError,
 141                            reachabilityProvenance: viewModel.currentSnapshot.provenanceBySection[.reachability],
 142                            locationRows: viewModel.locationRows,
 143                            geolocation: viewModel.ipGeolocation,
 144                            geolocationLoading: viewModel.ipGeolocationLoading,
 145                            geolocationError: viewModel.ipGeolocationError,
 146                            geolocationProvenance: viewModel.currentSnapshot.provenanceBySection[.ipGeolocation],
 147                            geolocationConfidence: viewModel.currentSnapshot.geolocationConfidence,
 148                            standardPortRows: viewModel.standardPortRows,
 149                            customPortRows: viewModel.customPortRows,
 150                            portScanLoading: viewModel.portScanLoading,
 151                            portScanError: viewModel.portScanError,
 152                            portScanProvenance: viewModel.currentSnapshot.provenanceBySection[.portScan],
 153                            customPortScanLoading: viewModel.customPortScanLoading,
 154                            customPortScanError: viewModel.customPortScanError,
 155                            isCloudflareProxied: viewModel.isCloudflareProxied,
 156                            customPortsExpanded: $customPortsExpanded,
 157                            customPortInput: $customPortInput,
 158                            onScanCustomPorts: runCustomPortScan
 159                        )
 160                        .padding(.top, appDensity.metrics.sectionSpacing)
 161                    } else if !viewModel.recentSearches.isEmpty {
 162                        recentSearchesSection
 163                    }
 164                }
 165                .padding(.horizontal)
 166                .padding(.bottom, 32)
 167            }
 168            .background(
 169                LinearGradient(
 170                    colors: [Color.black, Color(.systemGray6).opacity(0.12)],
 171                    startPoint: .top,
 172                    endPoint: .bottom
 173                )
 174            )
 175            .navigationTitle("DomainDig")
 176            .toolbarColorScheme(.dark, for: .navigationBar)
 177            .preferredColorScheme(.dark)
 178            .toolbar {
 179                ToolbarItem(placement: .topBarTrailing) {
 180                    if viewModel.hasRun {
 181                        Button {
 182                            viewModel.reset()
 183                        } label: {
 184                            Image(systemName: "xmark.circle")
 185                                .foregroundStyle(.secondary)
 186                        }
 187                    }
 188                }
 189
 190                ToolbarItemGroup(placement: .keyboard) {
 191                    Spacer()
 192                    Button("Dismiss Keyboard") {
 193                        focusedInputField = nil
 194                    }
 195                }
 196            }
 197            .navigationDestination(for: WorkflowNavigationTarget.self) { target in
 198                WorkflowDetailView(viewModel: viewModel, workflowID: target.workflowID)
 199            }
 200        }
 201        .onAppear {
 202            focusedInputField = .singleDomain
 203        }
 204        .task {
 205            await viewModel.refreshUsageCredits()
 206        }
 207        .onChange(of: viewModel.searchedDomain) { _, _ in
 208            collapsedSections = defaultCollapsedSections
 209        }
 210        .onChange(of: viewModel.rerunNavigationToken) { _, _ in
 211            navigationPath = NavigationPath()
 212            focusedInputField = nil
 213        }
 214        .onChange(of: inputMode) { _, newValue in
 215            viewModel.clearPresentedResults()
 216            focusedInputField = newValue == .single ? .singleDomain : .bulkDomains
 217        }
 218        .onChange(of: viewModel.domain) { _, newValue in
 219            guard inputMode == .single else { return }
 220            let normalized = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
 221            guard normalized != viewModel.searchedDomain else { return }
 222            guard viewModel.hasRun || !viewModel.batchResults.isEmpty else { return }
 223            viewModel.clearPresentedResults()
 224        }
 225        .onChange(of: viewModel.bulkInput) { _, newValue in
 226            guard inputMode == .bulk else { return }
 227            let normalized = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
 228            guard !normalized.isEmpty || viewModel.hasRun || !viewModel.batchResults.isEmpty else { return }
 229            viewModel.clearPresentedResults()
 230        }
 231        .sheet(item: $editingTrackedDomain) { trackedDomain in
 232            NavigationStack {
 233                Form {
 234                    Section("Tracking Note") {
 235                        TextField("Optional note", text: $trackingNoteDraft, axis: .vertical)
 236                            .lineLimit(3...6)
 237                            .textInputAutocapitalization(.never)
 238                            .autocorrectionDisabled()
 239                    }
 240                }
 241                .navigationTitle(trackedDomain.domain)
 242                .toolbar {
 243                    ToolbarItem(placement: .cancellationAction) {
 244                        Button("Cancel") {
 245                            editingTrackedDomain = nil
 246                        }
 247                    }
 248                    ToolbarItem(placement: .confirmationAction) {
 249                        Button("Save") {
 250                            viewModel.updateNote(trackingNoteDraft, for: trackedDomain)
 251                            editingTrackedDomain = nil
 252                        }
 253                    }
 254                }
 255            }
 256        }
 257        .sheet(isPresented: $showingCurrentDomainWorkflowSheet) {
 258            WorkflowBulkAddSheet(
 259                viewModel: viewModel,
 260                title: "Add Domain to Workflow",
 261                availableDomains: [viewModel.searchedDomain]
 262            )
 263        }
 264        .sheet(isPresented: $showingBatchWorkflowSheet) {
 265            WorkflowBulkAddSheet(
 266                viewModel: viewModel,
 267                title: "Add Batch Domains",
 268                availableDomains: viewModel.batchResults.map(\.domain)
 269            )
 270        }
 271        .sheet(item: manualBatchSummaryBinding) { summary in
 272            BatchSweepSummaryView(viewModel: viewModel, summary: summary)
 273        }
 274        .sheet(isPresented: $showingTimeline) {
 275            NavigationStack {
 276                TimelineView(viewModel: viewModel, domain: viewModel.searchedDomain)
 277            }
 278        }
 279        .sheet(isPresented: $showingAuditTimeline) {
 280            NavigationStack {
 281                AuditDomainTimelineView(viewModel: viewModel, domain: viewModel.searchedDomain)
 282            }
 283        }
 284    }
 285
 286    private var manualBatchSummaryBinding: Binding<BatchSweepSummary?> {
 287        Binding(
 288            get: {
 289                guard let summary = viewModel.latestBatchSweepSummary,
 290                      summary.source == .manual else {
 291                    return nil
 292                }
 293                return summary
 294            },
 295            set: { viewModel.latestBatchSweepSummary = $0 }
 296        )
 297    }
 298
 299    private var inputSection: some View {
 300        VStack(spacing: appDensity.metrics.cardSpacing + 2) {
 301            Picker("Mode", selection: $inputMode) {
 302                Text("Single").tag(LookupInputMode.single)
 303                Text("Bulk").tag(LookupInputMode.bulk)
 304            }
 305            .pickerStyle(.segmented)
 306
 307            if inputMode == .single {
 308                TextField("e.g. cleberg.net", text: $viewModel.domain)
 309                    .font(appDensity.font(.title3, design: .monospaced))
 310                    .textInputAutocapitalization(.never)
 311                    .autocorrectionDisabled()
 312                    .keyboardType(.URL)
 313                    .padding(.horizontal, 12)
 314                    .padding(.vertical, appDensity.metrics.controlVerticalPadding)
 315                    .background(Color(.systemGray6))
 316                    .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
 317                    .focused($focusedInputField, equals: .singleDomain)
 318                    .onSubmit {
 319                        focusedInputField = nil
 320                        viewModel.run()
 321                    }
 322
 323                Button {
 324                    focusedInputField = nil
 325                    viewModel.run()
 326                } label: {
 327                    Text("Run")
 328                        .font(appDensity.font(.headline, design: .default, weight: .semibold))
 329                        .frame(maxWidth: .infinity)
 330                        .frame(minHeight: appDensity.metrics.controlMinHeight)
 331                }
 332                .buttonStyle(.borderedProminent)
 333                .disabled(viewModel.trimmedDomain.isEmpty)
 334            } else {
 335                if FeatureAccessService.hasAccess(to: .batchOperations) {
 336                    VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
 337                        Text("Paste domains separated by new lines or commas.")
 338                            .font(appDensity.font(.caption))
 339                            .foregroundStyle(.secondary)
 340
 341                        if let batchAllowanceSummary = FeatureAccessService.batchAllowanceSummary() {
 342                            Text(batchAllowanceSummary)
 343                                .font(appDensity.font(.caption2))
 344                                .foregroundStyle(.secondary)
 345                        }
 346
 347                        TextField(
 348                            "example.com\napple.com, openai.com",
 349                            text: $viewModel.bulkInput,
 350                            axis: .vertical
 351                        )
 352                        .font(appDensity.font(.body))
 353                        .textInputAutocapitalization(.never)
 354                        .autocorrectionDisabled()
 355                        .keyboardType(.URL)
 356                        .lineLimit(4...10)
 357                        .padding(.horizontal, 12)
 358                        .padding(.vertical, appDensity.metrics.controlVerticalPadding)
 359                        .background(Color(.systemGray6))
 360                        .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
 361                        .focused($focusedInputField, equals: .bulkDomains)
 362
 363                        Button {
 364                            focusedInputField = nil
 365                            viewModel.runBulkLookup()
 366                        } label: {
 367                            Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch")
 368                                .font(appDensity.font(.headline, design: .default, weight: .semibold))
 369                                .frame(maxWidth: .infinity)
 370                                .frame(minHeight: appDensity.metrics.controlMinHeight)
 371                        }
 372                        .buttonStyle(.borderedProminent)
 373                        .disabled(viewModel.bulkInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.batchLookupRunning)
 374                    }
 375                } else {
 376                    lockedFeatureCard(
 377                        title: "Batch Operations",
 378                        message: FeatureAccessService.upgradeMessage(for: .batchOperations)
 379                    )
 380                }
 381            }
 382        }
 383        .padding(.vertical, appDensity.metrics.sectionSpacing)
 384    }
 385
 386    private var domainOverviewSection: some View {
 387        let trackedDomain = viewModel.currentTrackedDomain
 388        let workflows = viewModel.currentDomainWorkflows
 389
 390        return DomainSectionView(
 391            isCollapsed: sectionCollapsedBinding(.domain),
 392            rows: viewModel.domainRows,
 393            suggestions: viewModel.suggestionRows,
 394            showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading,
 395            availabilityLoading: viewModel.availabilityLoading,
 396            suggestionsLoading: viewModel.suggestionsLoading,
 397            provenance: viewModel.currentSnapshot.provenanceBySection[.availability],
 398            confidence: viewModel.currentSnapshot.availabilityConfidence,
 399            snapshotNote: viewModel.currentSnapshot.note,
 400            trackedDomain: trackedDomain,
 401            workflows: workflows,
 402            trackingLimitMessage: viewModel.trackingLimitMessage,
 403            pricingLoading: viewModel.domainPricingLoading,
 404            pricingError: viewModel.domainPricingError,
 405            showsPricingPlaceholder: !DataAccessService.hasAccess(to: .domainPricing),
 406            onTrack: {
 407                _ = viewModel.trackCurrentDomain()
 408            },
 409            onTogglePinned: {
 410                guard let trackedDomain else { return }
 411                viewModel.togglePinned(for: trackedDomain)
 412            },
 413            onEditNote: {
 414                guard let trackedDomain else { return }
 415                trackingNoteDraft = trackedDomain.note ?? ""
 416                editingTrackedDomain = trackedDomain
 417            },
 418            onAddToWorkflow: {
 419                showingCurrentDomainWorkflowSheet = true
 420            },
 421            onOpenWorkflow: { workflow in
 422                navigationPath.append(WorkflowNavigationTarget(workflowID: workflow.id))
 423            },
 424            onRunWorkflow: { workflow in
 425                viewModel.rerunCurrentDomain(in: workflow)
 426            }
 427        )
 428    }
 429
 430    private func intelligenceSection(report: DomainReport) -> some View {
 431        IntelligenceSectionView(
 432            isCollapsed: sectionCollapsedBinding(.intelligence),
 433            report: report,
 434            showsPlaceholder: FeatureAccessService.currentTier != .proPlus
 435        )
 436    }
 437
 438    private var ownershipSection: some View {
 439        OwnershipSectionView(
 440            isCollapsed: sectionCollapsedBinding(.ownership),
 441            rows: viewModel.ownershipRows,
 442            loading: viewModel.ownershipLoading,
 443            error: viewModel.ownershipError,
 444            provenance: viewModel.currentSnapshot.provenanceBySection[.ownership],
 445            confidence: viewModel.currentSnapshot.ownershipConfidence,
 446            showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory),
 447            history: viewModel.ownershipHistory,
 448            historyLoading: viewModel.ownershipHistoryLoading,
 449            historyError: viewModel.ownershipHistoryError,
 450            historyCreditStatus: viewModel.ownershipHistoryCreditStatus,
 451            onLoadHistory: {
 452                Task {
 453                    await viewModel.loadOwnershipHistory()
 454                }
 455            }
 456        )
 457    }
 458
 459    private var subdomainsSection: some View {
 460        SubdomainsSectionView(
 461            isCollapsed: sectionCollapsedBinding(.subdomains),
 462            rows: viewModel.subdomainRows,
 463            groups: viewModel.currentSubdomainGroups,
 464            loading: viewModel.subdomainsLoading,
 465            error: viewModel.subdomainsError,
 466            provenance: viewModel.currentSnapshot.provenanceBySection[.subdomains],
 467            confidence: viewModel.currentSnapshot.subdomainConfidence,
 468            showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains),
 469            extendedCount: viewModel.extendedSubdomains.count,
 470            extendedLoading: viewModel.extendedSubdomainsLoading,
 471            extendedError: viewModel.extendedSubdomainsError,
 472            extendedCreditStatus: viewModel.extendedSubdomainsCreditStatus,
 473            onLoadExtended: {
 474                Task {
 475                    await viewModel.loadExtendedSubdomains()
 476                }
 477            }
 478        )
 479    }
 480
 481    private var dnsSection: some View {
 482        DNSSectionView(
 483            isCollapsed: sectionCollapsedBinding(.dns),
 484            dnssecLabel: viewModel.dnssecLabel,
 485            patternSummary: viewModel.currentDNSPatterns,
 486            sections: viewModel.dnsRows,
 487            ptrMessage: viewModel.ptrMessage,
 488            loading: viewModel.dnsLoading || viewModel.ptrLoading,
 489            dnsProvenance: viewModel.currentSnapshot.provenanceBySection[.dns],
 490            ptrProvenance: viewModel.currentSnapshot.provenanceBySection[.ptr],
 491            sectionError: viewModel.dnsError,
 492            history: viewModel.dnsHistory,
 493            historyLoading: viewModel.dnsHistoryLoading,
 494            historyError: viewModel.dnsHistoryError,
 495            showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .dnsHistory),
 496            historyCreditStatus: viewModel.dnsHistoryCreditStatus,
 497            onLoadHistory: {
 498                Task {
 499                    await viewModel.loadDNSHistory()
 500                }
 501            }
 502        )
 503    }
 504
 505    private var actionButtons: some View {
 506        HStack {
 507            Spacer()
 508            if viewModel.resultsLoaded {
 509                Menu {
 510                    if !viewModel.isCurrentDomainTracked {
 511                        Button("Track this domain") {
 512                            _ = viewModel.trackCurrentDomain()
 513                        }
 514                    }
 515                    Button("Add to workflow") {
 516                        showingCurrentDomainWorkflowSheet = true
 517                    }
 518                    Button(auditStartInFlight ? "Starting audit…" : "Start audit") {
 519                        Task {
 520                            auditStartInFlight = true
 521                            if await viewModel.startAudit(for: viewModel.searchedDomain) != nil {
 522                                showingAuditTimeline = true
 523                            }
 524                            auditStartInFlight = false
 525                        }
 526                    }
 527                    .disabled(auditStartInFlight)
 528                    if !viewModel.audits(for: viewModel.searchedDomain).isEmpty {
 529                        Button("View audits") {
 530                            showingAuditTimeline = true
 531                        }
 532                    }
 533                    if !viewModel.historyEntries(for: viewModel.searchedDomain).isEmpty {
 534                        Button("Open timeline") {
 535                            showingTimeline = true
 536                        }
 537                    }
 538                    if FeatureAccessService.hasAccess(to: .advancedExports) {
 539                        Button("Copy report JSON") {
 540                            guard let json = viewModel.exportJSONString() else { return }
 541                            AppClipboard.copy(json)
 542                            AppHaptics.copy()
 543                        }
 544                    } else {
 545                        Button("Copy report JSON") {
 546                            viewModel.upgradePrompt = FeatureAccessService.upgradePrompt(for: .advancedExports)
 547                        }
 548                    }
 549                    Button("Export report") {
 550                        shareSingleResults(format: .text)
 551                    }
 552                } label: {
 553                    Image(systemName: "bolt.circle")
 554                        .font(appDensity.font(.body, design: .default))
 555                        .foregroundStyle(.secondary)
 556                }
 557                Button {
 558                    viewModel.toggleSavedDomain()
 559                } label: {
 560                    Image(systemName: viewModel.isCurrentDomainSaved ? "bookmark.fill" : "bookmark")
 561                        .font(appDensity.font(.body, design: .default))
 562                        .foregroundStyle(viewModel.isCurrentDomainSaved ? .yellow : .secondary)
 563                }
 564                Menu {
 565                    Button("Export TXT") {
 566                        shareSingleResults(format: .text)
 567                    }
 568                    if FeatureAccessService.hasAccess(to: .advancedExports) {
 569                        Button("Export CSV") {
 570                            shareSingleResults(format: .csv)
 571                        }
 572                        Button("Export JSON") {
 573                            shareSingleResults(format: .json)
 574                        }
 575                    } else {
 576                        Button("CSV Export • Available in Pro") {}
 577                            .disabled(true)
 578                        Button("JSON Export • Available in Pro") {}
 579                            .disabled(true)
 580                    }
 581                } label: {
 582                    Image(systemName: "square.and.arrow.up")
 583                        .font(appDensity.font(.body, design: .default))
 584                        .foregroundStyle(.secondary)
 585                }
 586            }
 587        }
 588    }
 589
 590    private var batchSection: some View {
 591        VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
 592            HStack {
 593                Spacer()
 594                if viewModel.batchLookupRunning {
 595                    Button("Cancel") {
 596                        viewModel.cancelBatchLookup()
 597                    }
 598                    .buttonStyle(.bordered)
 599                    .font(appDensity.font(.caption))
 600                }
 601                if !viewModel.currentBatchResultEntries.isEmpty {
 602                    Menu {
 603                        Button("Add to Workflow") {
 604                            showingBatchWorkflowSheet = true
 605                        }
 606                        Divider()
 607                        Button("Export Batch TXT") {
 608                            shareBatchResults(format: .text)
 609                        }
 610                        if FeatureAccessService.hasAccess(to: .advancedExports) {
 611                            Button("Export Batch CSV") {
 612                                shareBatchResults(format: .csv)
 613                            }
 614                            Button("Export Batch JSON") {
 615                                shareBatchResults(format: .json)
 616                            }
 617                        } else {
 618                            Button("Batch CSV • Available in Pro") {}
 619                                .disabled(true)
 620                            Button("Batch JSON • Available in Pro") {}
 621                                .disabled(true)
 622                        }
 623                    } label: {
 624                        Label("Export", systemImage: "square.and.arrow.up")
 625                            .font(appDensity.font(.caption))
 626                    }
 627                    .buttonStyle(.bordered)
 628                }
 629            }
 630
 631            BatchResultsView(
 632                viewModel: viewModel,
 633                title: viewModel.batchLookupSource == .watchlistRefresh ? "Tracked Domain Refresh" : "Batch Results"
 634            )
 635        }
 636    }
 637
 638    private var recentSearchesSection: some View {
 639        VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
 640            HStack {
 641                Text("RECENT")
 642                    .font(appDensity.font(.caption2))
 643                    .foregroundStyle(.secondary)
 644                Spacer()
 645                Button("Clear") {
 646                    viewModel.clearRecentSearches()
 647                }
 648                .font(appDensity.font(.caption2))
 649                .foregroundStyle(.secondary)
 650            }
 651
 652            ForEach(viewModel.recentSearches, id: \.self) { domain in
 653                Button {
 654                    viewModel.domain = domain
 655                    focusedInputField = nil
 656                    viewModel.run()
 657                } label: {
 658                    Text(domain)
 659                        .font(appDensity.font(.callout))
 660                        .foregroundStyle(.primary)
 661                        .frame(maxWidth: .infinity, alignment: .leading)
 662                        .padding(.vertical, 8)
 663                        .padding(.horizontal, 10)
 664                        .background(Color(.systemGray6).opacity(0.5))
 665                        .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
 666                }
 667            }
 668        }
 669        .padding(.top, appDensity.metrics.cardSpacing)
 670    }
 671
 672    private func runCustomPortScan() {
 673        let ports = parsedCustomPorts(from: customPortInput)
 674        Task {
 675            await viewModel.runCustomPortScan(ports: ports)
 676        }
 677    }
 678
 679    private func lockedFeatureCard(title: String, message: String) -> some View {
 680        VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
 681            Text(title)
 682                .font(appDensity.font(.headline, design: .default, weight: .semibold))
 683            Text(message)
 684                .font(appDensity.font(.callout, design: .default))
 685                .foregroundStyle(.secondary)
 686        }
 687        .frame(maxWidth: .infinity, alignment: .leading)
 688        .padding(appDensity.metrics.cardPadding)
 689        .background(Color(.systemGray6))
 690        .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
 691    }
 692
 693    private func parsedCustomPorts(from input: String) -> [UInt16] {
 694        let parts = input.split(separator: ",", omittingEmptySubsequences: true)
 695        var seen = Set<UInt16>()
 696        var ports: [UInt16] = []
 697
 698        for part in parts {
 699            let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines)
 700            guard let value = UInt16(trimmed), seen.insert(value).inserted else {
 701                continue
 702            }
 703            ports.append(value)
 704            if ports.count == 20 {
 705                break
 706            }
 707        }
 708
 709        return ports
 710    }
 711
 712    private func shareSingleResults(format: DomainExportFormat) {
 713        let (filename, data) = exportPayload(
 714            prefix: "domaindig_single",
 715            format: format,
 716            text: viewModel.exportText(),
 717            csv: viewModel.exportCSV(),
 718            json: viewModel.exportJSONData()
 719        )
 720        ExportPresenter.share(filename: filename, data: data)
 721    }
 722
 723    private func shareBatchResults(format: DomainExportFormat) {
 724        let (filename, data) = exportPayload(
 725            prefix: "domaindig_batch",
 726            format: format,
 727            text: viewModel.exportBatchText(),
 728            csv: viewModel.exportBatchCSV(),
 729            json: viewModel.exportBatchJSONData()
 730        )
 731        ExportPresenter.share(filename: filename, data: data)
 732    }
 733
 734    private func exportPayload(
 735        prefix: String,
 736        format: DomainExportFormat,
 737        text: String,
 738        csv: String,
 739        json: Data?
 740    ) -> (String, Data) {
 741        let formatter = DateFormatter()
 742        formatter.dateFormat = "yyyyMMdd_HHmmss"
 743        let timestamp = formatter.string(from: Date())
 744        let filename = "\(timestamp)_\(prefix).\(format.fileExtension)"
 745        let data: Data
 746
 747        switch format {
 748        case .text:
 749            data = Data(text.utf8)
 750        case .csv:
 751            data = Data(csv.utf8)
 752        case .json:
 753            data = json ?? Data("[]".utf8)
 754        }
 755
 756        return (filename, data)
 757    }
 758
 759    private var defaultCollapsedSections: Set<ResultSection> {
 760        []
 761    }
 762
 763    private var currentPrimaryIP: String? {
 764        viewModel.currentSnapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
 765    }
 766
 767    private var resultStatusMessage: String? {
 768        if let currentStatusMessage = viewModel.currentStatusMessage {
 769            return currentStatusMessage
 770        }
 771
 772        if viewModel.currentResultSource != .live {
 773            return viewModel.currentResultSource.label
 774        }
 775
 776        return nil
 777    }
 778
 779    private func sectionCollapsedBinding(_ section: ResultSection) -> Binding<Bool> {
 780        Binding(
 781            get: { collapsedSections.contains(section) },
 782            set: { isCollapsed in
 783                if isCollapsed {
 784                    collapsedSections.insert(section)
 785                } else {
 786                    collapsedSections.remove(section)
 787                }
 788            }
 789        )
 790    }
 791}
 792
 793struct SummaryView: View {
 794    @Environment(\.appDensity) private var appDensity
 795    let fields: [SummaryFieldViewData]
 796
 797    var body: some View {
 798        VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
 799            SectionTitleView(title: "Summary")
 800            LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: appDensity.metrics.cardSpacing) {
 801                ForEach(fields) { field in
 802                    VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing) {
 803                        Text(field.label)
 804                            .font(appDensity.font(.caption2))
 805                            .foregroundStyle(.secondary)
 806                        Text(field.value)
 807                            .font(appDensity.font(.caption))
 808                            .foregroundStyle(ResultColors.color(for: field.tone))
 809                            .lineLimit(2)
 810                            .textSelection(.enabled)
 811                    }
 812                    .frame(minHeight: appDensity.metrics.rowMinHeight + 12, alignment: .topLeading)
 813                    .frame(maxWidth: .infinity, alignment: .leading)
 814                    .padding(appDensity.metrics.cardPadding)
 815                    .background(Color(.systemGray6).opacity(0.5))
 816                    .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
 817                }
 818            }
 819        }
 820    }
 821}
 822
 823struct RiskSummaryCardView: View {
 824    @Environment(\.appDensity) private var appDensity
 825    let report: DomainReport
 826
 827    private var topFactors: [RiskFactor] {
 828        Array(report.riskAssessment.factors.prefix(3))
 829    }
 830
 831    var body: some View {
 832        VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
 833            SectionTitleView(title: "Risk")
 834            CardView(allowsHorizontalScroll: false) {
 835                HStack(alignment: .firstTextBaseline) {
 836                    VStack(alignment: .leading, spacing: 4) {
 837                        Text("\(report.riskAssessment.score)")
 838                            .font(appDensity.font(.largeTitle, weight: .bold))
 839                            .foregroundStyle(levelColor)
 840                        Text(report.riskAssessment.level.title)
 841                            .font(appDensity.font(.caption))
 842                            .foregroundStyle(levelColor)
 843                    }
 844                    Spacer()
 845                    Text("Deterministic")
 846                        .font(appDensity.font(.caption2))
 847                        .foregroundStyle(.secondary)
 848                }
 849
 850                if topFactors.isEmpty {
 851                    Text("No major risk factors identified")
 852                        .font(appDensity.font(.caption))
 853                        .foregroundStyle(.secondary)
 854                } else {
 855                    ForEach(Array(topFactors.enumerated()), id: \.offset) { _, factor in
 856                        HStack(alignment: .top, spacing: 8) {
 857                            Circle()
 858                                .fill(factorColor(factor.impact))
 859                                .frame(width: 8, height: 8)
 860                                .padding(.top, 5)
 861                            Text(factor.description)
 862                                .font(appDensity.font(.caption))
 863                                .foregroundStyle(.primary)
 864                        }
 865                    }
 866                }
 867            }
 868        }
 869    }
 870
 871    private var levelColor: Color {
 872        switch report.riskAssessment.level {
 873        case .low:
 874            return .green
 875        case .medium:
 876            return .yellow
 877        case .high:
 878            return .red
 879        }
 880    }
 881
 882    private func factorColor(_ impact: RiskImpact) -> Color {
 883        switch impact {
 884        case .positive:
 885            return .green
 886        case .neutral:
 887            return .secondary
 888        case .negative:
 889            return .red
 890        }
 891    }
 892}
 893
 894struct InsightsSummaryCardView: View {
 895    @Environment(\.appDensity) private var appDensity
 896    let insights: [String]
 897
 898    var body: some View {
 899        VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
 900            SectionTitleView(title: "Insights")
 901            CardView(allowsHorizontalScroll: false) {
 902                if insights.isEmpty {
 903                    Text("No deterministic insights triggered")
 904                        .font(appDensity.font(.caption))
 905                        .foregroundStyle(.secondary)
 906                } else {
 907                    ForEach(Array(insights.enumerated()), id: \.offset) { _, insight in
 908                        HStack(alignment: .top, spacing: 8) {
 909                            Image(systemName: "chart.line.uptrend.xyaxis")
 910                                .font(appDensity.font(.caption2))
 911                                .foregroundStyle(.cyan)
 912                                .padding(.top, 2)
 913                            Text(insight)
 914                                .font(appDensity.font(.caption))
 915                                .foregroundStyle(.primary)
 916                        }
 917                    }
 918                }
 919            }
 920        }
 921    }
 922}
 923
 924struct StickyLookupSummaryView: View {
 925    @Environment(\.appDensity) private var appDensity
 926
 927    let domain: String
 928    let availability: DomainAvailabilityStatus?
 929    let primaryIP: String?
 930    let sslInfo: SSLCertificateInfo?
 931    let sslError: String?
 932    let emailSecurity: EmailSecurityResult?
 933    let emailError: String?
 934    let changeSummary: DomainChangeSummary?
 935
 936    var body: some View {
 937        CardView(allowsHorizontalScroll: false) {
 938            VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
 939                HStack(alignment: .center, spacing: 10) {
 940                    Text(domain)
 941                        .font(appDensity.font(.headline, weight: .semibold))
 942                        .foregroundStyle(.primary)
 943                        .lineLimit(1)
 944                    Spacer(minLength: 6)
 945                    AppCopyButton(value: domain, label: "Copy domain")
 946                }
 947
 948                ScrollView(.horizontal, showsIndicators: false) {
 949                    HStack(spacing: 8) {
 950                        AppStatusBadgeView(model: AppStatusFactory.availability(availability))
 951                        AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError))
 952                        AppStatusBadgeView(model: AppStatusFactory.email(emailSecurity, error: emailError))
 953                        AppStatusBadgeView(model: AppStatusFactory.change(changeSummary))
 954                    }
 955                }
 956
 957                if let primaryIP {
 958                    HStack(spacing: 8) {
 959                        Label(primaryIP, systemImage: "network")
 960                            .font(appDensity.font(.caption))
 961                            .foregroundStyle(.secondary)
 962                        Spacer(minLength: 6)
 963                        AppCopyButton(value: primaryIP, label: "Copy IP")
 964                    }
 965                }
 966            }
 967        }
 968        .shadow(color: .black.opacity(0.12), radius: 14, y: 6)
 969    }
 970}
 971
 972struct LookupProgressOverviewView: View {
 973    @Environment(\.appDensity) private var appDensity
 974    let steps: [String]
 975
 976    var body: some View {
 977        CardView(allowsHorizontalScroll: false) {
 978            HStack(spacing: 8) {
 979                ProgressView()
 980                    .controlSize(.small)
 981                VStack(alignment: .leading, spacing: 4) {
 982                    Text("Running lookup…")
 983                        .font(appDensity.font(.caption))
 984                        .foregroundStyle(.primary)
 985                    Text(steps.isEmpty ? "Preparing requests" : steps.joined(separator: ""))
 986                        .font(appDensity.font(.caption2))
 987                        .foregroundStyle(.secondary)
 988                }
 989                Spacer()
 990            }
 991        }
 992    }
 993}
 994
 995struct LookupStatusBannerView: View {
 996    @Environment(\.appDensity) private var appDensity
 997    let message: String
 998    let resultSource: LookupResultSource
 999
1000    var body: some View {
1001        HStack(spacing: 8) {
1002            Image(systemName: iconName)
1003                .font(.caption)
1004            Text(message)
1005                .font(appDensity.font(.caption))
1006            Spacer()
1007        }
1008        .foregroundStyle(color)
1009        .padding(appDensity.metrics.cardPadding - 2)
1010        .frame(maxWidth: .infinity, alignment: .leading)
1011        .background(color.opacity(0.12))
1012        .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
1013    }
1014
1015    private var color: Color {
1016        switch resultSource {
1017        case .live:
1018            return .green
1019        case .cached:
1020            return .secondary
1021        case .mixed:
1022            return .yellow
1023        case .snapshot:
1024            return .orange
1025        }
1026    }
1027
1028    private var iconName: String {
1029        switch resultSource {
1030        case .live:
1031            return "bolt.horizontal"
1032        case .cached:
1033            return "clock.arrow.trianglehead.counterclockwise.rotate.90"
1034        case .mixed:
1035            return "arrow.triangle.branch"
1036        case .snapshot:
1037            return "archivebox"
1038        }
1039    }
1040}
1041
1042struct DomainChangeSummaryView: View {
1043    @Environment(\.appDensity) private var appDensity
1044    let summary: DomainChangeSummary
1045    @State private var showsDetails = false
1046
1047    var body: some View {
1048        CardView(allowsHorizontalScroll: false) {
1049            HStack {
1050                Label(summary.hasChanges ? "Changed" : "Stable", systemImage: summary.hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle")
1051                    .font(appDensity.font(.caption))
1052                    .foregroundStyle(summary.hasChanges ? severityColor(summary.severity) : .green)
1053                Spacer()
1054                Text(summary.severity.title.uppercased())
1055                    .font(appDensity.font(.caption2))
1056                    .foregroundStyle(summary.hasChanges ? severityColor(summary.severity) : .secondary)
1057                    .padding(.horizontal, 8)
1058                    .padding(.vertical, 4)
1059                    .background((summary.hasChanges ? severityColor(summary.severity) : .secondary).opacity(0.16))
1060                    .clipShape(Capsule())
1061                Text(summary.impactClassification.title.uppercased())
1062                    .font(appDensity.font(.caption2))
1063                    .foregroundStyle(impactColor(summary.impactClassification))
1064                    .padding(.horizontal, 8)
1065                    .padding(.vertical, 4)
1066                    .background(impactColor(summary.impactClassification).opacity(0.16))
1067                    .clipShape(Capsule())
1068                Text(summary.generatedAt, style: .time)
1069                    .font(appDensity.font(.caption2))
1070                    .foregroundStyle(.secondary)
1071            }
1072
1073            VStack(alignment: .leading, spacing: 4) {
1074                Text("Inference")
1075                    .font(appDensity.font(.caption2))
1076                    .foregroundStyle(.secondary)
1077                Text(summary.message)
1078                    .font(appDensity.font(.caption))
1079                    .foregroundStyle(.primary)
1080                    .lineLimit(2)
1081            }
1082
1083            if !summary.observedFacts.isEmpty || summary.contextNote != nil {
1084                DisclosureGroup(showsDetails ? "Hide Details" : "Show Details", isExpanded: $showsDetails) {
1085                    VStack(alignment: .leading, spacing: 8) {
1086                        if !summary.observedFacts.isEmpty {
1087                            VStack(alignment: .leading, spacing: 4) {
1088                                Text("Observed")
1089                                    .font(appDensity.font(.caption2))
1090                                    .foregroundStyle(.secondary)
1091                                ForEach(Array(summary.observedFacts.enumerated()), id: \.offset) { _, fact in
1092                                    Text(fact)
1093                                        .font(appDensity.font(.caption))
1094                                        .foregroundStyle(.primary)
1095                                }
1096                            }
1097                        }
1098
1099                        if let riskScoreDelta = summary.riskScoreDelta {
1100                            Text("Risk delta: \(riskScoreDelta >= 0 ? "+" : "")\(riskScoreDelta)")
1101                                .font(appDensity.font(.caption2))
1102                                .foregroundStyle(riskScoreDelta > 0 ? .orange : .secondary)
1103                        }
1104
1105                        if let contextNote = summary.contextNote {
1106                            Text(contextNote)
1107                                .font(appDensity.font(.caption2))
1108                                .foregroundStyle(.orange)
1109                        }
1110                    }
1111                    .padding(.top, 4)
1112                }
1113                .font(appDensity.font(.caption))
1114                .tint(.secondary)
1115            }
1116        }
1117    }
1118
1119    private func severityColor(_ severity: ChangeSeverity) -> Color {
1120        switch severity {
1121        case .low:
1122            return .secondary
1123        case .medium:
1124            return .yellow
1125        case .high:
1126            return .red
1127        }
1128    }
1129
1130    private func impactColor(_ impact: ChangeImpactClassification) -> Color {
1131        switch impact {
1132        case .informational:
1133            return .secondary
1134        case .warning:
1135            return .yellow
1136        case .critical:
1137            return .red
1138        }
1139    }
1140}
1141
1142struct DomainDiffView: View {
1143    let title: String
1144    let sections: [DomainDiffSection]
1145    let contextNote: String?
1146    let showsUnchanged: Bool
1147    let highlightedSectionID: String?
1148
1149    @State private var collapsedSections = Set<String>()
1150    @State private var showsLowSeverity = false
1151
1152    private var filteredSections: [DomainDiffSection] {
1153        sections
1154            .map { section in
1155                let items = section.items.filter { item in
1156                    if !showsUnchanged, !item.hasChanges {
1157                        return false
1158                    }
1159                    if showsLowSeverity {
1160                        return true
1161                    }
1162                    return item.severity >= .medium || (showsUnchanged && item.changeType == .unchanged)
1163                }
1164                return DomainDiffSection(id: section.id, title: section.title, items: items)
1165            }
1166            .filter { !$0.items.isEmpty }
1167    }
1168
1169    private var hasLowSeverityChanges: Bool {
1170        sections.flatMap(\.items).contains { $0.hasChanges && $0.severity == .low }
1171    }
1172
1173    var body: some View {
1174        VStack(alignment: .leading, spacing: 12) {
1175            HStack {
1176                SectionTitleView(title: title)
1177                Spacer()
1178                if hasLowSeverityChanges {
1179                    Button(showsLowSeverity ? "Hide Low" : "Show Low") {
1180                        showsLowSeverity.toggle()
1181                    }
1182                    .buttonStyle(.bordered)
1183                    .font(.system(.caption, design: .monospaced))
1184                }
1185            }
1186            if let contextNote {
1187                MessageCardView(text: contextNote, isError: false)
1188            }
1189            if filteredSections.isEmpty {
1190                MessageCardView(text: "No comparison data available", isError: false)
1191            } else {
1192                ForEach(filteredSections) { section in
1193                    CardView(allowsHorizontalScroll: false) {
1194                        DisclosureGroup(isExpanded: binding(for: section)) {
1195                            let visibleItems = showsUnchanged ? section.items : section.items.filter(\.hasChanges)
1196
1197                            ForEach(visibleItems) { item in
1198                                VStack(alignment: .leading, spacing: 6) {
1199                                    HStack {
1200                                        Text(item.label)
1201                                            .font(.system(.caption, design: .monospaced))
1202                                            .foregroundStyle(.secondary)
1203                                        Spacer()
1204                                        Text("\(item.changeType.marker) \(item.severity.title)\(changeLabel(for: item.changeType))")
1205                                            .font(.system(.caption2, design: .monospaced))
1206                                            .foregroundStyle(changeColor(for: item))
1207                                            .padding(.horizontal, 8)
1208                                            .padding(.vertical, 4)
1209                                            .background(changeColor(for: item).opacity(0.16))
1210                                            .clipShape(Capsule())
1211                                    }
1212
1213                                    if let oldValue = item.oldValue {
1214                                        VStack(alignment: .leading, spacing: 2) {
1215                                            Text("Old")
1216                                                .font(.system(.caption2, design: .monospaced))
1217                                                .foregroundStyle(.secondary)
1218                                            Text(oldValue)
1219                                                .font(.system(.caption2, design: .monospaced))
1220                                                .foregroundStyle(.secondary)
1221                                                .textSelection(.enabled)
1222                                        }
1223                                    }
1224
1225                                    if let newValue = item.newValue {
1226                                        VStack(alignment: .leading, spacing: 2) {
1227                                            Text("New")
1228                                                .font(.system(.caption2, design: .monospaced))
1229                                                .foregroundStyle(.secondary)
1230                                            Text(newValue)
1231                                                .font(.system(.caption, design: .monospaced))
1232                                                .foregroundStyle(item.hasChanges ? .primary : .secondary)
1233                                                .textSelection(.enabled)
1234                                        }
1235                                    }
1236                                }
1237                                .padding(10)
1238                                .background(item.hasChanges ? changeColor(for: item).opacity(0.08) : Color(.systemGray6).opacity(0.25))
1239                                .cornerRadius(8)
1240                            }
1241                        } label: {
1242                            HStack {
1243                                Text(section.title)
1244                                    .font(.system(.subheadline, design: .monospaced))
1245                                    .fontWeight(.semibold)
1246                                    .foregroundStyle(sectionColor(section))
1247                                Spacer()
1248                                Text(section.severity.title)
1249                                    .font(.system(.caption2, design: .monospaced))
1250                                    .foregroundStyle(sectionColor(section))
1251                            }
1252                        }
1253                    }
1254                    .id(section.id)
1255                    .overlay {
1256                        if highlightedSectionID == section.id {
1257                            RoundedRectangle(cornerRadius: 12)
1258                                .stroke(Color.cyan.opacity(0.55), lineWidth: 1)
1259                        }
1260                    }
1261                }
1262            }
1263        }
1264        .onAppear {
1265            collapsedSections = Set(sections.filter { !showsUnchanged && !$0.hasChanges }.map(\.id))
1266        }
1267    }
1268
1269    private func changeLabel(for changeType: DiffChangeType) -> String {
1270        switch changeType {
1271        case .added:
1272            return "Added"
1273        case .removed:
1274            return "Removed"
1275        case .changed:
1276            return "Changed"
1277        case .unchanged:
1278            return "Unchanged"
1279        }
1280    }
1281
1282    private func changeColor(for item: DomainDiffItem) -> Color {
1283        if item.changeType == .unchanged {
1284            return .secondary
1285        }
1286
1287        switch item.severity {
1288        case .low:
1289            return .blue
1290        case .medium:
1291            return .yellow
1292        case .high:
1293            return .red
1294        }
1295    }
1296
1297    private func sectionColor(_ section: DomainDiffSection) -> Color {
1298        switch section.severity {
1299        case .low:
1300            return .blue
1301        case .medium:
1302            return .yellow
1303        case .high:
1304            return .red
1305        }
1306    }
1307
1308    private func binding(for section: DomainDiffSection) -> Binding<Bool> {
1309        Binding(
1310            get: { !collapsedSections.contains(section.id) },
1311            set: { isExpanded in
1312                if isExpanded {
1313                    collapsedSections.remove(section.id)
1314                } else {
1315                    collapsedSections.insert(section.id)
1316                }
1317            }
1318        )
1319    }
1320}
1321
1322struct TrackedDomainDetailHeaderView: View {
1323    let trackedDomain: TrackedDomain
1324
1325    var body: some View {
1326        VStack(alignment: .leading, spacing: 4) {
1327            if let note = trackedDomain.note?.nilIfEmpty {
1328                LabeledValueRow(row: InfoRowViewData(label: "Tracking Note", value: note, tone: .secondary))
1329            }
1330            HStack(spacing: 8) {
1331                if trackedDomain.isPinned {
1332                    Label("Pinned", systemImage: "pin.fill")
1333                }
1334                Text("Last refresh \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))")
1335            }
1336            .font(.system(.caption2, design: .monospaced))
1337            .foregroundStyle(.secondary)
1338        }
1339    }
1340}
1341
1342struct DomainSectionView: View {
1343    @Environment(\.appDensity) private var appDensity
1344    @Binding var isCollapsed: Bool
1345    let rows: [InfoRowViewData]
1346    let suggestions: [DomainSuggestionViewData]
1347    let showSuggestions: Bool
1348    let availabilityLoading: Bool
1349    let suggestionsLoading: Bool
1350    let provenance: SectionProvenance?
1351    let confidence: ConfidenceLevel?
1352    let snapshotNote: String?
1353    let trackedDomain: TrackedDomain?
1354    let workflows: [DomainWorkflow]
1355    let trackingLimitMessage: String?
1356    let pricingLoading: Bool
1357    let pricingError: String?
1358    let showsPricingPlaceholder: Bool
1359    let onTrack: () -> Void
1360    let onTogglePinned: () -> Void
1361    let onEditNote: (() -> Void)?
1362    let onAddToWorkflow: (() -> Void)?
1363    let onOpenWorkflow: ((DomainWorkflow) -> Void)?
1364    let onRunWorkflow: ((DomainWorkflow) -> Void)?
1365
1366    var body: some View {
1367        CollapsibleSectionView(title: "Domain", isCollapsed: $isCollapsed) {
1368            if let trackedDomain {
1369                HStack(spacing: 8) {
1370                    AppStatusBadgeView(model: .init(title: "Tracked", systemImage: "eye.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16)))
1371                    Button {
1372                        onTogglePinned()
1373                    } label: {
1374                        Image(systemName: trackedDomain.isPinned ? "pin.fill" : "pin")
1375                    }
1376                    .buttonStyle(.bordered)
1377                    .font(appDensity.font(.caption))
1378                    if let onEditNote {
1379                        Button("Note") {
1380                            onEditNote()
1381                        }
1382                        .buttonStyle(.bordered)
1383                        .font(appDensity.font(.caption))
1384                    }
1385                }
1386            } else {
1387                Button("Track") {
1388                    AppHaptics.track()
1389                    onTrack()
1390                }
1391                .buttonStyle(.bordered)
1392                .font(appDensity.font(.caption))
1393            }
1394        } content: {
1395            CardView(allowsHorizontalScroll: false) {
1396                SectionTrustMetadataView(
1397                    provenance: provenance,
1398                    confidence: confidence,
1399                    note: snapshotNote == nil ? nil : "Audit note present"
1400                )
1401                ForEach(rows) { row in
1402                    LabeledValueRow(row: row)
1403                }
1404                if let trackedDomain {
1405                    TrackedDomainDetailHeaderView(trackedDomain: trackedDomain)
1406                        .padding(.top, 4)
1407                } else if let trackingLimitMessage {
1408                    MessageRowView(text: trackingLimitMessage, isError: false)
1409                        .padding(.top, 4)
1410                }
1411                if let onAddToWorkflow {
1412                    Button {
1413                        onAddToWorkflow()
1414                    } label: {
1415                        Label("Add to workflow", systemImage: "plus.rectangle.on.folder")
1416                            .font(appDensity.font(.caption))
1417                    }
1418                    .buttonStyle(.bordered)
1419                    .padding(.top, 4)
1420                }
1421                if !workflows.isEmpty {
1422                    VStack(alignment: .leading, spacing: 8) {
1423                        Text("Part of workflow")
1424                            .font(appDensity.font(.caption))
1425                            .foregroundStyle(.secondary)
1426
1427                        ForEach(workflows) { workflow in
1428                            HStack {
1429                                Text(workflow.name)
1430                                    .font(appDensity.font(.caption))
1431                                    .foregroundStyle(.primary)
1432                                Spacer()
1433                                if let onOpenWorkflow {
1434                                    Button("Open") {
1435                                        onOpenWorkflow(workflow)
1436                                    }
1437                                    .buttonStyle(.bordered)
1438                                    .font(appDensity.font(.caption2))
1439                                }
1440                                if let onRunWorkflow {
1441                                    Button("Run") {
1442                                        onRunWorkflow(workflow)
1443                                    }
1444                                    .buttonStyle(.bordered)
1445                                    .font(appDensity.font(.caption2))
1446                                }
1447                            }
1448                        }
1449                    }
1450                    .padding(.top, 4)
1451                }
1452                if availabilityLoading {
1453                    ProgressView("Checking availability…")
1454                        .appLoadingStyle()
1455                        .padding(.top, 4)
1456                }
1457                if showSuggestions {
1458                    Text("Suggestions")
1459                        .font(appDensity.font(.caption))
1460                        .foregroundStyle(.secondary)
1461                        .padding(.top, 4)
1462                    if suggestionsLoading {
1463                        ProgressView("Checking alternatives…")
1464                            .appLoadingStyle()
1465                    } else if suggestions.isEmpty {
1466                        MessageRowView(text: "No suggestions", isError: false)
1467                    } else {
1468                        ForEach(suggestions) { suggestion in
1469                            HStack {
1470                                Text(suggestion.domain)
1471                                    .font(appDensity.font(.caption))
1472                                    .foregroundStyle(.primary)
1473                                    .textSelection(.enabled)
1474                                Spacer()
1475                                AppStatusBadgeView(model: AppStatusFactory.availability(suggestion.availabilityStatus))
1476                            }
1477                        }
1478                    }
1479                }
1480                if pricingLoading {
1481                    ProgressView("Loading external pricing…")
1482                        .appLoadingStyle()
1483                        .padding(.top, 4)
1484                } else if let pricingError {
1485                    MessageRowView(text: pricingError, isError: false)
1486                        .padding(.top, 4)
1487                } else if showsPricingPlaceholder {
1488                    MessageRowView(text: "Pricing signals available in Pro+", isError: false)
1489                        .padding(.top, 4)
1490                }
1491            }
1492        }
1493    }
1494}
1495
1496struct OwnershipSectionView: View {
1497    @Environment(\.appDensity) private var appDensity
1498    @Binding var isCollapsed: Bool
1499    let rows: [InfoRowViewData]
1500    let loading: Bool
1501    let error: String?
1502    let provenance: SectionProvenance?
1503    let confidence: ConfidenceLevel?
1504    let showsHistoryPlaceholder: Bool
1505    let history: [DomainOwnershipHistoryEvent]
1506    let historyLoading: Bool
1507    let historyError: String?
1508    let historyCreditStatus: UsageCreditStatus?
1509    let onLoadHistory: (() -> Void)?
1510
1511    init(
1512        isCollapsed: Binding<Bool>,
1513        rows: [InfoRowViewData],
1514        loading: Bool,
1515        error: String?,
1516        provenance: SectionProvenance?,
1517        confidence: ConfidenceLevel?,
1518        showsHistoryPlaceholder: Bool,
1519        history: [DomainOwnershipHistoryEvent] = [],
1520        historyLoading: Bool = false,
1521        historyError: String? = nil,
1522        historyCreditStatus: UsageCreditStatus? = nil,
1523        onLoadHistory: (() -> Void)? = nil
1524    ) {
1525        _isCollapsed = isCollapsed
1526        self.rows = rows
1527        self.loading = loading
1528        self.error = error
1529        self.provenance = provenance
1530        self.confidence = confidence
1531        self.showsHistoryPlaceholder = showsHistoryPlaceholder
1532        self.history = history
1533        self.historyLoading = historyLoading
1534        self.historyError = historyError
1535        self.historyCreditStatus = historyCreditStatus
1536        self.onLoadHistory = onLoadHistory
1537    }
1538
1539    var body: some View {
1540        CollapsibleSectionView(title: "Ownership", isCollapsed: $isCollapsed) {
1541            CardView(allowsHorizontalScroll: false) {
1542                SectionTrustMetadataView(provenance: provenance, confidence: confidence)
1543                if loading {
1544                    ProgressView("Fetching RDAP ownership…")
1545                        .appLoadingStyle()
1546                } else {
1547                    ForEach(rows) { row in
1548                        LabeledValueRow(row: row)
1549                    }
1550                    if let error, rows.allSatisfy({ $0.value == "Unavailable" }) {
1551                        MessageRowView(text: error, isError: error != "Unavailable")
1552                            .padding(.top, 4)
1553                    }
1554                    VStack(alignment: .leading, spacing: 8) {
1555                        HStack {
1556                            Text("History")
1557                                .font(appDensity.font(.caption))
1558                                .foregroundStyle(.secondary)
1559                            Spacer()
1560                            if let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder {
1561                                Button("Load") {
1562                                    onLoadHistory()
1563                                }
1564                                .buttonStyle(.bordered)
1565                                .font(appDensity.font(.caption2))
1566                            }
1567                        }
1568                        if historyLoading {
1569                            ProgressView("Loading history…")
1570                                .appLoadingStyle()
1571                        } else if !history.isEmpty {
1572                            ForEach(history) { event in
1573                                VStack(alignment: .leading, spacing: 3) {
1574                                    Text(event.date.formatted(date: .abbreviated, time: .omitted))
1575                                        .font(appDensity.font(.caption2))
1576                                        .foregroundStyle(.secondary)
1577                                    Text(event.summary)
1578                                        .font(appDensity.font(.caption))
1579                                    Text(event.source)
1580                                        .font(appDensity.font(.caption2))
1581                                        .foregroundStyle(.secondary)
1582                                }
1583                            }
1584                        } else if let historyError {
1585                            MessageRowView(text: historyError, isError: false)
1586                        } else if showsHistoryPlaceholder {
1587                            MessageRowView(text: "Ownership history available in Pro+", isError: false)
1588                        }
1589                    }
1590                }
1591            }
1592        }
1593    }
1594}
1595
1596struct IntelligenceSectionView: View {
1597    @Environment(\.appDensity) private var appDensity
1598    @Binding var isCollapsed: Bool
1599    let report: DomainReport
1600    let showsPlaceholder: Bool
1601
1602    var body: some View {
1603        CollapsibleSectionView(title: "Data+ Intelligence", isCollapsed: $isCollapsed) {
1604            CardView(allowsHorizontalScroll: false) {
1605                if showsPlaceholder {
1606                    MessageRowView(text: "Richer intelligence history, hosting analysis, and risk signals are available in Pro+", isError: false)
1607                } else {
1608                    if let provider = report.inferredProvider {
1609                        intelligenceBlock(title: "Infrastructure") {
1610                            LabeledValueRow(row: .init(label: "Provider", value: provider.name, tone: .primary))
1611                            if !provider.evidence.isEmpty {
1612                                MessageRowView(text: provider.evidence.joined(separator: ""), isError: false)
1613                            }
1614                            if !report.priorProviders.isEmpty {
1615                                LabeledValueRow(row: .init(label: "Prior", value: report.priorProviders.joined(separator: ", "), tone: .secondary))
1616                            }
1617                        }
1618                    }
1619                    if let classification = report.domainClassification {
1620                        intelligenceBlock(title: "Classification") {
1621                            LabeledValueRow(row: .init(label: "Purpose", value: classification.kind.title, tone: .primary))
1622                            MessageRowView(text: classification.reasons.joined(separator: ""), isError: false)
1623                        }
1624                    }
1625                    intelligenceBlock(title: "Risk Signals") {
1626                        if report.riskSignals.isEmpty {
1627                            MessageRowView(text: "No material historical risk signals detected", isError: false)
1628                        } else {
1629                            ForEach(report.riskSignals.prefix(4)) { signal in
1630                                VStack(alignment: .leading, spacing: 3) {
1631                                    Text(signal.title)
1632                                        .font(appDensity.font(.caption, weight: .semibold))
1633                                    Text(signal.detail)
1634                                        .font(appDensity.font(.caption2))
1635                                        .foregroundStyle(.secondary)
1636                                }
1637                            }
1638                        }
1639                    }
1640                    intelligenceBlock(title: "Ownership History") {
1641                        if report.ownershipTransitions.isEmpty {
1642                            MessageRowView(text: "No ownership transitions observed locally", isError: false)
1643                        } else {
1644                            ForEach(report.ownershipTransitions.prefix(4)) { event in
1645                                intelligenceEventRow(date: event.date, title: event.summary)
1646                            }
1647                        }
1648                    }
1649                    intelligenceBlock(title: "Hosting History") {
1650                        if report.hostingTransitions.isEmpty {
1651                            MessageRowView(text: "No hosting transitions observed locally", isError: false)
1652                        } else {
1653                            ForEach(report.hostingTransitions.prefix(4)) { event in
1654                                intelligenceEventRow(date: event.date, title: event.summary)
1655                            }
1656                        }
1657                    }
1658                    intelligenceBlock(title: "Subdomain Intelligence") {
1659                        if report.subdomainHistory.isEmpty {
1660                            MessageRowView(text: "No subdomain history available", isError: false)
1661                        } else {
1662                            ForEach(report.subdomainHistory.prefix(5)) { item in
1663                                VStack(alignment: .leading, spacing: 3) {
1664                                    HStack {
1665                                        Text(item.hostname)
1666                                            .font(appDensity.font(.caption))
1667                                        Spacer()
1668                                        if item.isEphemeral {
1669                                            Text("Ephemeral")
1670                                                .font(appDensity.font(.caption2))
1671                                                .foregroundStyle(.yellow)
1672                                        }
1673                                    }
1674                                    Text("First \(item.firstSeen.formatted(date: .abbreviated, time: .omitted)) • Last \(item.lastSeen.formatted(date: .abbreviated, time: .omitted)) • Seen \(item.recurrenceCount)x")
1675                                        .font(appDensity.font(.caption2))
1676                                        .foregroundStyle(.secondary)
1677                                }
1678                            }
1679                        }
1680                    }
1681                    intelligenceBlock(title: "Timeline") {
1682                        if report.intelligenceTimeline.isEmpty {
1683                            MessageRowView(text: "No inferred intelligence events yet", isError: false)
1684                        } else {
1685                            ForEach(report.intelligenceTimeline.prefix(5)) { event in
1686                                intelligenceEventRow(date: event.date, title: "\(event.title): \(event.detail)")
1687                            }
1688                        }
1689                    }
1690                }
1691            }
1692        }
1693    }
1694
1695    @ViewBuilder
1696    private func intelligenceBlock<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
1697        VStack(alignment: .leading, spacing: 8) {
1698            Text(title)
1699                .font(appDensity.font(.subheadline, weight: .semibold))
1700                .foregroundStyle(.cyan)
1701            content()
1702        }
1703    }
1704
1705    private func intelligenceEventRow(date: Date, title: String) -> some View {
1706        VStack(alignment: .leading, spacing: 3) {
1707            Text(date.formatted(date: .abbreviated, time: .omitted))
1708                .font(appDensity.font(.caption2))
1709                .foregroundStyle(.secondary)
1710            Text(title)
1711                .font(appDensity.font(.caption))
1712        }
1713    }
1714}
1715
1716struct SubdomainsSectionView: View {
1717    @Environment(\.appDensity) private var appDensity
1718    @Binding var isCollapsed: Bool
1719    let rows: [SubdomainRowViewData]
1720    let groups: [SubdomainGroup]
1721    let loading: Bool
1722    let error: String?
1723    let provenance: SectionProvenance?
1724    let confidence: ConfidenceLevel?
1725    let showsExtendedPlaceholder: Bool
1726    let extendedCount: Int
1727    let extendedLoading: Bool
1728    let extendedError: String?
1729    let extendedCreditStatus: UsageCreditStatus?
1730    let onLoadExtended: (() -> Void)?
1731
1732    init(
1733        isCollapsed: Binding<Bool>,
1734        rows: [SubdomainRowViewData],
1735        groups: [SubdomainGroup],
1736        loading: Bool,
1737        error: String?,
1738        provenance: SectionProvenance?,
1739        confidence: ConfidenceLevel?,
1740        showsExtendedPlaceholder: Bool,
1741        extendedCount: Int = 0,
1742        extendedLoading: Bool = false,
1743        extendedError: String? = nil,
1744        extendedCreditStatus: UsageCreditStatus? = nil,
1745        onLoadExtended: (() -> Void)? = nil
1746    ) {
1747        _isCollapsed = isCollapsed
1748        self.rows = rows
1749        self.groups = groups
1750        self.loading = loading
1751        self.error = error
1752        self.provenance = provenance
1753        self.confidence = confidence
1754        self.showsExtendedPlaceholder = showsExtendedPlaceholder
1755        self.extendedCount = extendedCount
1756        self.extendedLoading = extendedLoading
1757        self.extendedError = extendedError
1758        self.extendedCreditStatus = extendedCreditStatus
1759        self.onLoadExtended = onLoadExtended
1760    }
1761
1762    var body: some View {
1763        CollapsibleSectionView(title: "Subdomains", isCollapsed: $isCollapsed, subtitle: "\(rows.count) found") {
1764            CardView(allowsHorizontalScroll: false) {
1765                SectionTrustMetadataView(provenance: provenance, confidence: confidence)
1766                if loading {
1767                    ProgressView("Checking certificate transparency…")
1768                        .appLoadingStyle()
1769                } else if rows.isEmpty {
1770                    MessageRowView(text: error ?? "No passive subdomains found", isError: false)
1771                    if showsExtendedPlaceholder {
1772                        MessageRowView(text: "Extended subdomain discovery available in Pro+", isError: false)
1773                            .padding(.top, 4)
1774                    }
1775                } else {
1776                    if let onLoadExtended, extendedCount == 0, !extendedLoading, !showsExtendedPlaceholder {
1777                        Button("Load extended results") {
1778                            onLoadExtended()
1779                        }
1780                        .buttonStyle(.bordered)
1781                        .font(appDensity.font(.caption2))
1782                    }
1783                    if !groups.isEmpty {
1784                        Text("Groups")
1785                            .font(appDensity.font(.caption2))
1786                            .foregroundStyle(.secondary)
1787                        ForEach(groups) { group in
1788                            HStack {
1789                                Text("\(group.label).*")
1790                                    .font(appDensity.font(.caption))
1791                                    .foregroundStyle(.cyan)
1792                                Spacer()
1793                                Text("\(group.subdomains.count)")
1794                                    .font(appDensity.font(.caption2))
1795                                    .foregroundStyle(.secondary)
1796                            }
1797                        }
1798                    }
1799                    ForEach(rows) { row in
1800                        HStack(spacing: 8) {
1801                            Text(row.hostname)
1802                                .font(appDensity.font(.caption))
1803                                .foregroundStyle(.primary)
1804                                .textSelection(.enabled)
1805                            Spacer()
1806                            if row.isInteresting {
1807                                Text("Interesting")
1808                                    .font(.system(.caption2, design: .monospaced))
1809                                    .foregroundStyle(.yellow)
1810                                    .padding(.horizontal, 8)
1811                                    .padding(.vertical, 4)
1812                                    .background(Color.yellow.opacity(0.14))
1813                                    .clipShape(Capsule())
1814                            }
1815                        }
1816                    }
1817                    if extendedLoading {
1818                        ProgressView("Loading extended subdomains…")
1819                            .appLoadingStyle()
1820                            .padding(.top, 4)
1821                    } else if extendedCount > 0 {
1822                        MessageRowView(text: "\(extendedCount) extended results included", isError: false)
1823                            .padding(.top, 4)
1824                    } else if let extendedError {
1825                        MessageRowView(text: extendedError, isError: false)
1826                            .padding(.top, 4)
1827                    } else if showsExtendedPlaceholder {
1828                        MessageRowView(text: "Extended subdomain discovery available in Pro+", isError: false)
1829                            .padding(.top, 4)
1830                    }
1831                }
1832            }
1833        }
1834    }
1835}
1836
1837struct DNSSectionView: View {
1838    @Environment(\.appDensity) private var appDensity
1839    @Binding var isCollapsed: Bool
1840    let dnssecLabel: String?
1841    let patternSummary: DNSPatternSummary?
1842    let sections: [DNSRecordSectionViewData]
1843    let ptrMessage: SectionMessageViewData?
1844    let loading: Bool
1845    let dnsProvenance: SectionProvenance?
1846    let ptrProvenance: SectionProvenance?
1847    let sectionError: String?
1848    let history: [DNSHistoryEvent]
1849    let historyLoading: Bool
1850    let historyError: String?
1851    let showsHistoryPlaceholder: Bool
1852    let historyCreditStatus: UsageCreditStatus?
1853    let onLoadHistory: (() -> Void)?
1854
1855    init(
1856        isCollapsed: Binding<Bool>,
1857        dnssecLabel: String?,
1858        patternSummary: DNSPatternSummary?,
1859        sections: [DNSRecordSectionViewData],
1860        ptrMessage: SectionMessageViewData?,
1861        loading: Bool,
1862        dnsProvenance: SectionProvenance?,
1863        ptrProvenance: SectionProvenance?,
1864        sectionError: String?,
1865        history: [DNSHistoryEvent] = [],
1866        historyLoading: Bool = false,
1867        historyError: String? = nil,
1868        showsHistoryPlaceholder: Bool = false,
1869        historyCreditStatus: UsageCreditStatus? = nil,
1870        onLoadHistory: (() -> Void)? = nil
1871    ) {
1872        _isCollapsed = isCollapsed
1873        self.dnssecLabel = dnssecLabel
1874        self.patternSummary = patternSummary
1875        self.sections = sections
1876        self.ptrMessage = ptrMessage
1877        self.loading = loading
1878        self.dnsProvenance = dnsProvenance
1879        self.ptrProvenance = ptrProvenance
1880        self.sectionError = sectionError
1881        self.history = history
1882        self.historyLoading = historyLoading
1883        self.historyError = historyError
1884        self.showsHistoryPlaceholder = showsHistoryPlaceholder
1885        self.historyCreditStatus = historyCreditStatus
1886        self.onLoadHistory = onLoadHistory
1887    }
1888
1889    var body: some View {
1890        CollapsibleSectionView(title: "DNS", isCollapsed: $isCollapsed, subtitle: dnssecLabel) {
1891            if loading {
1892                LoadingCardView(text: "Querying DNS…")
1893            } else if let sectionError, sections.isEmpty {
1894                MessageCardView(text: sectionError, isError: true)
1895            } else {
1896                if dnsProvenance != nil {
1897                    CardView(allowsHorizontalScroll: false) {
1898                        SectionTrustMetadataView(provenance: dnsProvenance, confidence: nil)
1899                        if let patternSummary {
1900                            if !patternSummary.providers.isEmpty {
1901                                MessageRowView(text: "Providers: \(patternSummary.providers.joined(separator: ", "))", isError: false)
1902                            }
1903                            if !patternSummary.patterns.isEmpty {
1904                                ForEach(Array(patternSummary.patterns.enumerated()), id: \.offset) { _, pattern in
1905                                    MessageRowView(text: pattern, isError: false)
1906                                }
1907                            }
1908                        }
1909                    }
1910                }
1911                ForEach(sections) { section in
1912                    CardView {
1913                        Text(section.title)
1914                            .font(.system(.subheadline, design: .monospaced))
1915                            .fontWeight(.semibold)
1916                            .foregroundStyle(.cyan)
1917
1918                        if let message = section.message {
1919                            MessageRowView(text: message.text, isError: message.isError)
1920                        }
1921
1922                        ForEach(section.rows) { row in
1923                            LabeledValueRow(row: row)
1924                        }
1925
1926                        if let wildcardTitle = section.wildcardTitle {
1927                            Text(wildcardTitle)
1928                                .font(.system(.caption, design: .monospaced))
1929                                .foregroundStyle(.secondary)
1930                                .padding(.top, 4)
1931                            ForEach(section.wildcardRows) { row in
1932                                LabeledValueRow(row: row)
1933                            }
1934                        }
1935                    }
1936                }
1937
1938                if let ptrMessage {
1939                    CardView {
1940                        Text("PTR")
1941                            .font(.system(.subheadline, design: .monospaced))
1942                            .fontWeight(.semibold)
1943                            .foregroundStyle(.cyan)
1944                        SectionTrustMetadataView(provenance: ptrProvenance, confidence: nil)
1945                        MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError)
1946                    }
1947                }
1948
1949                CardView(allowsHorizontalScroll: false) {
1950                    HStack {
1951                        Text("History")
1952                            .font(appDensity.font(.subheadline, weight: .semibold))
1953                            .foregroundStyle(.cyan)
1954                        Spacer()
1955                        if let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder {
1956                            Button("Load") {
1957                                onLoadHistory()
1958                            }
1959                            .buttonStyle(.bordered)
1960                            .font(appDensity.font(.caption2))
1961                        }
1962                    }
1963                    if historyLoading {
1964                        ProgressView("Loading DNS history…")
1965                            .appLoadingStyle()
1966                    } else if !history.isEmpty {
1967                        ForEach(history) { event in
1968                            VStack(alignment: .leading, spacing: 3) {
1969                                Text(event.date.formatted(date: .abbreviated, time: .omitted))
1970                                    .font(appDensity.font(.caption2))
1971                                    .foregroundStyle(.secondary)
1972                                Text(event.summary)
1973                                    .font(appDensity.font(.caption))
1974                                if !event.aRecords.isEmpty {
1975                                    Text("A: \(event.aRecords.joined(separator: ", "))")
1976                                        .font(appDensity.font(.caption2))
1977                                        .foregroundStyle(.secondary)
1978                                }
1979                                if !event.nameservers.isEmpty {
1980                                    Text("NS: \(event.nameservers.joined(separator: ", "))")
1981                                        .font(appDensity.font(.caption2))
1982                                        .foregroundStyle(.secondary)
1983                                }
1984                            }
1985                        }
1986                    } else if let historyError {
1987                        MessageRowView(text: historyError, isError: false)
1988                    } else if showsHistoryPlaceholder {
1989                        MessageRowView(text: "DNS history available in Pro+", isError: false)
1990                    }
1991                }
1992            }
1993        }
1994    }
1995}
1996
1997struct WebSectionView: View {
1998    @Environment(\.appDensity) private var appDensity
1999    @Binding var isCollapsed: Bool
2000    let certificateRows: [InfoRowViewData]
2001    let sslInfo: SSLCertificateInfo?
2002    let tlsSummary: WebResultSummary?
2003    let sslLoading: Bool
2004    let sslError: String?
2005    let tlsProvenance: SectionProvenance?
2006    let responseRows: [InfoRowViewData]
2007    let headers: [HTTPHeader]
2008    let headersLoading: Bool
2009    let headersError: String?
2010    let httpProvenance: SectionProvenance?
2011    let redirects: [RedirectHopViewData]
2012    let redirectLoading: Bool
2013    let redirectError: String?
2014    let redirectProvenance: SectionProvenance?
2015    let finalURL: String?
2016
2017    var body: some View {
2018        CollapsibleSectionView(title: "Web", isCollapsed: $isCollapsed) {
2019            CardView {
2020                HStack {
2021                    Text("TLS")
2022                        .font(appDensity.font(.subheadline, weight: .semibold))
2023                        .foregroundStyle(.cyan)
2024                    Spacer()
2025                    if !sslLoading {
2026                        AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError))
2027                    }
2028                }
2029                SectionTrustMetadataView(provenance: tlsProvenance, confidence: nil)
2030                if !sslLoading, let tlsSummary {
2031                    LabeledValueRow(row: InfoRowViewData(label: "TLS Grade", value: tlsSummary.tlsGrade.rawValue, tone: tlsSummary.tlsGrade == .a ? .success : (tlsSummary.tlsGrade == .f ? .failure : .warning)))
2032                    ForEach(Array(tlsSummary.tlsHighlights.enumerated()), id: \.offset) { _, highlight in
2033                        MessageRowView(text: highlight, isError: isTLSHighlightError(highlight))
2034                    }
2035                }
2036                if sslLoading {
2037                    ProgressView("Checking certificate…")
2038                        .appLoadingStyle()
2039                } else if let sslError {
2040                    MessageRowView(text: sslError, isError: true)
2041                } else {
2042                    ForEach(certificateRows) { row in
2043                        LabeledValueRow(row: row)
2044                    }
2045                    if let sslInfo, !sslInfo.subjectAltNames.isEmpty {
2046                        Text("SANs")
2047                            .font(appDensity.font(.caption2))
2048                            .foregroundStyle(.secondary)
2049                        ForEach(sslInfo.subjectAltNames, id: \.self) { san in
2050                            HStack(alignment: .top, spacing: 8) {
2051                                Text(san)
2052                                    .font(appDensity.font(.caption))
2053                                    .lineLimit(nil)
2054                                    .fixedSize(horizontal: false, vertical: true)
2055                                    .textSelection(.enabled)
2056                                Spacer()
2057                                AppCopyButton(value: san, label: "Copy certificate SAN")
2058                            }
2059                        }
2060                    }
2061                }
2062            }
2063
2064            CardView {
2065                Text("Headers")
2066                    .font(appDensity.font(.subheadline, weight: .semibold))
2067                    .foregroundStyle(.cyan)
2068                SectionTrustMetadataView(provenance: httpProvenance, confidence: nil)
2069                if headersLoading {
2070                    ProgressView("Fetching headers…")
2071                        .appLoadingStyle()
2072                } else if let headersError {
2073                    MessageRowView(text: headersError, isError: true)
2074                } else {
2075                    ForEach(responseRows) { row in
2076                        LabeledValueRow(row: row)
2077                    }
2078                    if headers.isEmpty {
2079                        MessageRowView(text: "No HTTP headers returned", isError: false)
2080                    } else {
2081                        ForEach(headers) { header in
2082                            HStack(alignment: .top, spacing: 4) {
2083                                Text(header.name + ":")
2084                                    .font(appDensity.font(.caption))
2085                                    .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan)
2086                                Text(header.value)
2087                                    .font(appDensity.font(.caption))
2088                                    .foregroundStyle(.primary)
2089                                    .textSelection(.enabled)
2090                            }
2091                        }
2092                    }
2093                }
2094            }
2095
2096            CardView {
2097                HStack {
2098                    Text("Redirects")
2099                        .font(appDensity.font(.subheadline, weight: .semibold))
2100                        .foregroundStyle(.cyan)
2101                    Spacer()
2102                    if let finalURL {
2103                        AppCopyButton(value: finalURL, label: "Copy redirect URL")
2104                    }
2105                }
2106                SectionTrustMetadataView(provenance: redirectProvenance, confidence: nil)
2107                if redirectLoading {
2108                    ProgressView("Tracing redirects…")
2109                        .appLoadingStyle()
2110                } else if let redirectError {
2111                    MessageRowView(text: redirectError, isError: true)
2112                } else if redirects.isEmpty {
2113                    MessageRowView(text: "No redirect data available", isError: false)
2114                } else {
2115                    if let finalURL {
2116                        LabeledValueRow(row: InfoRowViewData(label: "Final URL", value: finalURL, tone: .secondary))
2117                    }
2118                    ForEach(redirects) { redirect in
2119                        HStack(alignment: .top, spacing: 6) {
2120                            Text(redirect.stepLabel)
2121                                .font(appDensity.font(.caption))
2122                                .foregroundStyle(.secondary)
2123                                .frame(width: 16, alignment: .trailing)
2124                            Text(redirect.statusCode)
2125                                .font(appDensity.font(.caption))
2126                                .foregroundStyle(.cyan)
2127                                .frame(width: 36, alignment: .leading)
2128                            Text(redirect.url)
2129                                .font(appDensity.font(.caption))
2130                                .textSelection(.enabled)
2131                            AppCopyButton(value: redirect.url, label: "Copy redirect URL")
2132                            if redirect.isFinal {
2133                                Text("(final)")
2134                                    .font(appDensity.font(.caption2))
2135                                    .foregroundStyle(.secondary)
2136                            }
2137                        }
2138                    }
2139                }
2140            }
2141        }
2142    }
2143
2144    private func isTLSHighlightError(_ highlight: String) -> Bool {
2145        let normalized = highlight.lowercased()
2146        if normalized.contains("no weak tls indicators were detected") {
2147            return false
2148        }
2149        return normalized.contains("expires")
2150            || normalized.contains("weak")
2151            || normalized.contains("tls 1.0")
2152            || normalized.contains("tls 1.1")
2153    }
2154}
2155
2156struct EmailSectionView: View {
2157    @Environment(\.appDensity) private var appDensity
2158    @Binding var isCollapsed: Bool
2159    let rows: [EmailRowViewData]
2160    let assessment: EmailSecuritySummary?
2161    let loading: Bool
2162    let provenance: SectionProvenance?
2163    let confidence: ConfidenceLevel?
2164    let error: String?
2165
2166    var body: some View {
2167        CollapsibleSectionView(title: "Email", isCollapsed: $isCollapsed) {
2168            CardView {
2169                SectionTrustMetadataView(provenance: provenance, confidence: confidence)
2170                HStack {
2171                    Spacer()
2172                    AppStatusBadgeView(model: AppStatusFactory.email(nil, error: error))
2173                        .opacity(loading ? 0 : 1)
2174                }
2175                if let assessment, let grade = assessment.grade {
2176                    LabeledValueRow(row: InfoRowViewData(label: "Grade", value: grade.rawValue, tone: grade == .a ? .success : (grade == .f ? .failure : .warning)))
2177                    if !assessment.reasons.isEmpty {
2178                        Text(assessment.reasons.joined(separator: " | "))
2179                            .font(appDensity.font(.caption2))
2180                            .foregroundStyle(.secondary)
2181                    }
2182                }
2183                if loading {
2184                    ProgressView("Checking email records…")
2185                        .appLoadingStyle()
2186                } else if let error {
2187                    MessageRowView(text: error, isError: true)
2188                } else if rows.isEmpty {
2189                    MessageRowView(text: "No email security records found", isError: false)
2190                } else {
2191                    ForEach(rows) { row in
2192                        VStack(alignment: .leading, spacing: 4) {
2193                            HStack(spacing: 8) {
2194                                Text(row.label)
2195                                    .font(appDensity.font(.caption))
2196                                    .foregroundStyle(.cyan)
2197                                    .frame(width: 76, alignment: .leading)
2198                                AppStatusBadgeView(model: emailRowBadge(row))
2199                            }
2200                            Text(row.detail)
2201                                .font(appDensity.font(.caption2))
2202                                .foregroundStyle(.primary)
2203                                .textSelection(.enabled)
2204                            if let auxiliaryDetail = row.auxiliaryDetail {
2205                                Text(auxiliaryDetail)
2206                                    .font(appDensity.font(.caption2))
2207                                    .foregroundStyle(.secondary)
2208                            }
2209                        }
2210                    }
2211                }
2212            }
2213        }
2214    }
2215
2216    private func emailRowBadge(_ row: EmailRowViewData) -> AppStatusBadgeModel {
2217        switch row.statusTone {
2218        case .success:
2219            return .init(title: row.status, systemImage: "checkmark.shield.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
2220        case .warning:
2221            return .init(title: row.status, systemImage: "shield.lefthalf.filled", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
2222        case .failure:
2223            return .init(title: row.status, systemImage: "minus.circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55))
2224        case .primary, .secondary:
2225            return .init(title: row.status, systemImage: "circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55))
2226        }
2227    }
2228}
2229
2230struct NetworkSectionView: View {
2231    @Environment(\.appDensity) private var appDensity
2232    @Binding var isCollapsed: Bool
2233    let reachabilityRows: [ReachabilityRowViewData]
2234    let reachabilityLoading: Bool
2235    let reachabilityError: String?
2236    let reachabilityProvenance: SectionProvenance?
2237    let locationRows: [InfoRowViewData]
2238    let geolocation: IPGeolocation?
2239    let geolocationLoading: Bool
2240    let geolocationError: String?
2241    let geolocationProvenance: SectionProvenance?
2242    let geolocationConfidence: ConfidenceLevel?
2243    let standardPortRows: [PortScanRowViewData]
2244    let customPortRows: [PortScanRowViewData]
2245    let portScanLoading: Bool
2246    let portScanError: String?
2247    let portScanProvenance: SectionProvenance?
2248    let customPortScanLoading: Bool
2249    let customPortScanError: String?
2250    let isCloudflareProxied: Bool
2251    @Binding var customPortsExpanded: Bool
2252    @Binding var customPortInput: String
2253    let onScanCustomPorts: () -> Void
2254
2255    var body: some View {
2256        CollapsibleSectionView(title: "Network", isCollapsed: $isCollapsed) {
2257            CardView {
2258                Text("Reachability")
2259                    .font(appDensity.font(.subheadline, weight: .semibold))
2260                    .foregroundStyle(.cyan)
2261                SectionTrustMetadataView(provenance: reachabilityProvenance, confidence: nil)
2262                if reachabilityLoading {
2263                    ProgressView("Checking ports…")
2264                        .appLoadingStyle()
2265                } else if let reachabilityError {
2266                    MessageRowView(text: reachabilityError, isError: true)
2267                } else {
2268                    ForEach(reachabilityRows) { row in
2269                        HStack {
2270                            Text(row.portLabel)
2271                                .font(appDensity.font(.caption))
2272                            Spacer()
2273                            Text(row.latencyLabel)
2274                                .font(appDensity.font(.caption2))
2275                                .foregroundStyle(.secondary)
2276                            AppStatusBadgeView(model: reachabilityBadge(row))
2277                        }
2278                    }
2279                }
2280            }
2281
2282            CardView(allowsHorizontalScroll: false) {
2283                Text("Location")
2284                    .font(appDensity.font(.subheadline, weight: .semibold))
2285                    .foregroundStyle(.cyan)
2286                SectionTrustMetadataView(provenance: geolocationProvenance, confidence: geolocationConfidence)
2287                if geolocationLoading {
2288                    ProgressView("Looking up location…")
2289                        .appLoadingStyle()
2290                } else if let geolocationError, geolocation == nil {
2291                    MessageRowView(text: geolocationError, isError: geolocationError != "No A record available")
2292                } else if let geolocation {
2293                    ForEach(locationRows) { row in
2294                        LabeledValueRow(row: row)
2295                    }
2296                    if let latitude = geolocation.latitude, let longitude = geolocation.longitude {
2297                        let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
2298                        Map(initialPosition: .region(MKCoordinateRegion(
2299                            center: coordinate,
2300                            span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
2301                        ))) {
2302                            Marker(geolocation.ip, coordinate: coordinate)
2303                        }
2304                        .mapStyle(.standard)
2305                        .frame(maxWidth: .infinity)
2306                        .frame(height: 180)
2307                        .cornerRadius(8)
2308                    }
2309                } else {
2310                    MessageRowView(text: "No location data available", isError: false)
2311                }
2312            }
2313
2314            CardView(allowsHorizontalScroll: false) {
2315                Text("Port Scan")
2316                    .font(appDensity.font(.subheadline, weight: .semibold))
2317                    .foregroundStyle(.cyan)
2318                SectionTrustMetadataView(provenance: portScanProvenance, confidence: nil)
2319
2320                if isCloudflareProxied {
2321                    Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.")
2322                        .font(appDensity.font(.caption2))
2323                        .foregroundStyle(.orange)
2324                        .fixedSize(horizontal: false, vertical: true)
2325                }
2326
2327                if portScanLoading {
2328                    ProgressView("Scanning ports…")
2329                        .appLoadingStyle()
2330                } else if let portScanError, standardPortRows.isEmpty {
2331                    MessageRowView(text: portScanError, isError: true)
2332                } else {
2333                    Text("Standard Ports")
2334                        .font(.system(.caption, design: .monospaced))
2335                        .foregroundStyle(.secondary)
2336                    PortRowsView(rows: standardPortRows)
2337                }
2338
2339                DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) {
2340                    VStack(alignment: .leading, spacing: 10) {
2341                        TextField("8888, 9000, 27017", text: $customPortInput)
2342                            .font(appDensity.font(.caption))
2343                            .textInputAutocapitalization(.never)
2344                            .autocorrectionDisabled()
2345                            .keyboardType(.numberPad)
2346                            .padding(10)
2347                            .background(Color(.systemGray6).opacity(0.5))
2348                            .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
2349
2350                        Button("Scan") {
2351                            AppHaptics.refresh()
2352                            onScanCustomPorts()
2353                        }
2354                        .buttonStyle(.borderedProminent)
2355                        .disabled(customPortScanLoading)
2356
2357                        if customPortScanLoading {
2358                            ProgressView("Scanning custom ports…")
2359                                .appLoadingStyle()
2360                        } else if let customPortScanError {
2361                            MessageRowView(text: customPortScanError, isError: true)
2362                        } else {
2363                            PortRowsView(rows: customPortRows)
2364                        }
2365                    }
2366                    .padding(.top, 8)
2367                }
2368                .font(.system(.caption, design: .monospaced))
2369                .tint(.secondary)
2370            }
2371        }
2372    }
2373
2374    private func reachabilityBadge(_ row: ReachabilityRowViewData) -> AppStatusBadgeModel {
2375        switch row.statusTone {
2376        case .success:
2377            return .init(title: row.statusLabel, systemImage: "checkmark.circle.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
2378        case .warning:
2379            return .init(title: row.statusLabel, systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
2380        case .failure:
2381            return .init(title: row.statusLabel, systemImage: "xmark.circle.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
2382        case .primary, .secondary:
2383            return .init(title: row.statusLabel, systemImage: "circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55))
2384        }
2385    }
2386}
2387
2388struct PortRowsView: View {
2389    @Environment(\.appDensity) private var appDensity
2390    let rows: [PortScanRowViewData]
2391
2392    var body: some View {
2393        if rows.isEmpty {
2394            MessageRowView(text: "No results", isError: false)
2395        } else {
2396            ForEach(rows) { row in
2397                VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) {
2398                    HStack {
2399                        Text(row.portLabel)
2400                            .font(appDensity.font(.caption))
2401                            .frame(width: 52, alignment: .leading)
2402                        Text(row.service)
2403                            .font(appDensity.font(.caption))
2404                            .foregroundStyle(.primary)
2405                        Spacer()
2406                        if let durationLabel = row.durationLabel {
2407                            Text(durationLabel)
2408                                .font(appDensity.font(.caption2))
2409                                .foregroundStyle(.secondary)
2410                        }
2411                        AppStatusBadgeView(model: portBadge(row))
2412                    }
2413                    if let banner = row.banner {
2414                        Text(banner)
2415                            .font(appDensity.font(.caption2))
2416                            .foregroundStyle(.secondary)
2417                            .padding(.leading, 8)
2418                    }
2419                }
2420                .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading)
2421            }
2422        }
2423    }
2424
2425    private func portBadge(_ row: PortScanRowViewData) -> AppStatusBadgeModel {
2426        switch row.statusTone {
2427        case .success:
2428            return .init(title: row.statusLabel, systemImage: "checkmark.circle.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
2429        case .warning:
2430            return .init(title: row.statusLabel, systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
2431        case .failure:
2432            return .init(title: row.statusLabel, systemImage: "xmark.circle.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
2433        case .primary, .secondary:
2434            return .init(title: row.statusLabel, systemImage: "circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55))
2435        }
2436    }
2437}
2438
2439struct SectionTitleView: View {
2440    @Environment(\.appDensity) private var appDensity
2441    let title: String
2442
2443    var body: some View {
2444        Text(title)
2445            .font(appDensity.font(.headline, design: .default, weight: .semibold))
2446            .foregroundStyle(.white)
2447    }
2448}
2449
2450struct CardView<Content: View>: View {
2451    @Environment(\.appDensity) private var appDensity
2452    let allowsHorizontalScroll: Bool
2453    let content: Content
2454
2455    init(allowsHorizontalScroll: Bool = true, @ViewBuilder content: () -> Content) {
2456        self.allowsHorizontalScroll = allowsHorizontalScroll
2457        self.content = content()
2458    }
2459
2460    var body: some View {
2461        Group {
2462            if allowsHorizontalScroll {
2463                ScrollView(.horizontal) {
2464                    cardContent
2465                        .scrollTargetLayout()
2466                }
2467                .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
2468            } else {
2469                cardContent
2470                    .frame(maxWidth: .infinity, alignment: .leading)
2471            }
2472        }
2473        .frame(maxWidth: .infinity, alignment: .leading)
2474        .padding(appDensity.metrics.cardPadding)
2475        .background(Color(.systemGray6).opacity(0.5))
2476        .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
2477    }
2478
2479    private var cardContent: some View {
2480        VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
2481            content
2482        }
2483    }
2484}
2485
2486struct LoadingCardView: View {
2487    let text: String
2488
2489    var body: some View {
2490        CardView {
2491            ProgressView(text)
2492                .appLoadingStyle()
2493                .frame(maxWidth: .infinity, alignment: .center)
2494        }
2495    }
2496}
2497
2498struct MessageCardView: View {
2499    let text: String
2500    let isError: Bool
2501
2502    var body: some View {
2503        CardView {
2504            MessageRowView(text: text, isError: isError)
2505        }
2506    }
2507}
2508
2509struct MessageRowView: View {
2510    @Environment(\.appDensity) private var appDensity
2511    let text: String
2512    let isError: Bool
2513
2514    var body: some View {
2515        Label(text, systemImage: isError ? "exclamationmark.triangle.fill" : "info.circle")
2516            .font(appDensity.font(.caption))
2517            .foregroundStyle(isError ? .red : .secondary)
2518            .lineLimit(nil)
2519            .fixedSize(horizontal: false, vertical: true)
2520    }
2521}
2522
2523struct SectionTrustMetadataView: View {
2524    @Environment(\.appDensity) private var appDensity
2525    let provenance: SectionProvenance?
2526    let confidence: ConfidenceLevel?
2527    let note: String?
2528
2529    init(provenance: SectionProvenance?, confidence: ConfidenceLevel?, note: String? = nil) {
2530        self.provenance = provenance
2531        self.confidence = confidence
2532        self.note = note
2533    }
2534
2535    var body: some View {
2536        if provenance != nil || confidence != nil || note != nil {
2537            VStack(alignment: .leading, spacing: 6) {
2538                HStack(spacing: 8) {
2539                    if let confidence {
2540                        Text("Confidence \(confidence.title)")
2541                            .font(appDensity.font(.caption2))
2542                            .foregroundStyle(.secondary)
2543                    }
2544                    if let provenance {
2545                        Text(provenance.provider ?? provenance.source)
2546                            .font(appDensity.font(.caption2))
2547                            .foregroundStyle(.secondary)
2548                        Text(provenance.resultSource.label)
2549                            .font(appDensity.font(.caption2))
2550                            .foregroundStyle(.secondary)
2551                    }
2552                }
2553                DisclosureGroup("Details") {
2554                    VStack(alignment: .leading, spacing: 4) {
2555                        if let provenance {
2556                            LabeledValueRow(row: .init(label: "Method", value: provenance.source, tone: .secondary))
2557                            if let provider = provenance.provider {
2558                                LabeledValueRow(row: .init(label: "Provider", value: provider, tone: .secondary))
2559                            }
2560                            if let resolver = provenance.resolver {
2561                                LabeledValueRow(row: .init(label: "Resolver", value: resolver, tone: .secondary))
2562                            }
2563                            LabeledValueRow(row: .init(label: "Collected", value: provenance.collectedAt.formatted(date: .abbreviated, time: .shortened), tone: .secondary))
2564                            LabeledValueRow(row: .init(label: "Mode", value: provenance.resultSource.label, tone: .secondary))
2565                        }
2566                        if let note {
2567                            LabeledValueRow(row: .init(label: "Note", value: note, tone: .secondary))
2568                        }
2569                    }
2570                    .padding(.top, 4)
2571                }
2572                .font(appDensity.font(.caption))
2573                .tint(.secondary)
2574            }
2575        }
2576    }
2577}
2578
2579struct LabeledValueRow: View {
2580    @Environment(\.appDensity) private var appDensity
2581    let row: InfoRowViewData
2582
2583    var body: some View {
2584        VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) {
2585            HStack(alignment: .top, spacing: 8) {
2586                VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) {
2587                    Text(row.label)
2588                        .font(appDensity.font(.caption2))
2589                        .foregroundStyle(.secondary)
2590                    Text(row.value)
2591                        .font(appDensity.font(.caption))
2592                        .foregroundStyle(ResultColors.color(for: row.tone))
2593                        .lineLimit(nil)
2594                        .fixedSize(horizontal: false, vertical: true)
2595                        .textSelection(.enabled)
2596                }
2597                .frame(maxWidth: .infinity, alignment: .leading)
2598                .layoutPriority(1)
2599                Spacer(minLength: 6)
2600                if !row.value.isEmpty, row.value != "Unavailable" {
2601                    AppCopyButton(value: row.value, label: "Copy \(row.label)")
2602                }
2603            }
2604        }
2605        .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading)
2606    }
2607}
2608
2609enum ResultColors {
2610    static func color(for tone: ResultTone) -> Color {
2611        switch tone {
2612        case .primary:
2613            return .primary
2614        case .secondary:
2615            return .secondary
2616        case .success:
2617            return .green
2618        case .warning:
2619            return .yellow
2620        case .failure:
2621            return .red
2622        }
2623    }
2624}
2625
2626extension DateFormatter {
2627    static let certDate: DateFormatter = {
2628        let formatter = DateFormatter()
2629        formatter.dateStyle = .medium
2630        formatter.timeStyle = .short
2631        return formatter
2632    }()
2633}
2634
2635private extension View {
2636    func appLoadingStyle() -> some View {
2637        font(.system(.caption, design: .monospaced))
2638    }
2639}
2640
2641private extension String {
2642    var nilIfEmpty: String? {
2643        isEmpty ? nil : self
2644    }
2645}
2646
2647struct SettingsView: View {
2648    @Environment(\.appDensity) private var appDensity
2649    @Bindable var viewModel: DomainViewModel
2650    @State private var purchaseService = PurchaseService.shared
2651
2652    var body: some View {
2653        let _ = purchaseService.currentTier
2654
2655        List {
2656            Section("Tier") {
2657                LabeledContent("Status", value: purchaseService.currentTier.title)
2658
2659                if purchaseService.currentTier == .free {
2660                    Button("Upgrade") {
2661                        viewModel.isPaywallPresented = true
2662                    }
2663                } else {
2664                    Button("Manage Subscription") {
2665                        Task {
2666                            await purchaseService.manageSubscription()
2667                        }
2668                    }
2669                }
2670
2671                Button(purchaseService.isRestoring ? "Restoring…" : "Restore Purchases") {
2672                    Task {
2673                        await purchaseService.restorePurchases()
2674                    }
2675                }
2676                .disabled(purchaseService.isRestoring || purchaseService.isPurchasing)
2677
2678                if let statusMessage = purchaseService.statusMessage {
2679                    Text(statusMessage)
2680                        .font(appDensity.font(.caption, design: .default))
2681                        .foregroundStyle(.secondary)
2682                }
2683
2684                if let errorMessage = purchaseService.errorMessage {
2685                    Text(errorMessage)
2686                        .font(appDensity.font(.caption, design: .default))
2687                        .foregroundStyle(.red)
2688                }
2689            }
2690
2691            Section("Preferences") {
2692                NavigationLink("Tracked Domains") {
2693                    WatchlistView(viewModel: viewModel)
2694                }
2695
2696                NavigationLink("Workflows") {
2697                    WorkflowsView(viewModel: viewModel)
2698                }
2699
2700                NavigationLink("Display") {
2701                    DisplaySettingsView()
2702                }
2703
2704                NavigationLink("History & Network") {
2705                    HistoryNetworkSettingsView(viewModel: viewModel)
2706                }
2707            }
2708
2709            Section("Services") {
2710                NavigationLink("Monitoring Activity") {
2711                    MonitoringView(viewModel: viewModel)
2712                }
2713
2714                NavigationLink("Integrations") {
2715                    IntegrationsSettingsView()
2716                }
2717
2718                NavigationLink("Local API") {
2719                    LocalAPISettingsView()
2720                }
2721
2722                NavigationLink("iCloud Sync") {
2723                    CloudSyncSettingsView()
2724                }
2725
2726                NavigationLink("Monitoring") {
2727                    MonitoringSettingsView(viewModel: viewModel)
2728                }
2729            }
2730
2731            Section("Data") {
2732                NavigationLink("Import & Export") {
2733                    DataPortabilitySettingsView(viewModel: viewModel)
2734                }
2735
2736                NavigationLink("Data Management") {
2737                    DataManagementSettingsView(viewModel: viewModel)
2738                }
2739            }
2740
2741            Section("About") {
2742                NavigationLink("App Info") {
2743                    AboutSettingsView()
2744                }
2745            }
2746        }
2747        .navigationTitle("Settings")
2748    }
2749}
2750
2751private struct DisplaySettingsView: View {
2752    @AppStorage(AppDensity.userDefaultsKey) private var storedDensity = AppDensity.compact.rawValue
2753
2754    var body: some View {
2755        Form {
2756            Section("Display") {
2757                Picker("Density", selection: $storedDensity) {
2758                    ForEach(AppDensity.allCases) { density in
2759                        Text(density.title).tag(density.rawValue)
2760                    }
2761                }
2762            }
2763        }
2764        .navigationTitle("Display")
2765    }
2766}
2767
2768private struct HistoryNetworkSettingsView: View {
2769    @Environment(\.appDensity) private var appDensity
2770    @Bindable var viewModel: DomainViewModel
2771    @AppStorage(DNSResolverOption.userDefaultsKey) private var storedResolverURL = DNSResolverOption.defaultURLString
2772    @AppStorage(AppDensity.userDefaultsKey) private var storedDensity = AppDensity.compact.rawValue
2773
2774    @State private var resolverOption: DNSResolverOption = .cloudflare
2775    @State private var customResolverURL = DNSResolverOption.defaultURLString
2776
2777    private var customResolverError: String? {
2778        guard resolverOption == .custom else { return nil }
2779        return DNSResolverOption.isValidCustomURL(customResolverURL) ? nil : "Resolver URL must start with https://"
2780    }
2781
2782    var body: some View {
2783        Form {
2784            Section("History") {
2785                Picker(
2786                    "Auto-prune",
2787                    selection: Binding(
2788                        get: { viewModel.historyAutoPruneOption },
2789                        set: { viewModel.setHistoryAutoPruneOption($0) }
2790                    )
2791                ) {
2792                    ForEach(HistoryAutoPruneOption.allCases) { option in
2793                        Text(option.title).tag(option)
2794                    }
2795                }
2796
2797                Text("History remains local-first. Auto-prune only trims older local snapshots on this device and defaults to unlimited.")
2798                    .font(appDensity.font(.caption, design: .default))
2799                    .foregroundStyle(.secondary)
2800            }
2801
2802            Section("Network") {
2803                Picker("Resolver", selection: $resolverOption) {
2804                    ForEach(DNSResolverOption.allCases) { option in
2805                        Text(option.title).tag(option)
2806                    }
2807                }
2808
2809                if resolverOption == .custom {
2810                    TextField("https://resolver.example/dns-query", text: $customResolverURL)
2811                        .textInputAutocapitalization(.never)
2812                        .autocorrectionDisabled()
2813                        .keyboardType(.URL)
2814
2815                    if let customResolverError {
2816                        Text(customResolverError)
2817                            .font(appDensity.font(.caption, design: .default))
2818                            .foregroundStyle(.red)
2819                    }
2820                }
2821            }
2822        }
2823        .navigationTitle("History & Network")
2824        .onAppear {
2825            let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
2826            resolverOption = DNSResolverOption.option(for: currentResolverURL)
2827            customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString
2828        }
2829        .onChange(of: resolverOption) { _, newValue in
2830            guard let presetURL = newValue.urlString else {
2831                storedResolverURL = customResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
2832                viewModel.persistCurrentAppSettings(
2833                    resolverURLString: storedResolverURL,
2834                    appDensityRawValue: storedDensity
2835                )
2836                return
2837            }
2838            storedResolverURL = presetURL
2839            viewModel.persistCurrentAppSettings(
2840                resolverURLString: storedResolverURL,
2841                appDensityRawValue: storedDensity
2842            )
2843        }
2844        .onChange(of: customResolverURL) { _, newValue in
2845            guard resolverOption == .custom else { return }
2846            storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
2847            viewModel.persistCurrentAppSettings(
2848                resolverURLString: storedResolverURL,
2849                appDensityRawValue: storedDensity
2850            )
2851        }
2852        .onChange(of: storedDensity) { _, newValue in
2853            viewModel.persistCurrentAppSettings(
2854                resolverURLString: storedResolverURL,
2855                appDensityRawValue: newValue
2856            )
2857        }
2858    }
2859}
2860
2861private struct CloudSyncSettingsView: View {
2862    @Environment(\.appDensity) private var appDensity
2863    @State private var cloudSyncService = CloudSyncService.shared
2864
2865    var body: some View {
2866        Form {
2867            Section("iCloud Sync") {
2868                Toggle(
2869                    "Enable iCloud Sync",
2870                    isOn: Binding(
2871                        get: { cloudSyncService.isEnabled },
2872                        set: { cloudSyncService.setSyncEnabled($0) }
2873                    )
2874                )
2875
2876                LabeledContent("Status", value: cloudSyncService.status.title)
2877                LabeledContent(
2878                    "Last Sync",
2879                    value: cloudSyncService.lastSyncDate?.formatted(date: .abbreviated, time: .shortened) ?? "Not yet synced"
2880                )
2881
2882                Button(cloudSyncService.status == .syncing ? "Syncing…" : "Sync Now") {
2883                    Task {
2884                        await cloudSyncService.syncNow(trigger: .manual)
2885                    }
2886                }
2887                .disabled(!cloudSyncService.isEnabled || cloudSyncService.status == .syncing)
2888
2889                Text("iCloud Sync stores DomainDig data in your private iCloud account. DomainDig does not operate a sync server. Disabling sync keeps local data on this device.")
2890                    .font(appDensity.font(.caption, design: .default))
2891                    .foregroundStyle(.secondary)
2892
2893                Text(cloudSyncService.detailMessage)
2894                    .font(appDensity.font(.caption, design: .default))
2895                    .foregroundStyle(.secondary)
2896
2897                if let lastErrorMessage = cloudSyncService.lastErrorMessage {
2898                    Text(lastErrorMessage)
2899                        .font(appDensity.font(.caption, design: .default))
2900                        .foregroundStyle(.red)
2901                }
2902            }
2903        }
2904        .navigationTitle("iCloud Sync")
2905        .task {
2906            await cloudSyncService.refreshAvailability()
2907        }
2908    }
2909}
2910
2911private struct LocalAPISettingsView: View {
2912    @Environment(\.appDensity) private var appDensity
2913    @State private var localAPIService = LocalAPIService.shared
2914    @State private var portText = ""
2915
2916    var body: some View {
2917        Form {
2918            Section("Local API") {
2919                Toggle(
2920                    "Enable Local API",
2921                    isOn: Binding(
2922                        get: { localAPIService.config.isEnabled },
2923                        set: { localAPIService.setEnabled($0) }
2924                    )
2925                )
2926
2927                TextField(
2928                    "Port",
2929                    text: Binding(
2930                        get: { portText },
2931                        set: { newValue in
2932                            portText = newValue
2933                            if let port = Int(newValue) {
2934                                localAPIService.setPort(port)
2935                            }
2936                        }
2937                    )
2938                )
2939                .keyboardType(.numberPad)
2940
2941                LabeledContent("Address", value: localAPIService.address)
2942                LabeledContent("Status", value: localAPIService.isRunning ? "Running" : (localAPIService.config.isEnabled ? "Stopped" : "Disabled"))
2943                LabeledContent("Token", value: localAPIService.maskedToken)
2944
2945                if let statusMessage = localAPIService.statusMessage {
2946                    Text(statusMessage)
2947                        .font(appDensity.font(.caption, design: .default))
2948                        .foregroundStyle(.secondary)
2949                }
2950            }
2951
2952            Section("Authentication") {
2953                Button("Copy Token") {
2954                    localAPIService.copyToken()
2955                }
2956
2957                Button("Rotate Token") {
2958                    localAPIService.rotateToken()
2959                }
2960
2961                Text("Every request requires either `Authorization: Bearer <token>` or `X-API-Token`. DomainDig stores the token in Keychain and only binds the server to localhost.")
2962                    .font(appDensity.font(.caption, design: .default))
2963                    .foregroundStyle(.secondary)
2964            }
2965
2966            Section("Request Logging") {
2967                Toggle(
2968                    "Log Requests",
2969                    isOn: Binding(
2970                        get: { localAPIService.config.requestLoggingEnabled },
2971                        set: { localAPIService.setRequestLoggingEnabled($0) }
2972                    )
2973                )
2974
2975                if localAPIService.requestLogs.isEmpty {
2976                    Text("No local API requests logged yet.")
2977                        .font(appDensity.font(.caption, design: .default))
2978                        .foregroundStyle(.secondary)
2979                } else {
2980                    ForEach(localAPIService.requestLogs.prefix(25)) { log in
2981                        VStack(alignment: .leading, spacing: 4) {
2982                            HStack {
2983                                Text("\(log.method) \(log.path)")
2984                                    .font(appDensity.font(.callout, design: .monospaced))
2985                                Spacer()
2986                                Text("\(log.statusCode)")
2987                                    .font(appDensity.font(.caption, design: .default))
2988                                    .foregroundStyle(log.statusCode >= 400 ? .red : .secondary)
2989                            }
2990
2991                            Text(log.timestamp.formatted(date: .abbreviated, time: .standard))
2992                                .font(appDensity.font(.caption2, design: .default))
2993                                .foregroundStyle(.secondary)
2994
2995                            Text("\(Int(log.duration * 1000)) ms")
2996                                .font(appDensity.font(.caption2, design: .default))
2997                                .foregroundStyle(.secondary)
2998                        }
2999                    }
3000                }
3001
3002                Button("Clear Logs", role: .destructive) {
3003                    localAPIService.clearRequestLogs()
3004                }
3005            }
3006
3007            Section("Control") {
3008                Button("Restart Server") {
3009                    localAPIService.setEnabled(false)
3010                    localAPIService.setEnabled(true)
3011                }
3012                .disabled(!localAPIService.config.isEnabled)
3013
3014                Button("Stop Server") {
3015                    localAPIService.stopServer()
3016                }
3017                .disabled(!localAPIService.isRunning)
3018            }
3019        }
3020        .navigationTitle("Local API")
3021        .onAppear {
3022            portText = String(localAPIService.config.port)
3023            localAPIService.refresh()
3024        }
3025    }
3026}
3027
3028private struct MonitoringSettingsView: View {
3029    @Environment(\.appDensity) private var appDensity
3030    @Bindable var viewModel: DomainViewModel
3031
3032    private var notificationAuthorizationLabel: String {
3033        switch viewModel.monitoringNotificationStatus {
3034        case .authorized, .provisional, .ephemeral:
3035            return "Allowed"
3036        case .denied:
3037            return "Denied"
3038        case .notDetermined:
3039            return "Not Requested"
3040        @unknown default:
3041            return "Unknown"
3042        }
3043    }
3044
3045    var body: some View {
3046        Form {
3047            Section("Monitoring") {
3048                Toggle(
3049                    "Enable Background Monitoring",
3050                    isOn: Binding(
3051                        get: { viewModel.monitoringSettings.isEnabled },
3052                        set: { viewModel.setMonitoringEnabled($0) }
3053                    )
3054                )
3055
3056                Picker(
3057                    "Base Interval",
3058                    selection: Binding(
3059                        get: { MonitoringBaseInterval.nearest(to: viewModel.monitoringSettings.baseInterval) },
3060                        set: { viewModel.setMonitoringBaseInterval($0) }
3061                    )
3062                ) {
3063                    ForEach(MonitoringBaseInterval.allCases) { interval in
3064                        Text(interval.title).tag(interval)
3065                    }
3066                }
3067
3068                Toggle(
3069                    "Adaptive Monitoring",
3070                    isOn: Binding(
3071                        get: { viewModel.monitoringSettings.adaptiveEnabled },
3072                        set: { viewModel.setMonitoringAdaptiveEnabled($0) }
3073                    )
3074                )
3075
3076                Picker(
3077                    "Sensitivity",
3078                    selection: Binding(
3079                        get: { viewModel.monitoringSettings.sensitivity },
3080                        set: { viewModel.setMonitoringSensitivity($0) }
3081                    )
3082                ) {
3083                    ForEach(MonitoringSensitivity.allCases) { sensitivity in
3084                        Text(sensitivity.title).tag(sensitivity)
3085                    }
3086                }
3087
3088                let quietHoursStart = viewModel.monitoringSettings.quietHours?.startHour ?? 22
3089                let quietHoursEnd = viewModel.monitoringSettings.quietHours?.endHour ?? 7
3090                Toggle(
3091                    "Quiet Hours",
3092                    isOn: Binding(
3093                        get: { viewModel.monitoringSettings.quietHours != nil },
3094                        set: { isEnabled in
3095                            viewModel.setMonitoringQuietHours(
3096                                startHour: quietHoursStart,
3097                                endHour: quietHoursEnd,
3098                                isEnabled: isEnabled
3099                            )
3100                        }
3101                    )
3102                )
3103
3104                if viewModel.monitoringSettings.quietHours != nil {
3105                    Picker(
3106                        "Quiet Starts",
3107                        selection: Binding(
3108                            get: { quietHoursStart },
3109                            set: { startHour in
3110                                viewModel.setMonitoringQuietHours(
3111                                    startHour: startHour,
3112                                    endHour: quietHoursEnd,
3113                                    isEnabled: true
3114                                )
3115                            }
3116                        )
3117                    ) {
3118                        ForEach(0..<24, id: \.self) { hour in
3119                            Text(Self.monitoringHourLabel(for: hour)).tag(hour)
3120                        }
3121                    }
3122
3123                    Picker(
3124                        "Quiet Ends",
3125                        selection: Binding(
3126                            get: { quietHoursEnd },
3127                            set: { endHour in
3128                                viewModel.setMonitoringQuietHours(
3129                                    startHour: quietHoursStart,
3130                                    endHour: endHour,
3131                                    isEnabled: true
3132                                )
3133                            }
3134                        )
3135                    ) {
3136                        ForEach(0..<24, id: \.self) { hour in
3137                            Text(Self.monitoringHourLabel(for: hour)).tag(hour)
3138                        }
3139                    }
3140                }
3141
3142                Picker(
3143                    "Domains",
3144                    selection: Binding(
3145                        get: { viewModel.monitoringSettings.scope },
3146                        set: { viewModel.setMonitoringScope($0) }
3147                    )
3148                ) {
3149                    ForEach(MonitoringScope.allCases) { scope in
3150                        Text(scope.title).tag(scope)
3151                    }
3152                }
3153
3154                if viewModel.monitoringSettings.scope == .selectedOnly {
3155                    ForEach(viewModel.trackedDomains) { trackedDomain in
3156                        Toggle(
3157                            trackedDomain.domain,
3158                            isOn: Binding(
3159                                get: { viewModel.monitoringSettings.selectedDomainIDs.contains(trackedDomain.id) },
3160                                set: { viewModel.setMonitoringSelection(for: trackedDomain, isSelected: $0) }
3161                            )
3162                        )
3163                    }
3164                }
3165
3166                Toggle(
3167                    "Local Alerts",
3168                    isOn: Binding(
3169                        get: { viewModel.monitoringSettings.alertsEnabled },
3170                        set: { isEnabled in
3171                            if isEnabled {
3172                                Task {
3173                                    await viewModel.requestMonitoringNotificationAuthorization()
3174                                }
3175                            } else {
3176                                viewModel.setMonitoringAlertsEnabled(false)
3177                            }
3178                        }
3179                    )
3180                )
3181
3182                Picker(
3183                    "Notify For",
3184                    selection: Binding(
3185                        get: { viewModel.monitoringSettings.alertFilter },
3186                        set: { viewModel.setMonitoringAlertFilter($0) }
3187                    )
3188                ) {
3189                    ForEach(MonitoringAlertFilter.allCases) { filter in
3190                        Text(filter.title).tag(filter)
3191                    }
3192                }
3193
3194                LabeledContent("Background Refresh", value: DomainMonitoringScheduler.shared.backgroundRefreshStatusDescription())
3195                LabeledContent("Notification Access", value: notificationAuthorizationLabel)
3196
3197                if let monitoringStatusMessage = viewModel.monitoringStatusMessage {
3198                    Text(monitoringStatusMessage)
3199                        .font(appDensity.font(.caption, design: .default))
3200                        .foregroundStyle(.secondary)
3201                }
3202
3203                if !FeatureAccessService.hasAccess(to: .automatedMonitoring) {
3204                    Text("Background monitoring and alerts are available in Pro.")
3205                        .font(appDensity.font(.caption, design: .default))
3206                        .foregroundStyle(.secondary)
3207                }
3208            }
3209        }
3210        .navigationTitle("Monitoring")
3211        .onAppear {
3212            viewModel.refreshMonitoringState()
3213            Task {
3214                await viewModel.refreshMonitoringAuthorizationStatus()
3215            }
3216        }
3217    }
3218
3219    private static func monitoringHourLabel(for hour: Int) -> String {
3220        let formatter = DateFormatter()
3221        formatter.dateFormat = "h a"
3222        let components = DateComponents(calendar: .current, hour: hour)
3223        return components.date.map(formatter.string(from:)) ?? "\(hour):00"
3224    }
3225}
3226
3227private struct DataPortabilitySettingsView: View {
3228    private enum ImportTarget {
3229        case backup
3230        case trackedDomains
3231        case workflows
3232
3233        var expectedKind: DataPortabilityImportKind {
3234            switch self {
3235            case .backup:
3236                return .backup
3237            case .trackedDomains:
3238                return .trackedDomains
3239            case .workflows:
3240                return .workflows
3241            }
3242        }
3243
3244        var allowedContentTypes: [UTType] {
3245            switch self {
3246            case .backup:
3247                return [UTType.json]
3248            case .trackedDomains, .workflows:
3249                return [UTType.json, UTType.commaSeparatedText]
3250            }
3251        }
3252    }
3253
3254    @Environment(\.appDensity) private var appDensity
3255    @Bindable var viewModel: DomainViewModel
3256
3257    @State private var importMode: DataPortabilityImportMode = .merge
3258    @State private var activeImportTarget: ImportTarget?
3259    @State private var pendingImportTarget: ImportTarget?
3260    @State private var importDebugStatus: String?
3261    @State private var pendingImportPreview: DataImportPreview?
3262    @State private var pendingImportError: String?
3263    @State private var showReplaceImportConfirmation = false
3264
3265    var body: some View {
3266        Form {
3267            Section("Import & Export") {
3268                Picker("Import Mode", selection: $importMode) {
3269                    ForEach(DataPortabilityImportMode.allCases) { mode in
3270                        Text(mode.title).tag(mode)
3271                    }
3272                }
3273
3274                Text(importMode.explanation)
3275                    .font(appDensity.font(.caption, design: .default))
3276                    .foregroundStyle(.secondary)
3277
3278                Button("Export Full Backup") {
3279                    exportFullBackup()
3280                }
3281
3282                Button("Import Backup") {
3283                    recordImportDebugStatus("Tapped Import Backup")
3284                    pendingImportTarget = .backup
3285                    activeImportTarget = .backup
3286                }
3287
3288                Menu("Export Tracked Domains") {
3289                    Button("JSON") {
3290                        exportPortableTrackedDomainsJSON()
3291                    }
3292                    Button("CSV") {
3293                        exportPortableTrackedDomainsCSV()
3294                    }
3295                }
3296
3297                Button("Import Tracked Domains") {
3298                    recordImportDebugStatus("Tapped Import Tracked Domains")
3299                    pendingImportTarget = .trackedDomains
3300                    activeImportTarget = .trackedDomains
3301                }
3302
3303                Menu("Export Workflows") {
3304                    Button("JSON") {
3305                        exportPortableWorkflowsJSON()
3306                    }
3307                    Button("CSV") {
3308                        exportPortableWorkflowsCSV()
3309                    }
3310                }
3311
3312                Button("Import Workflows") {
3313                    recordImportDebugStatus("Tapped Import Workflows")
3314                    pendingImportTarget = .workflows
3315                    activeImportTarget = .workflows
3316                }
3317
3318                Button("Export History") {
3319                    exportPortableHistoryJSON()
3320                }
3321            }
3322
3323            Section("Local Data") {
3324                LabeledContent("Tracked Domains", value: "\(viewModel.dataLifecycleSummary.trackedDomains)")
3325                LabeledContent("History Snapshots", value: "\(viewModel.dataLifecycleSummary.historySnapshots)")
3326                LabeledContent("Audit Sessions", value: "\(viewModel.dataLifecycleSummary.auditSessions)")
3327                LabeledContent("Workflows", value: "\(viewModel.dataLifecycleSummary.workflows)")
3328                LabeledContent("Cached Items", value: "\(viewModel.dataLifecycleSummary.cachedItems)")
3329                LabeledContent("Monitoring Logs", value: "\(viewModel.dataLifecycleSummary.monitoringLogs)")
3330
3331                Text("Data stays on this device unless you export it. Backup files can include domain history, monitoring settings, and notes. Imported files are processed on-device.")
3332                    .font(appDensity.font(.caption, design: .default))
3333                    .foregroundStyle(.secondary)
3334
3335                if let portabilityStatusMessage = viewModel.portabilityStatusMessage {
3336                    Text(portabilityStatusMessage)
3337                        .font(appDensity.font(.caption, design: .default))
3338                        .foregroundStyle(.secondary)
3339                }
3340            }
3341
3342            #if DEBUG
3343            if let importDebugStatus {
3344                Section("Import Debug") {
3345                    Text(importDebugStatus)
3346                        .font(appDensity.font(.caption, design: .default))
3347                        .foregroundStyle(.secondary)
3348                        .textSelection(.enabled)
3349                }
3350            }
3351            #endif
3352        }
3353        .navigationTitle("Import & Export")
3354        .alert("Replace local data?", isPresented: $showReplaceImportConfirmation) {
3355            Button("Replace", role: .destructive) {
3356                applyPendingImport()
3357            }
3358            Button("Cancel", role: .cancel) {}
3359        } message: {
3360            Text("Replace mode overwrites local data covered by the imported file and may remove items that are only on this device.")
3361        }
3362        .alert("Import Error", isPresented: Binding(
3363            get: { pendingImportError != nil },
3364            set: { if !$0 { pendingImportError = nil } }
3365        )) {
3366            Button("OK", role: .cancel) {}
3367        } message: {
3368            Text(pendingImportError ?? "The import could not be completed.")
3369        }
3370        .sheet(isPresented: Binding(
3371            get: { pendingImportPreview != nil },
3372            set: { if !$0 { pendingImportPreview = nil } }
3373        )) {
3374            if let pendingImportPreview {
3375                DataImportPreviewSheet(
3376                    preview: pendingImportPreview,
3377                    mode: importMode,
3378                    onCancel: {
3379                        self.pendingImportPreview = nil
3380                    },
3381                    onApply: {
3382                        if importMode == .replace {
3383                            showReplaceImportConfirmation = true
3384                        } else {
3385                            applyPendingImport()
3386                        }
3387                    }
3388                )
3389            }
3390        }
3391        .fileImporter(
3392            isPresented: Binding(
3393                get: { activeImportTarget != nil },
3394                set: { if !$0 { activeImportTarget = nil } }
3395            ),
3396            allowedContentTypes: activeImportTarget?.allowedContentTypes ?? [UTType.json],
3397            allowsMultipleSelection: false
3398        ) { result in
3399            guard let pendingImportTarget else {
3400                recordImportDebugStatus("fileImporter returned with no active target")
3401                return
3402            }
3403            recordImportDebugStatus("fileImporter returned for \(pendingImportTarget.expectedKind.rawValue)")
3404            handleImportResult(result, expectedKind: pendingImportTarget.expectedKind)
3405            self.pendingImportTarget = nil
3406            self.activeImportTarget = nil
3407        }
3408        .onAppear {
3409            viewModel.refreshDataLifecycleSummary()
3410        }
3411    }
3412
3413    private func exportFullBackup() {
3414        guard let data = viewModel.exportFullBackupData() else { return }
3415        ExportPresenter.share(filename: portabilityFilename(suffix: "backup", fileExtension: "json"), data: data)
3416    }
3417
3418    private func exportPortableTrackedDomainsJSON() {
3419        guard let data = viewModel.exportPortableTrackedDomainsJSONData() else { return }
3420        ExportPresenter.share(filename: portabilityFilename(suffix: "tracked_domains", fileExtension: "json"), data: data)
3421    }
3422
3423    private func exportPortableTrackedDomainsCSV() {
3424        ExportPresenter.share(
3425            filename: portabilityFilename(suffix: "tracked_domains", fileExtension: "csv"),
3426            contents: viewModel.exportPortableTrackedDomainsCSV()
3427        )
3428    }
3429
3430    private func exportPortableWorkflowsJSON() {
3431        guard let data = viewModel.exportPortableWorkflowsJSONData() else { return }
3432        ExportPresenter.share(filename: portabilityFilename(suffix: "workflows", fileExtension: "json"), data: data)
3433    }
3434
3435    private func exportPortableWorkflowsCSV() {
3436        ExportPresenter.share(
3437            filename: portabilityFilename(suffix: "workflows", fileExtension: "csv"),
3438            contents: viewModel.exportPortableWorkflowsCSV()
3439        )
3440    }
3441
3442    private func exportPortableHistoryJSON() {
3443        guard let data = viewModel.exportPortableHistoryJSONData() else { return }
3444        ExportPresenter.share(filename: portabilityFilename(suffix: "history", fileExtension: "json"), data: data)
3445    }
3446
3447    private func handleImportResult(
3448        _ result: Result<[URL], Error>,
3449        expectedKind: DataPortabilityImportKind
3450    ) {
3451        DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult expectedKind=\(expectedKind.rawValue)")
3452        recordImportDebugStatus("handleImportResult started for \(expectedKind.rawValue)")
3453        do {
3454            let urls = try result.get()
3455            guard let url = urls.first else {
3456                DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult noURLReturned")
3457                recordImportDebugStatus("No URL returned from picker")
3458                return
3459            }
3460            DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult selectedURL=\(url.absoluteString)")
3461            recordImportDebugStatus("Selected \(url.lastPathComponent)")
3462            let shouldStopAccessing = url.startAccessingSecurityScopedResource()
3463            DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult securityScopeGranted=\(shouldStopAccessing)")
3464            recordImportDebugStatus("Security scope granted: \(shouldStopAccessing)")
3465            defer {
3466                if shouldStopAccessing {
3467                    url.stopAccessingSecurityScopedResource()
3468                    DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult securityScopeReleased")
3469                }
3470            }
3471
3472            let data = try Data(contentsOf: url)
3473            DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult dataRead bytes=\(data.count) fileName=\(url.lastPathComponent)")
3474            recordImportDebugStatus("Read \(data.count) bytes from \(url.lastPathComponent)")
3475            let preview = try viewModel.prepareDataImport(
3476                data: data,
3477                fileName: url.lastPathComponent,
3478                mode: importMode
3479            )
3480            DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult previewReady previewKind=\(preview.kind.rawValue) expectedKind=\(expectedKind.rawValue)")
3481            recordImportDebugStatus("Preview ready: \(preview.kind.rawValue)")
3482
3483            guard preview.kind == expectedKind else {
3484                let message = preview.kind == .backup
3485                    ? "That file is a full backup. Use Import Backup."
3486                    : "That file type does not match this import action."
3487                DomainDebugLog.error("DataPortabilitySettingsView.handleImportResult kindMismatch message=\(message)")
3488                recordImportDebugStatus("Kind mismatch: \(message)")
3489                presentImportError(message)
3490                return
3491            }
3492
3493            DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult presentingPreview kind=\(preview.kind.rawValue)")
3494            recordImportDebugStatus("Presenting preview for \(preview.kind.rawValue)")
3495            presentImportPreview(preview)
3496        } catch {
3497            DomainDebugLog.error("DataPortabilitySettingsView.handleImportResult failed error=\(error.localizedDescription)")
3498            recordImportDebugStatus("Import failed: \(error.localizedDescription)")
3499            presentImportError(error.localizedDescription)
3500        }
3501    }
3502
3503    private func applyPendingImport() {
3504        guard let pendingImportPreview else { return }
3505        do {
3506            _ = try viewModel.applyDataImport(pendingImportPreview, mode: importMode)
3507            self.pendingImportPreview = nil
3508        } catch {
3509            pendingImportError = error.localizedDescription
3510        }
3511    }
3512
3513    private func portabilityFilename(suffix: String, fileExtension: String) -> String {
3514        let formatter = DateFormatter()
3515        formatter.dateFormat = "yyyyMMdd_HHmmss"
3516        return "\(formatter.string(from: Date()))_domaindig_\(suffix).\(fileExtension)"
3517    }
3518
3519    private func presentImportPreview(_ preview: DataImportPreview) {
3520        Task { @MainActor in
3521            try? await Task.sleep(for: .milliseconds(300))
3522            DomainDebugLog.debug("DataPortabilitySettingsView.presentImportPreview kind=\(preview.kind.rawValue) fileName=\(preview.fileName)")
3523            recordImportDebugStatus("Preview presented for \(preview.fileName)")
3524            pendingImportPreview = preview
3525        }
3526    }
3527
3528    private func presentImportError(_ message: String) {
3529        Task { @MainActor in
3530            try? await Task.sleep(for: .milliseconds(300))
3531            DomainDebugLog.error("DataPortabilitySettingsView.presentImportError message=\(message)")
3532            recordImportDebugStatus("Error presented: \(message)")
3533            pendingImportError = message
3534        }
3535    }
3536
3537    private func recordImportDebugStatus(_ message: String) {
3538        #if DEBUG
3539        let status = "[Import Debug] \(message)"
3540        importDebugStatus = status
3541        print(status)
3542        #endif
3543    }
3544}
3545
3546private struct DataManagementSettingsView: View {
3547    @Bindable var viewModel: DomainViewModel
3548
3549    @State private var showClearHistoryConfirmation = false
3550    @State private var showClearCacheConfirmation = false
3551    @State private var showClearWorkflowsConfirmation = false
3552    @State private var showClearTrackedDomainsConfirmation = false
3553    @State private var showDeleteAllConfirmation = false
3554    @State private var deleteAllErrorMessage: String?
3555    @State private var deleteAllSuccessMessage: String?
3556    @State private var isDeletingAllData = false
3557
3558    var body: some View {
3559        Form {
3560            Section("Data") {
3561                Button("Clear History", role: .destructive) {
3562                    showClearHistoryConfirmation = true
3563                }
3564
3565                Button("Clear Cache", role: .destructive) {
3566                    showClearCacheConfirmation = true
3567                }
3568
3569                Button("Clear Workflows", role: .destructive) {
3570                    showClearWorkflowsConfirmation = true
3571                }
3572
3573                Button("Clear Tracked Domains", role: .destructive) {
3574                    showClearTrackedDomainsConfirmation = true
3575                }
3576            }
3577
3578            Section {
3579                Button(role: .destructive) {
3580                    showDeleteAllConfirmation = true
3581                } label: {
3582                    HStack {
3583                        Text("Delete All Data")
3584                        Spacer()
3585                        if isDeletingAllData {
3586                            ProgressView()
3587                                .controlSize(.small)
3588                        }
3589                    }
3590                }
3591                .disabled(isDeletingAllData)
3592            } header: {
3593                Text("Danger Zone")
3594            } footer: {
3595                Text("Permanently removes all local DomainDig data from this device.")
3596            }
3597        }
3598        .disabled(isDeletingAllData)
3599        .navigationTitle("Data Management")
3600        .alert("Clear history?", isPresented: $showClearHistoryConfirmation) {
3601            Button("Clear", role: .destructive) {
3602                viewModel.clearHistory()
3603            }
3604            Button("Cancel", role: .cancel) {}
3605        } message: {
3606            Text("This removes saved lookup snapshots and clears monitoring run history on this device.")
3607        }
3608        .alert("Clear cache?", isPresented: $showClearCacheConfirmation) {
3609            Button("Clear", role: .destructive) {
3610                viewModel.clearLookupCache()
3611            }
3612            Button("Cancel", role: .cancel) {}
3613        } message: {
3614            Text("This clears the in-memory lookup cache and cancels any cached in-flight work.")
3615        }
3616        .alert("Clear workflows?", isPresented: $showClearWorkflowsConfirmation) {
3617            Button("Clear", role: .destructive) {
3618                viewModel.clearWorkflows()
3619            }
3620            Button("Cancel", role: .cancel) {}
3621        } message: {
3622            Text("This removes saved workflows only. History, tracked domains, and saved reports stay intact.")
3623        }
3624        .alert("Clear tracked domains?", isPresented: $showClearTrackedDomainsConfirmation) {
3625            Button("Clear", role: .destructive) {
3626                viewModel.clearTrackedDomains()
3627            }
3628            Button("Cancel", role: .cancel) {}
3629        } message: {
3630            Text("This removes the watchlist and clears monitoring run history. History and workflows stay intact.")
3631        }
3632        .alert("Delete All Data?", isPresented: $showDeleteAllConfirmation) {
3633            Button("Cancel", role: .cancel) {}
3634            Button("Delete All Data", role: .destructive) {
3635                deleteAllData()
3636            }
3637        } message: {
3638            Text("This will permanently remove all saved DomainDig data from this device. This includes tracked domains, monitoring history, snapshots, exports, cached reports, and local settings. This action cannot be undone.")
3639        }
3640        .alert("Delete Failed", isPresented: Binding(
3641            get: { deleteAllErrorMessage != nil },
3642            set: { if !$0 { deleteAllErrorMessage = nil } }
3643        )) {
3644            Button("OK", role: .cancel) {}
3645        } message: {
3646            Text(deleteAllErrorMessage ?? "The local data reset could not be completed.")
3647        }
3648        .safeAreaInset(edge: .bottom) {
3649            if let deleteAllSuccessMessage {
3650                Text(deleteAllSuccessMessage)
3651                    .font(.footnote.weight(.medium))
3652                    .foregroundStyle(.secondary)
3653                    .padding(.horizontal, 14)
3654                    .padding(.vertical, 10)
3655                    .background(.thinMaterial, in: Capsule())
3656                    .padding(.bottom, 8)
3657                    .transition(.move(edge: .bottom).combined(with: .opacity))
3658            }
3659        }
3660    }
3661
3662    private func deleteAllData() {
3663        guard !isDeletingAllData else { return }
3664
3665        isDeletingAllData = true
3666        deleteAllErrorMessage = nil
3667        deleteAllSuccessMessage = nil
3668
3669        Task {
3670            do {
3671                try await DataResetService.wipeAllLocalData(viewModel: viewModel)
3672                deleteAllSuccessMessage = "All local data removed."
3673                try? await Task.sleep(for: .seconds(2))
3674                if deleteAllSuccessMessage == "All local data removed." {
3675                    deleteAllSuccessMessage = nil
3676                }
3677            } catch {
3678                deleteAllErrorMessage = error.localizedDescription
3679            }
3680
3681            isDeletingAllData = false
3682        }
3683    }
3684}
3685
3686private struct AboutSettingsView: View {
3687    @State private var cloudSyncService = CloudSyncService.shared
3688
3689    private var appVersion: String {
3690        AppVersion.current
3691    }
3692
3693    var body: some View {
3694        Form {
3695            Section("About") {
3696                LabeledContent("Version", value: appVersion)
3697                LabeledContent("Storage", value: cloudSyncService.isEnabled ? "Local-first + iCloud" : "Local-only")
3698                LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)")
3699            }
3700        }
3701        .navigationTitle("App Info")
3702        .task {
3703            await cloudSyncService.refreshAvailability()
3704        }
3705    }
3706}
3707
3708private struct DataImportPreviewSheet: View {
3709    @Environment(\.dismiss) private var dismiss
3710
3711    let preview: DataImportPreview
3712    let mode: DataPortabilityImportMode
3713    let onCancel: () -> Void
3714    let onApply: () -> Void
3715
3716    var body: some View {
3717        NavigationStack {
3718            List {
3719                Section("Summary") {
3720                    ForEach(preview.summaryLines, id: \.self) { line in
3721                        Text(line)
3722                    }
3723                }
3724
3725                Section("Projected Counts") {
3726                    LabeledContent("Tracked Domains", value: "\(preview.projectedCounts.trackedDomains)")
3727                    LabeledContent("History Snapshots", value: "\(preview.projectedCounts.historySnapshots)")
3728                    LabeledContent("Audit Sessions", value: "\(preview.projectedCounts.auditSessions)")
3729                    LabeledContent("Workflows", value: "\(preview.projectedCounts.workflows)")
3730                    LabeledContent("Cached Items", value: "\(preview.projectedCounts.cachedItems)")
3731                    LabeledContent("Monitoring Logs", value: "\(preview.projectedCounts.monitoringLogs)")
3732                }
3733
3734                if !preview.warnings.isEmpty {
3735                    Section("Warnings") {
3736                        ForEach(preview.warnings, id: \.self) { warning in
3737                            Text(warning)
3738                                .foregroundStyle(.secondary)
3739                        }
3740                    }
3741                }
3742            }
3743            .navigationTitle("Import Preview")
3744            .toolbar {
3745                ToolbarItem(placement: .cancellationAction) {
3746                    Button("Cancel") {
3747                        onCancel()
3748                        dismiss()
3749                    }
3750                }
3751                ToolbarItem(placement: .confirmationAction) {
3752                    Button(mode == .replace ? "Replace" : "Import") {
3753                        onApply()
3754                        if mode == .merge {
3755                            dismiss()
3756                        }
3757                    }
3758                }
3759            }
3760        }
3761    }
3762}
3763
3764#Preview {
3765    ContentView(viewModel: DomainViewModel())
3766}