krz/domain-dig

an ios app for DNS & SSL analysis

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

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