//
//  SnapshotGalleryTests.swift
//  HoneyDueTests
//
//  iOS parity-gallery Roborazzi-equivalent. Records baseline PNGs for
//  every iOS-reachable screen in the canonical
//  `GalleryScreens` manifest (defined in
//  `composeApp/src/commonMain/.../testing/GalleryManifest.kt`).
//
//  Variant matrix (driven by `GalleryCategory` in the manifest):
//
//   DataCarrying surfaces — 4 captures per surface:
//     surface_empty_light.png      (empty fixture, no lookups,  light)
//     surface_empty_dark.png       (empty fixture, no lookups,  dark)
//     surface_populated_light.png  (populated fixture,          light)
//     surface_populated_dark.png   (populated fixture,          dark)
//
//   DataFree surfaces — 2 captures per surface:
//     surface_light.png            (empty fixture, lookups seeded, light)
//     surface_dark.png             (empty fixture, lookups seeded, dark)
//
//  The companion Android Roborazzi harness follows the identical rules,
//  so any layout divergence between iOS and Android renders of the same
//  screen is a real parity bug — not a test data mismatch.
//
//  Recording goldens
//  -----------------
//  Preferred: `make record-snapshots` (or `./scripts/record_snapshots.sh
//  --ios-only`). The script exports `SNAPSHOT_TESTING_RECORD=1` in the
//  xcodebuild env, deletes the old `__Snapshots__/SnapshotGalleryTests`
//  directory, runs the target, then invokes the shared PNG optimizer.
//
//  Manual override: set `SNAPSHOT_TESTING_RECORD=1` in the Xcode scheme's
//  Test action (Edit Scheme → Test → Arguments → Environment Variables)
//  and re-run the test target.
//
//  Rendering scale
//  ---------------
//  We force `displayScale: 2.0` on every snapshot. @3x native on modern
//  iPhones produced 800–1000 KB PNGs per image on gradient-heavy views.
//  @2x keeps captures under the 400 KB CI budget after zopflipng.
//

@preconcurrency import SnapshotTesting
import SwiftUI
import XCTest
import ComposeApp
@testable import honeyDue

@MainActor
final class SnapshotGalleryTests: XCTestCase {

    // MARK: - Configuration

    /// Record mode is driven by the `SNAPSHOT_TESTING_RECORD` env var.
    /// When unset/empty we only write missing goldens (`.missing`) so
    /// local dev runs never silently overwrite committed PNGs.
    private static var recordMode: SnapshotTestingConfiguration.Record {
        let env = ProcessInfo.processInfo.environment["SNAPSHOT_TESTING_RECORD"] ?? ""
        return (env == "1" || env.lowercased() == "true") ? .all : .missing
    }

    override func invokeTest() {
        withSnapshotTesting(record: Self.recordMode) {
            super.invokeTest()
        }
    }

    // Tuned so that a second run against goldens recorded on the first
    // run passes green across SF Pro rendering jitter and anti-alias.
    private static let pixelPrecision: Float = 0.97
    private static let perceptualPrecision: Float = 0.95
    private static let forcedDisplayScale: CGFloat = 2.0

    // MARK: - Fixture seeding

    /// Clear and re-seed `DataManagerObservable.shared` with the empty
    /// fixture. `seedLookups: false` mirrors the Android harness's
    /// empty-variant for DataCarrying surfaces (empty forms show empty
    /// dropdowns so populated-vs-empty PNGs differ). DataFree surfaces
    /// pass `seedLookups: true` to match production behaviour (a user
    /// with zero entities still sees the priority picker).
    private func seedEmpty(seedLookups: Bool) {
        copyFixture(
            FixtureDataManager.shared.empty(seedLookups: seedLookups),
            into: DataManagerObservable.shared
        )
    }

    /// Seed `DataManagerObservable.shared` with the populated fixture.
    private func seedPopulated() {
        copyFixture(FixtureDataManager.shared.populated(), into: DataManagerObservable.shared)
    }

    /// Synchronously copy every StateFlow value from an `IDataManager`
    /// fixture onto a `DataManagerObservable`'s `@Published` properties.
    private func copyFixture(_ fixture: IDataManager, into observable: DataManagerObservable) {
        observable.currentUser = fixture.currentUser.value
        observable.isAuthenticated = fixture.currentUser.value != nil

        observable.residences = fixture.residences.value
        observable.myResidences = fixture.myResidences.value
        observable.totalSummary = fixture.totalSummary.value
        observable.residenceSummaries = mapInt(fixture.residenceSummaries.value)

        observable.allTasks = fixture.allTasks.value
        observable.tasksByResidence = mapInt(fixture.tasksByResidence.value)

        observable.documents = fixture.documents.value
        observable.documentsByResidence = mapIntArray(fixture.documentsByResidence.value)

        observable.contractors = fixture.contractors.value

        observable.subscription = fixture.subscription.value
        observable.upgradeTriggers = mapString(fixture.upgradeTriggers.value)
        observable.featureBenefits = fixture.featureBenefits.value
        observable.promotions = fixture.promotions.value

        observable.residenceTypes = fixture.residenceTypes.value
        observable.taskFrequencies = fixture.taskFrequencies.value
        observable.taskPriorities = fixture.taskPriorities.value
        observable.taskCategories = fixture.taskCategories.value
        observable.contractorSpecialties = fixture.contractorSpecialties.value

        observable.taskTemplates = fixture.taskTemplates.value
        observable.taskTemplatesGrouped = fixture.taskTemplatesGrouped.value

        let hasLookups = !fixture.residenceTypes.value.isEmpty ||
            !fixture.taskPriorities.value.isEmpty ||
            !fixture.taskCategories.value.isEmpty
        observable.lookupsInitialized = hasLookups
        observable.isInitialized = hasLookups
    }

    private func mapInt<V>(_ kotlinMap: Any?) -> [Int32: V] {
        guard let nsDict = kotlinMap as? NSDictionary else { return [:] }
        var result: [Int32: V] = [:]
        for key in nsDict.allKeys {
            guard let value = nsDict[key], let typed = value as? V else { continue }
            if let ki = key as? KotlinInt {
                result[ki.int32Value] = typed
            } else if let ns = key as? NSNumber {
                result[ns.int32Value] = typed
            }
        }
        return result
    }

    private func mapIntArray<V>(_ kotlinMap: Any?) -> [Int32: [V]] {
        guard let nsDict = kotlinMap as? NSDictionary else { return [:] }
        var result: [Int32: [V]] = [:]
        for key in nsDict.allKeys {
            guard let value = nsDict[key] else { continue }
            let typed: [V]
            if let arr = value as? [V] {
                typed = arr
            } else if let nsArr = value as? NSArray {
                typed = nsArr.compactMap { $0 as? V }
            } else {
                continue
            }
            if let ki = key as? KotlinInt {
                result[ki.int32Value] = typed
            } else if let ns = key as? NSNumber {
                result[ns.int32Value] = typed
            }
        }
        return result
    }

    private func mapString<V>(_ kotlinMap: Any?) -> [String: V] {
        if let direct = kotlinMap as? [String: V] { return direct }
        guard let nsDict = kotlinMap as? NSDictionary else { return [:] }
        var result: [String: V] = [:]
        for key in nsDict.allKeys {
            guard let s = key as? String, let v = nsDict[key] as? V else { continue }
            result[s] = v
        }
        return result
    }

    // MARK: - Snap helpers

    /// Capture a DataFree surface: 2 PNGs (`<name>_light`, `<name>_dark`)
    /// against the lookups-seeded empty fixture.
    private func snapDataFree<V: View>(
        _ name: String,
        file: StaticString = #filePath,
        testName: String = #function,
        line: UInt = #line,
        @ViewBuilder content: () -> V
    ) {
        seedEmpty(seedLookups: true)
        let view = content()
            .environmentObject(ThemeManager.shared)
            .environment(\.dataManager, DataManagerObservable.shared)
        assertLightDark(view, name: name, file: file, testName: testName, line: line)
    }

    /// Capture a DataCarrying surface: 4 PNGs (`<name>_empty_light`,
    /// `<name>_empty_dark`, `<name>_populated_light`, `<name>_populated_dark`).
    /// The view closure is invoked *after* each fixture seeding so the
    /// view's ViewModels pick up the freshly-seeded `DataManagerObservable`
    /// values on init (their `init(dataManager: = .shared)` path seeds
    /// synchronously from the shared cache).
    private func snapDataCarrying<V: View>(
        _ name: String,
        file: StaticString = #filePath,
        testName: String = #function,
        line: UInt = #line,
        @ViewBuilder content: () -> V
    ) {
        // Empty variant (no lookups — so forms diff against populated).
        seedEmpty(seedLookups: false)
        let emptyView = content()
            .environmentObject(ThemeManager.shared)
            .environment(\.dataManager, DataManagerObservable.shared)
        assertLightDark(emptyView, name: "\(name)_empty", file: file, testName: testName, line: line)

        // Populated variant.
        seedPopulated()
        let populatedView = content()
            .environmentObject(ThemeManager.shared)
            .environment(\.dataManager, DataManagerObservable.shared)
        assertLightDark(populatedView, name: "\(name)_populated", file: file, testName: testName, line: line)
    }

    /// Render `view` in both light and dark color schemes, writing
    /// `<name>_light` and `<name>_dark` goldens.
    private func assertLightDark<V: View>(
        _ view: V,
        name: String,
        file: StaticString,
        testName: String,
        line: UInt
    ) {
        assertSnapshot(
            of: view.environment(\.colorScheme, .light),
            as: .image(
                precision: Self.pixelPrecision,
                perceptualPrecision: Self.perceptualPrecision,
                layout: .device(config: .iPhone13),
                traits: .init(traitsFrom: [
                    UITraitCollection(userInterfaceStyle: .light),
                    UITraitCollection(displayScale: Self.forcedDisplayScale),
                ])
            ),
            named: "\(name)_light",
            file: file,
            testName: testName,
            line: line
        )
        assertSnapshot(
            of: view.environment(\.colorScheme, .dark),
            as: .image(
                precision: Self.pixelPrecision,
                perceptualPrecision: Self.perceptualPrecision,
                layout: .device(config: .iPhone13),
                traits: .init(traitsFrom: [
                    UITraitCollection(userInterfaceStyle: .dark),
                    UITraitCollection(displayScale: Self.forcedDisplayScale),
                ])
            ),
            named: "\(name)_dark",
            file: file,
            testName: testName,
            line: line
        )
    }

    // MARK: - Fixture accessors
    //
    // Pull a realistic id / object out of the populated fixture so detail
    // and edit surfaces can be instantiated with values that will actually
    // have a match in the seeded observable. Using `.populated()` avoids
    // depending on fixture ordering within `.shared`.

    private var fixtureResidenceId: Int32 {
        Int32(FixtureDataManager.shared.populated().residences.value.first?.id ?? 1)
    }

    private var fixtureResidence: ResidenceResponse? {
        FixtureDataManager.shared.populated().residences.value.first
    }

    private var fixtureTask: TaskResponse? {
        FixtureDataManager.shared.populated().allTasks.value?
            .columns.first?.tasks.first
    }

    private var fixtureContractor: Contractor? {
        FixtureDataManager.shared.populated().contractorDetail.value.values.first
    }

    private var fixtureContractorId: Int32 {
        Int32(FixtureDataManager.shared.populated().contractors.value.first?.id ?? 1)
    }

    private var fixtureDocument: Document? {
        FixtureDataManager.shared.populated().documents.value.first
    }

    private var fixtureDocumentId: Int32 {
        Int32(fixtureDocument?.id ?? 1)
    }

    /// Hand-rolled `ResidenceUserResponse` list for `manage_users`.
    /// The fixture doesn't seed residence-users (there's no
    /// `usersByResidence` StateFlow yet on `IDataManager`), so we build
    /// a minimal set here. Matches the Kotlin
    /// `ResidenceUserResponse(id, username, email, firstName, lastName)`
    /// shape.
    private var fixtureResidenceUsers: [ResidenceUserResponse] {
        let user = FixtureDataManager.shared.populated().currentUser.value
        let ownerId = Int32(user?.id ?? 1)
        return [
            ResidenceUserResponse(
                id: ownerId,
                username: user?.username ?? "owner",
                email: user?.email ?? "owner@example.com",
                firstName: user?.firstName ?? "Sam",
                lastName: user?.lastName ?? "Owner"
            ),
            ResidenceUserResponse(
                id: ownerId + 1,
                username: "partner",
                email: "partner@example.com",
                firstName: "Taylor",
                lastName: "Partner"
            ),
        ]
    }

    // ========================================================================
    // MARK: - Auth (DataFree)
    // ========================================================================

    func test_login() {
        snapDataFree("login") {
            LoginView(resetToken: .constant(nil), onLoginSuccess: nil)
        }
    }

    func test_register() {
        snapDataFree("register") {
            RegisterView(isPresented: .constant(true), onVerified: nil)
        }
    }

    func test_forgot_password() {
        let vm = PasswordResetViewModel()
        snapDataFree("forgot_password") {
            NavigationStack { ForgotPasswordView(viewModel: vm) }
        }
    }

    func test_verify_reset_code() {
        let vm = PasswordResetViewModel()
        vm.email = "user@example.com"
        vm.currentStep = .verifyCode
        snapDataFree("verify_reset_code") {
            NavigationStack { VerifyResetCodeView(viewModel: vm) }
        }
    }

    func test_reset_password() {
        let vm = PasswordResetViewModel()
        vm.currentStep = .resetPassword
        snapDataFree("reset_password") {
            NavigationStack { ResetPasswordView(viewModel: vm, onSuccess: {}) }
        }
    }

    func test_verify_email() {
        snapDataFree("verify_email") {
            VerifyEmailView(onVerifySuccess: {}, onLogout: {})
        }
    }

    // ========================================================================
    // MARK: - Onboarding (DataFree, except first_task)
    // ========================================================================

    func test_onboarding_welcome() {
        snapDataFree("onboarding_welcome") {
            OnboardingWelcomeView(onStartFresh: {}, onJoinExisting: {}, onLogin: {})
        }
    }

    func test_onboarding_value_props() {
        snapDataFree("onboarding_value_props") {
            OnboardingValuePropsView(onContinue: {}, onSkip: {}, onBack: {})
        }
    }

    func test_onboarding_create_account() {
        snapDataFree("onboarding_create_account") {
            OnboardingCreateAccountView(onAccountCreated: { _ in }, onBack: {})
        }
    }

    func test_onboarding_verify_email() {
        snapDataFree("onboarding_verify_email") {
            OnboardingVerifyEmailView(onVerified: {}, onLogout: {})
        }
    }

    func test_onboarding_location() {
        snapDataFree("onboarding_location") {
            OnboardingLocationContent(onLocationDetected: { _ in }, onSkip: {})
        }
    }

    func test_onboarding_name_residence() {
        snapDataFree("onboarding_name_residence") {
            StatefulPreviewWrapper("") { binding in
                OnboardingNameResidenceView(
                    residenceName: binding,
                    onContinue: {},
                    onBack: {}
                )
            }
        }
    }

    func test_onboarding_home_profile() {
        snapDataFree("onboarding_home_profile") {
            OnboardingHomeProfileContent(onContinue: {}, onSkip: {})
        }
    }

    func test_onboarding_join_residence() {
        snapDataFree("onboarding_join_residence") {
            OnboardingJoinResidenceView(onJoined: {}, onSkip: {})
        }
    }

    func test_onboarding_first_task() {
        // Empty uses the default init (VM loads via APILayer, fails
        // hermetically, renders error/empty). Populated uses the preview
        // init passing a VM seeded with fixture `taskTemplatesGrouped`
        // so the "Browse All" tab renders a populated template catalog.
        seedEmpty(seedLookups: false)
        let emptyView = OnboardingFirstTaskContent(
            residenceName: "My House",
            onTaskAdded: {}
        )
        .environmentObject(ThemeManager.shared)
        .environment(\.dataManager, DataManagerObservable.shared)
        assertLightDark(emptyView, name: "onboarding_first_task_empty", file: #filePath, testName: #function, line: #line)

        seedPopulated()
        let grouped = FixtureDataManager.shared.populated().taskTemplatesGrouped.value
        let seededVM = OnboardingTasksViewModel(
            initialSuggestions: [],
            initialGrouped: grouped
        )
        let populatedView = OnboardingFirstTaskContent(
            residenceName: "My House",
            onTaskAdded: {},
            viewModel: seededVM
        )
        .environmentObject(ThemeManager.shared)
        .environment(\.dataManager, DataManagerObservable.shared)
        assertLightDark(populatedView, name: "onboarding_first_task_populated", file: #filePath, testName: #function, line: #line)
    }

    func test_onboarding_subscription() {
        snapDataFree("onboarding_subscription") {
            OnboardingSubscriptionView(onSubscribe: {}, onSkip: {})
        }
    }

    // ========================================================================
    // MARK: - Residences
    // ========================================================================

    func test_residences() {
        snapDataCarrying("residences") {
            NavigationStack { ResidencesListView() }
        }
    }

    func test_residence_detail() {
        snapDataCarrying("residence_detail") {
            NavigationStack {
                ResidenceDetailView(residenceId: self.fixtureResidenceId, preview: true)
            }
        }
    }

    func test_add_residence() {
        snapDataFree("add_residence") {
            AddResidenceView(isPresented: .constant(true), onResidenceCreated: nil)
        }
    }

    func test_edit_residence() {
        // `edit_residence` is DataFree: the form is populated from the
        // passed-in `residence` object regardless of DataManager state.
        let residence = fixtureResidence ?? FixtureDataManager.shared.populated().residences.value.first!
        snapDataFree("edit_residence") {
            NavigationStack {
                EditResidenceView(residence: residence, isPresented: .constant(true))
            }
        }
    }

    func test_join_residence() {
        snapDataFree("join_residence") {
            JoinResidenceView(onJoined: {})
        }
    }

    func test_manage_users() {
        // Empty variant uses the default init (0 users, loading state);
        // populated variant uses the preview init with a seeded user
        // list. This produces a visible populated-vs-empty diff without
        // waiting for `loadUsers()`'s APILayer round-trip.
        seedEmpty(seedLookups: false)
        let emptyView = NavigationStack {
            ManageUsersView(
                residenceId: fixtureResidenceId,
                residenceName: fixtureResidence?.name ?? "My House",
                isPrimaryOwner: true,
                residenceOwnerId: Int32(fixtureResidence?.ownerId ?? 1),
                residence: nil,
                initialUsers: []
            )
        }
        .environmentObject(ThemeManager.shared)
        .environment(\.dataManager, DataManagerObservable.shared)
        assertLightDark(emptyView, name: "manage_users_empty", file: #filePath, testName: #function, line: #line)

        seedPopulated()
        let populatedView = NavigationStack {
            ManageUsersView(
                residenceId: fixtureResidenceId,
                residenceName: fixtureResidence?.name ?? "My House",
                isPrimaryOwner: true,
                residenceOwnerId: Int32(fixtureResidence?.ownerId ?? 1),
                residence: fixtureResidence,
                initialUsers: fixtureResidenceUsers
            )
        }
        .environmentObject(ThemeManager.shared)
        .environment(\.dataManager, DataManagerObservable.shared)
        assertLightDark(populatedView, name: "manage_users_populated", file: #filePath, testName: #function, line: #line)
    }

    // ========================================================================
    // MARK: - Tasks
    // ========================================================================

    func test_all_tasks() {
        snapDataCarrying("all_tasks") {
            NavigationStack { AllTasksView() }
        }
    }

    func test_add_task() {
        snapDataFree("add_task") {
            AddTaskView(residenceId: self.fixtureResidenceId, isPresented: .constant(true))
        }
    }

    func test_add_task_with_residence() {
        snapDataFree("add_task_with_residence") {
            AddTaskWithResidenceView(
                isPresented: .constant(true),
                residences: DataManagerObservable.shared.myResidences?.residences ?? []
            )
        }
    }

    func test_edit_task() {
        // `edit_task` is DataFree: the form is populated from the passed-in
        // `task` object regardless of DataManager state.
        let task = fixtureTask ?? FixtureDataManager.shared.populated()
            .allTasks.value!.columns.first!.tasks.first!
        snapDataFree("edit_task") {
            NavigationStack {
                EditTaskView(task: task, isPresented: .constant(true))
            }
        }
    }

    func test_complete_task() {
        // DataFree: task and residence name are static props; the
        // contractor picker is collapsed on first paint, so nothing
        // visible diffs between empty and populated.
        let task = fixtureTask ?? FixtureDataManager.shared.populated()
            .allTasks.value!.columns.first!.tasks.first!
        snapDataFree("complete_task") {
            NavigationStack {
                CompleteTaskView(task: task, onComplete: { _ in })
            }
        }
    }

    func test_task_suggestions() {
        snapDataCarrying("task_suggestions") {
            // TaskSuggestionsView accepts `suggestions: [TaskTemplate]`
            // directly; pulling from `DataManagerObservable.shared` lets
            // the populated variant show seeded templates and the empty
            // variant show the empty state.
            let templates = Array(DataManagerObservable.shared.taskTemplates.prefix(4))
            return TaskSuggestionsView(suggestions: templates, onSelect: { _ in })
        }
    }

    func test_task_templates_browser() {
        snapDataCarrying("task_templates_browser") {
            NavigationStack { TaskTemplatesBrowserView(onSelect: { _ in }) }
        }
    }

    // ========================================================================
    // MARK: - Contractors
    // ========================================================================

    func test_contractors() {
        snapDataCarrying("contractors") {
            NavigationStack { ContractorsListView() }
        }
    }

    func test_contractor_detail() {
        // Empty variant uses the default init (no pre-seeded detail);
        // populated variant passes a VM built with a fixture contractor
        // so `selectedContractor` is non-nil on the first frame.
        seedEmpty(seedLookups: false)
        let emptyView = NavigationStack {
            ContractorDetailView(contractorId: self.fixtureContractorId)
        }
        .environmentObject(ThemeManager.shared)
        .environment(\.dataManager, DataManagerObservable.shared)
        assertLightDark(emptyView, name: "contractor_detail_empty", file: #filePath, testName: #function, line: #line)

        seedPopulated()
        let populatedView = NavigationStack {
            ContractorDetailView(
                contractorId: self.fixtureContractorId,
                viewModel: ContractorViewModel(initialSelectedContractor: self.fixtureContractor)
            )
        }
        .environmentObject(ThemeManager.shared)
        .environment(\.dataManager, DataManagerObservable.shared)
        assertLightDark(populatedView, name: "contractor_detail_populated", file: #filePath, testName: #function, line: #line)
    }

    // ========================================================================
    // MARK: - Documents
    // ========================================================================

    func test_documents_warranties() {
        snapDataCarrying("documents_warranties") {
            NavigationStack { DocumentsWarrantiesView(residenceId: nil) }
        }
    }

    func test_document_detail() {
        // Empty uses the default init (no pre-seeded detail); populated
        // passes a VM pre-seeded with a fixture document so the detail
        // renders on the first frame.
        seedEmpty(seedLookups: false)
        let emptyDocId = Int32(FixtureDataManager.shared.populated().documents.value.first?.id?.int32Value ?? 1)
        let emptyView = NavigationStack {
            DocumentDetailView(documentId: emptyDocId)
        }
        .environmentObject(ThemeManager.shared)
        .environment(\.dataManager, DataManagerObservable.shared)
        assertLightDark(emptyView, name: "document_detail_empty", file: #filePath, testName: #function, line: #line)

        seedPopulated()
        // Pull the document off the freshly-seeded shared observable so
        // we're definitely using the same instance the fixture pushed
        // in. `FixtureDataManager.populated()` returns a *new* IDataManager
        // on each call, so calling `.documents.value.first` on a separate
        // `populated()` invocation isn't guaranteed to match (and in
        // practice was returning nil here due to the Kotlin→Swift bridge
        // not retaining the flow's element type).
        guard let populatedDoc = DataManagerObservable.shared.documents.first,
              let populatedId = populatedDoc.id?.int32Value else {
            XCTFail("Populated fixture should seed at least one document onto DataManagerObservable.shared")
            return
        }
        let populatedView = NavigationStack {
            DocumentDetailView(
                documentId: populatedId,
                viewModel: DocumentViewModelWrapper(initialDocument: populatedDoc)
            )
        }
        .environmentObject(ThemeManager.shared)
        .environment(\.dataManager, DataManagerObservable.shared)
        assertLightDark(populatedView, name: "document_detail_populated", file: #filePath, testName: #function, line: #line)
    }

    func test_add_document() {
        snapDataFree("add_document") {
            AddDocumentView(
                residenceId: self.fixtureResidenceId,
                initialDocumentType: "other",
                isPresented: .constant(true),
                documentViewModel: DocumentViewModel()
            )
        }
    }

    func test_edit_document() {
        let doc = fixtureDocument ?? FixtureDataManager.shared.populated().documents.value.first!
        snapDataFree("edit_document") {
            NavigationStack {
                EditDocumentView(document: doc)
            }
        }
    }

    // ========================================================================
    // MARK: - Profile / settings
    // ========================================================================

    func test_profile() {
        snapDataCarrying("profile") {
            NavigationStack { ProfileTabView() }
        }
    }

    func test_profile_edit() {
        snapDataFree("profile_edit") {
            NavigationStack { ProfileView() }
        }
    }

    func test_notification_preferences() {
        snapDataFree("notification_preferences") {
            NavigationStack { NotificationPreferencesView() }
        }
    }

    func test_theme_selection() {
        snapDataFree("theme_selection") {
            NavigationStack { ThemeSelectionView() }
        }
    }

    // ========================================================================
    // MARK: - Subscription
    // ========================================================================

    func test_feature_comparison() {
        snapDataFree("feature_comparison") {
            FeatureComparisonView(isPresented: .constant(true))
        }
    }
}

// MARK: - StatefulPreviewWrapper

/// Lets us hand a view a `@Binding` backed by a local `@State` so that
/// views which mutate bindings (e.g. `OnboardingNameResidenceView`) render
/// correctly inside a snapshot test — which has no surrounding state host.
private struct StatefulPreviewWrapper<Value, Content: View>: View {
    @State private var value: Value
    let content: (Binding<Value>) -> Content

    init(_ initial: Value, @ViewBuilder content: @escaping (Binding<Value>) -> Content) {
        _value = State(initialValue: initial)
        self.content = content
    }

    var body: some View {
        content($value)
    }
}
