//
//  TravelAlbumViewModelTests.swift
//  suixinkanTests
//

import XCTest
@testable import suixinkan

@MainActor
/// 旅拍相册 ViewModel 测试。
final class TravelAlbumEntryViewModelTests: XCTestCase {
    func testLoadAlbumsUpdatesListAndTotal() async {
        let api = TravelAlbumMockAPI()
        api.listResponse = TravelAlbumListResponse(total: 1, list: [TravelAlbum(id: 1, name: "A")])
        let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })

        await viewModel.loadAlbums(api: api)

        XCTAssertEqual(viewModel.albumTotal, 1)
        XCTAssertEqual(viewModel.albums.first?.name, "A")
    }

    func testPreShootRequiresSinglePrice() async {
        let api = TravelAlbumMockAPI()
        let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
        var message = ""
        viewModel.onShowMessage = { message = $0 }

        await viewModel.confirmCreate(mode: .preShoot, freeCount: "", singlePrice: "", packagePrice: "", order: nil, api: api)

        XCTAssertEqual(message, "请输入单张照片价格")
        XCTAssertEqual(api.createRequests.count, 0)
    }

    func testPreOrderRequiresOrder() async {
        let api = TravelAlbumMockAPI()
        let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
        var message = ""
        viewModel.onShowMessage = { message = $0 }

        await viewModel.confirmCreate(mode: .preOrder, freeCount: "", singlePrice: "", packagePrice: "", order: nil, api: api)

        XCTAssertEqual(message, "请选择绑定订单")
    }

    func testCreateSuccessRefreshesAlbums() async {
        let api = TravelAlbumMockAPI()
        api.createResponse = TravelAlbumCreateResponse(id: 9)
        api.listResponse = TravelAlbumListResponse(total: 1, list: [TravelAlbum(id: 9)])
        let date = Calendar(identifier: .gregorian).date(from: DateComponents(year: 2026, month: 7, day: 7))!
        let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 }, dateProvider: { date })

        await viewModel.confirmCreate(mode: .preShoot, freeCount: "2", singlePrice: "12.5", packagePrice: "88", order: nil, api: api)

        XCTAssertEqual(api.createRequests.first?.name, "2026-07-07-001")
        XCTAssertEqual(api.createRequests.first?.type, 1)
        XCTAssertEqual(viewModel.albums.first?.id, 9)
        XCTAssertFalse(viewModel.isCreateSheetVisible)
    }

    func testOrderAlreadyBoundReloadsOrders() async {
        let api = TravelAlbumMockAPI()
        api.createError = APIError.serverCode(100001, "订单已关联")
        api.availableOrdersResponse = [TravelAlbumAvailableOrder(projectName: "新订单", orderNumber: "NO1")]
        let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
        let order = TravelAlbumAvailableOrder(projectName: "项目", orderNumber: "NO0")

        await viewModel.confirmCreate(mode: .preOrder, freeCount: "", singlePrice: "", packagePrice: "", order: order, api: api)

        XCTAssertEqual(api.availableOrdersCallCount, 1)
        XCTAssertEqual(viewModel.availableOrders.first?.projectName, "新订单")
    }
}

@MainActor
/// 旅拍相册详情 ViewModel 测试。
final class TravelAlbumDetailViewModelTests: XCTestCase {
    func testRefreshLoadsInfoAndMaterials() async {
        let api = TravelAlbumMockAPI()
        api.infoResponse = TravelAlbum(id: 2, name: "详情")
        api.materialListResponses = [
            TravelAlbumListResponse(total: 2, list: []),
            TravelAlbumListResponse(total: 2, list: [TravelAlbumMaterial(id: 1), TravelAlbumMaterial(id: 2)]),
        ]
        let viewModel = TravelAlbumDetailViewModel(albumId: 2)

        await viewModel.refreshAll(api: api)

        XCTAssertEqual(viewModel.album?.name, "详情")
        XCTAssertEqual(viewModel.allPhotoCount, 2)
        XCTAssertEqual(viewModel.materials.count, 2)
    }

    func testTabAndSortTriggerMaterialRequests() async {
        let api = TravelAlbumMockAPI()
        api.materialListResponses = [
            TravelAlbumListResponse(total: 0, list: []),
            TravelAlbumListResponse(total: 0, list: []),
        ]
        let viewModel = TravelAlbumDetailViewModel(albumId: 2)

        await viewModel.selectTab(.purchased, api: api)
        await viewModel.setSortOption(.fileNameDesc, api: api)

        XCTAssertEqual(api.materialRequests.first?.isPurchased, 1)
        XCTAssertEqual(api.materialRequests.last?.orderBy, 4)
    }

    func testOnlyUnpurchasedMaterialCanBeSelected() {
        let viewModel = TravelAlbumDetailViewModel(albumId: 2)
        viewModel.toggleSelectionMode()
        viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 2))
        XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)

        viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 1))
        XCTAssertEqual(viewModel.selectedMaterialIds, [1])
    }

    func testDeleteAlbumCallsCallback() async {
        let api = TravelAlbumMockAPI()
        let viewModel = TravelAlbumDetailViewModel(albumId: 5)
        var deletedId = 0
        viewModel.onDeletedAlbum = { deletedId = $0 }

        await viewModel.deleteAlbum(api: api)

        XCTAssertEqual(api.deletedAlbumIds, [5])
        XCTAssertEqual(deletedId, 5)
    }
}

/// 有线传输 ViewModel 测试。
@MainActor
final class WiredCameraTransferViewModelTests: XCTestCase {
    func testDefaultDisconnectedStateAndOptions() {
        let viewModel = WiredCameraTransferViewModel(
            albumId: 1,
            albumTitle: "相册",
            headerPhone: "13800138000",
            userDefaults: makeOTGTestDefaults()
        )

        XCTAssertEqual(viewModel.cameraStatusText, "未连接 USB")
        XCTAssertEqual(viewModel.tabCounts, [0, 0, 0])
        XCTAssertEqual(viewModel.photoFormatOption, .jpg)
        XCTAssertEqual(viewModel.transferModeOption, "边拍边传")
        XCTAssertTrue(viewModel.filteredPhotos().isEmpty)
    }

    func testTransferModePersistsLocallyWithoutAlbumScope() {
        let defaults = makeOTGTestDefaults()
        let firstContext = makeOTGTestContext()
        let secondContext = makeOTGTestContext()
        let firstViewModel = makeWiredViewModel(
            albumId: 9,
            context: firstContext,
            manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
            userDefaults: defaults
        )

        firstViewModel.selectTransferMode("拍后传输")

        let secondViewModel = makeWiredViewModel(
            albumId: 18,
            context: secondContext,
            manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
            userDefaults: defaults
        )

        XCTAssertEqual(secondViewModel.transferModeOption, "拍后传输")
    }

    func testConnectedStatusIncludesCameraModelName() {
        let context = makeOTGTestContext()
        let viewModel = makeWiredViewModel(
            context: context,
            manager: MockWiredCameraConnectionManager(
                state: .connected(platform: .canon, deviceName: "Canon R8"),
                driver: MockCameraDriver(objects: [])
            )
        )

        viewModel.start()

        XCTAssertEqual(viewModel.cameraStatusText, "Canon R8已连接")
        XCTAssertFalse(viewModel.deviceModelName.isEmpty)
        XCTAssertTrue(viewModel.availableStorageText.hasSuffix("GB 可用"))
    }

    func testTransferPhotoSectionsAndSidebarUseDescendingHalfHourSlots() {
        let photos = [
            makeOTGPhotoItem(id: "a", capturedAt: "2026-07-08 12:05:00"),
            makeOTGPhotoItem(id: "b", capturedAt: "2026-07-08 12:29:59"),
            makeOTGPhotoItem(id: "c", capturedAt: "2026-07-08 12:30:00"),
            makeOTGPhotoItem(id: "d", capturedAt: "2026-07-07 18:10:00"),
        ]

        let sections = photos.buildPhotoSections()
        let groups = photos.buildSidebarGroups()

        XCTAssertEqual(sections.map(\.slotId), [
            "2026-07-08T12:30",
            "2026-07-08T12:00",
            "2026-07-07T18:00",
        ])
        XCTAssertEqual(sections[0].headerTitle, "07月08日 12:30 - 13:00")
        XCTAssertEqual(sections[1].photos.map(\.id), ["b", "a"])
        XCTAssertEqual(groups.map(\.dateLabel), ["07月08日", "07月07日"])
        XCTAssertEqual(groups.first?.slots.map(\.slotStartLabel), ["12:30", "12:00"])
        XCTAssertEqual(groups.first?.slots.last?.photoCount, 2)
    }

    func testSelectedTimeSlotFallsBackWhenTabChanges() throws {
        let context = makeOTGTestContext()
        context.store.save([
            try makePersistedOTGRecord(context: context, id: "pending", capturedAt: "2026-07-08 12:05:00", status: .pending),
            try makePersistedOTGRecord(context: context, id: "failed", capturedAt: "2026-07-08 12:30:00", status: .failed),
        ], albumId: 9)
        let viewModel = makeWiredViewModel(
            context: context,
            manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: []))
        )

        viewModel.reloadLocalPhotos()
        viewModel.selectTimeSlot(id: "2026-07-08T12:00")
        viewModel.selectTab(.failed)

        XCTAssertEqual(viewModel.selectedTimeSlotId, "2026-07-08T12:30")
    }

    func testSyncVisibleTimeSlotDoesNotNotifyStateChange() throws {
        let context = makeOTGTestContext()
        context.store.save([
            try makePersistedOTGRecord(context: context, id: "first", capturedAt: "2026-07-08 12:35:00", status: .pending),
            try makePersistedOTGRecord(context: context, id: "second", capturedAt: "2026-07-08 12:05:00", status: .pending),
        ], albumId: 9)
        let viewModel = makeWiredViewModel(
            context: context,
            manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: []))
        )

        viewModel.reloadLocalPhotos()
        var stateChangeCount = 0
        viewModel.onStateChange = {
            stateChangeCount += 1
        }

        viewModel.syncVisibleTimeSlot(id: "2026-07-08T12:00")

        XCTAssertEqual(viewModel.selectedTimeSlotId, "2026-07-08T12:00")
        XCTAssertEqual(stateChangeCount, 0)

        viewModel.selectTimeSlot(id: "2026-07-08T12:30")

        XCTAssertEqual(viewModel.selectedTimeSlotId, "2026-07-08T12:30")
        XCTAssertEqual(stateChangeCount, 1)
    }

    func testSelectionModeAllowsPendingAndFailedOnly() throws {
        let context = makeOTGTestContext()
        context.store.save([
            try makePersistedOTGRecord(context: context, id: "pending", capturedAt: "2026-07-08 12:05:00", status: .pending),
            try makePersistedOTGRecord(context: context, id: "failed", capturedAt: "2026-07-08 12:10:00", status: .failed),
            try makePersistedOTGRecord(context: context, id: "uploaded", capturedAt: "2026-07-08 12:15:00", status: .uploaded),
        ], albumId: 9)
        let viewModel = makeWiredViewModel(
            context: context,
            manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: []))
        )

        viewModel.reloadLocalPhotos()
        viewModel.onBatchUploadButtonClick()
        viewModel.toggleTransferPhotoSelection(photoId: "pending")
        viewModel.toggleTransferPhotoSelection(photoId: "failed")
        viewModel.toggleTransferPhotoSelection(photoId: "uploaded")

        XCTAssertEqual(viewModel.selectedPhotoIds, ["pending", "failed"])
    }

    func testEnteringPageDoesNotListCameraObjects() async {
        let context = makeOTGTestContext()
        let object = CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date())
        let driver = MockCameraDriver(objects: [object])
        let manager = MockWiredCameraConnectionManager(driver: driver)
        let viewModel = makeWiredViewModel(context: context, manager: manager)

        viewModel.start()
        try? await Task.sleep(nanoseconds: 80_000_000)

        XCTAssertEqual(driver.listCallCount, 0)
        XCTAssertTrue(viewModel.filteredPhotos().isEmpty)
    }

    func testHistoryImportRequiresConnectedCatalog() {
        let context = makeOTGTestContext()
        let driver = MockCameraDriver(objects: [])
        let manager = MockWiredCameraConnectionManager(driver: driver, isContentCatalogReady: false)
        let viewModel = makeWiredViewModel(context: context, manager: manager)
        var message = ""
        viewModel.onShowMessage = { message = $0 }

        viewModel.start()
        XCTAssertNil(viewModel.makeCameraImportViewModel())

        XCTAssertEqual(message, "相机照片目录尚未就绪，请稍候再试")
    }

    func testHistoryImportCreatesImportViewModelWhenCatalogReady() {
        let context = makeOTGTestContext()
        let jpeg = CameraObject(id: "JPG|10", filename: "IMG_0001.JPG", fileSize: 10, capturedAt: Date())
        let driver = MockCameraDriver(objects: [jpeg])
        let manager = MockWiredCameraConnectionManager(driver: driver)
        let viewModel = makeWiredViewModel(context: context, manager: manager)

        viewModel.start()

        XCTAssertNotNil(viewModel.makeCameraImportViewModel())
        XCTAssertEqual(driver.listCallCount, 0)
    }

    func testHistoryImportDoesNotDownloadFromTransferPage() async {
        let context = makeOTGTestContext()
        let object = CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date())
        let driver = MockCameraDriver(objects: [object])
        let manager = MockWiredCameraConnectionManager(driver: driver)
        let viewModel = makeWiredViewModel(context: context, manager: manager)

        viewModel.start()
        _ = viewModel.makeCameraImportViewModel()
        try? await Task.sleep(nanoseconds: 80_000_000)

        XCTAssertEqual(driver.downloadedObjectIds, [])
        XCTAssertTrue(viewModel.filteredPhotos().isEmpty)
    }

    func testStopSuspendsLiveTransferPolling() {
        let context = makeOTGTestContext()
        let manager = MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: []))
        let viewModel = makeWiredViewModel(context: context, manager: manager)

        viewModel.start()
        viewModel.stop()

        XCTAssertEqual(manager.suspendLiveTransferCallCount, 1)
    }

    func testHistoricalImportPausesLiveTransferPolling() {
        let context = makeOTGTestContext()
        let manager = MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: []))
        let viewModel = makeWiredViewModel(context: context, manager: manager)

        viewModel.pauseLiveTransferForHistoricalImport()

        XCTAssertEqual(manager.suspendLiveTransferCallCount, 1)
    }

    func testPostTransferModeKeepsNewImportsPending() throws {
        let context = makeOTGTestContext()
        let uploader = MockTravelAlbumOTGUploader()
        let viewModel = makeWiredViewModel(
            context: context,
            manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
            uploader: uploader
        )
        let records = [
            try makePersistedOTGRecord(context: context, id: "a", capturedAt: "2026-07-08 12:05:00", status: .pending),
            try makePersistedOTGRecord(context: context, id: "b", capturedAt: "2026-07-08 12:06:00", status: .pending),
        ]
        context.store.save(records, albumId: 9)

        viewModel.selectTransferMode("拍后传输")
        viewModel.handleNewlyImportedPhotoIds(records.map(\.id))

        XCTAssertEqual(uploader.uploadCallCount, 0)
        XCTAssertEqual(Set(context.store.load(albumId: 9).map(\.status)), [.pending])
    }

    func testSpecifyUploadTodayCapturedOnlyUploadsTodayNotUploadedPhotos() async throws {
        let context = makeOTGTestContext()
        let uploader = MockTravelAlbumOTGUploader()
        let viewModel = makeWiredViewModel(
            context: context,
            manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
            uploader: uploader
        )
        let today = TravelAlbumOTGPhotoStore.transferTimeText(Date())
        let yesterday = TravelAlbumOTGPhotoStore.transferTimeText(Date().addingTimeInterval(-86_400))
        let records = [
            try makePersistedOTGRecord(context: context, id: "today-pending", capturedAt: today, status: .pending),
            try makePersistedOTGRecord(context: context, id: "today-failed", capturedAt: today, status: .failed),
            try makePersistedOTGRecord(context: context, id: "today-uploaded", capturedAt: today, status: .uploaded),
            try makePersistedOTGRecord(context: context, id: "yesterday-pending", capturedAt: yesterday, status: .pending),
        ]
        context.store.save(records, albumId: 9)

        viewModel.reloadLocalPhotos()
        viewModel.onSpecifyUploadOptionSelected(.todayCaptured)
        await waitUntil { uploader.uploadCallCount == 2 }

        XCTAssertEqual(uploader.uploadedRecordIds.sorted(), ["today-failed", "today-pending"])
        let recordsById = Dictionary(uniqueKeysWithValues: context.store.load(albumId: 9).map { ($0.id, $0) })
        XCTAssertEqual(recordsById["today-pending"]?.status, .uploaded)
        XCTAssertEqual(recordsById["today-failed"]?.status, .uploaded)
        XCTAssertEqual(recordsById["today-uploaded"]?.status, .uploaded)
        XCTAssertEqual(recordsById["yesterday-pending"]?.status, .pending)
    }

    func testSpecifyUploadAllPendingUploadsPendingAndFailedPhotos() async throws {
        let context = makeOTGTestContext()
        let uploader = MockTravelAlbumOTGUploader()
        let viewModel = makeWiredViewModel(
            context: context,
            manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
            uploader: uploader
        )
        let records = [
            try makePersistedOTGRecord(context: context, id: "pending", capturedAt: "2026-07-08 12:05:00", status: .pending),
            try makePersistedOTGRecord(context: context, id: "failed", capturedAt: "2026-07-08 12:06:00", status: .failed),
            try makePersistedOTGRecord(context: context, id: "uploaded", capturedAt: "2026-07-08 12:07:00", status: .uploaded),
        ]
        context.store.save(records, albumId: 9)

        viewModel.reloadLocalPhotos()
        viewModel.onSpecifyUploadOptionSelected(.allPending)
        await waitUntil { uploader.uploadCallCount == 2 }

        XCTAssertEqual(uploader.uploadedRecordIds.sorted(), ["failed", "pending"])
    }

    func testSwitchingToLiveUploadOnlyUploadsNewImports() async throws {
        let context = makeOTGTestContext()
        let uploader = MockTravelAlbumOTGUploader()
        let viewModel = makeWiredViewModel(
            context: context,
            manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
            uploader: uploader
        )
        let oldRecords = [
            try makePersistedOTGRecord(context: context, id: "old-a", capturedAt: "2026-07-08 12:05:00", status: .pending),
            try makePersistedOTGRecord(context: context, id: "old-b", capturedAt: "2026-07-08 12:06:00", status: .pending),
        ]
        context.store.save(oldRecords, albumId: 9)

        viewModel.selectTransferMode("拍后传输")
        viewModel.handleNewlyImportedPhotoIds(oldRecords.map(\.id))

        let newRecords = [
            try makePersistedOTGRecord(context: context, id: "new-c", capturedAt: "2026-07-08 12:10:00", status: .pending),
            try makePersistedOTGRecord(context: context, id: "new-d", capturedAt: "2026-07-08 12:11:00", status: .pending),
        ]
        context.store.save(oldRecords + newRecords, albumId: 9)

        viewModel.selectTransferMode("边拍边传")
        viewModel.handleNewlyImportedPhotoIds(newRecords.map(\.id))
        await waitUntil { uploader.uploadCallCount == 2 }

        let recordsById = Dictionary(uniqueKeysWithValues: context.store.load(albumId: 9).map { ($0.id, $0) })
        XCTAssertEqual(uploader.uploadedRecordIds.sorted(), ["new-c", "new-d"])
        XCTAssertEqual(recordsById["old-a"]?.status, .pending)
        XCTAssertEqual(recordsById["old-b"]?.status, .pending)
        XCTAssertEqual(recordsById["new-c"]?.status, .uploaded)
        XCTAssertEqual(recordsById["new-d"]?.status, .uploaded)
    }

    func testPhoneAlbumImportWritesLocalRecordsAndAutoUploadsInLiveMode() async {
        let context = makeOTGTestContext()
        let uploader = MockTravelAlbumOTGUploader()
        let viewModel = makeWiredViewModel(
            context: context,
            manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
            uploader: uploader
        )

        let result = viewModel.importPhoneAlbumImages([
            TravelAlbumPhoneAlbumImportItem(data: Data([1, 2, 3]), fileName: "album.jpg"),
            TravelAlbumPhoneAlbumImportItem(data: Data([4, 5, 6]), fileName: "album.jpg"),
        ])
        await waitUntil { uploader.uploadCallCount == 2 }

        let records = context.store.load(albumId: 9)
        XCTAssertEqual(result.importedCount, 2)
        XCTAssertEqual(result.failedCount, 0)
        XCTAssertEqual(Set(result.importedPhotoIds), Set(records.map(\.id)))
        XCTAssertEqual(Set(records.map(\.status)), [.uploaded])
        XCTAssertEqual(Set(records.map(\.fileName)).count, 2)
        XCTAssertEqual(Set(records.map(\.clientPhotoId)).count, 2)
        XCTAssertTrue(records.allSatisfy { UUID(uuidString: $0.clientPhotoId) != nil })
    }

    func testStartSynchronizesPersistedUploadStatusesFromServerClientPhotoIds() async {
        let context = makeOTGTestContext()
        let api = TravelAlbumMockAPI()
        api.materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(
            clientPhotoIds: ["server-id"]
        )
        let records = [
            makeOTGRecord(id: "matched", capturedAt: "2026-07-16 12:00:00", status: .failed)
                .withClientPhotoId("server-id"),
            makeOTGRecord(id: "missing", capturedAt: "2026-07-16 12:01:00", status: .uploaded)
                .withClientPhotoId("missing-id"),
        ]
        context.store.save(records, albumId: 9)
        let viewModel = makeWiredViewModel(
            context: context,
            manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
            api: api
        )

        viewModel.start()
        await waitUntil {
            let byId = Dictionary(uniqueKeysWithValues: context.store.load(albumId: 9).map { ($0.id, $0) })
            return byId["matched"]?.status == .uploaded && byId["missing"]?.status == .pending
        }
        viewModel.stop()

        XCTAssertEqual(api.materialClientPhotoIdsCallCount, 1)
    }
}

/// 旅拍相册相机历史导入 ViewModel 测试。
@MainActor
final class TravelAlbumCameraImportViewModelTests: XCTestCase {
    func testLoadPhotosListsCameraObjectsAndGroupsByDay() async {
        let context = makeOTGTestContext()
        let calendar = Calendar(identifier: .gregorian)
        let first = calendar.date(from: DateComponents(year: 2026, month: 7, day: 8, hour: 10))!
        let second = calendar.date(from: DateComponents(year: 2026, month: 7, day: 7, hour: 10))!
        let driver = MockCameraDriver(objects: [
            CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: first),
            CameraObject(id: "B|10", filename: "B.JPG", fileSize: 10, capturedAt: second),
        ])
        let viewModel = makeImportViewModel(context: context, driver: driver)

        viewModel.loadPhotos()
        await waitUntil {
            if case .loaded(let sections) = viewModel.state {
                return sections.count == 2
            }
            return false
        }

        XCTAssertEqual(driver.listCallCount, 1)
        if case .loaded(let sections) = viewModel.state {
            XCTAssertEqual(sections.first?.photos.first?.id, "A|10")
        } else {
            XCTFail("Expected loaded state")
        }
    }

    func testAlreadyImportedPhotoCannotBeSelected() async throws {
        let context = makeOTGTestContext()
        let object = CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date())
        let localFile = try context.store.uniqueOriginalFileURL(filename: "A.JPG", albumId: 9)
        try Data([1]).write(to: localFile)
        context.store.save([
            TravelAlbumOTGPhotoRecord(
                id: "local-id",
                sourceId: object.id,
                fileName: "A.JPG",
                localPath: localFile.path,
                capturedAt: TravelAlbumOTGPhotoStore.transferTimeText(object.capturedAt),
                fileSizeBytes: 1,
                status: .pending,
                albumId: 9,
                userId: "u1"
            )
        ], albumId: 9)
        let viewModel = makeImportViewModel(context: context, driver: MockCameraDriver(objects: [object]))

        viewModel.loadPhotos()
        await waitUntil {
            if case .loaded = viewModel.state { return true }
            return false
        }
        viewModel.togglePhoto(id: object.id)

        XCTAssertTrue(viewModel.isPhotoAlreadyInAlbum(id: object.id))
        XCTAssertTrue(viewModel.selectedPhotoIds.isEmpty)
    }

    func testSelectionAndSectionToggleStates() async {
        let context = makeOTGTestContext()
        let photos = [
            CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date()),
            CameraObject(id: "B|10", filename: "B.JPG", fileSize: 10, capturedAt: Date()),
        ]
        let viewModel = makeImportViewModel(context: context, driver: MockCameraDriver(objects: photos))

        viewModel.loadPhotos()
        await waitUntil {
            if case .loaded = viewModel.state { return true }
            return false
        }
        guard case .loaded(let sections) = viewModel.state, let section = sections.first else {
            return XCTFail("Expected loaded state")
        }

        viewModel.togglePhoto(id: photos[0].id)
        XCTAssertTrue(viewModel.isSectionPartiallySelected(section))
        XCTAssertFalse(viewModel.isSectionFullySelected(section))

        viewModel.toggleSection(section)
        XCTAssertTrue(viewModel.isSectionFullySelected(section))

        viewModel.toggleSection(section)
        XCTAssertTrue(viewModel.selectedPhotoIds.isEmpty)
    }

    func testImportSelectedDownloadsOnlySelectedAndWritesPendingRecords() async {
        let context = makeOTGTestContext()
        let selected = CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date())
        let unselected = CameraObject(id: "B|10", filename: "B.JPG", fileSize: 10, capturedAt: Date())
        let driver = MockCameraDriver(objects: [selected, unselected])
        let viewModel = makeImportViewModel(context: context, driver: driver)

        viewModel.loadPhotos()
        await waitUntil {
            if case .loaded = viewModel.state { return true }
            return false
        }
        viewModel.togglePhoto(id: selected.id)
        viewModel.importSelected()
        await waitUntil {
            if case .finished = viewModel.state { return true }
            return false
        }

        XCTAssertEqual(driver.downloadedObjectIds, [selected.id])
        let records = context.store.load(albumId: 9)
        XCTAssertEqual(records.count, 1)
        XCTAssertEqual(records.first?.sourceId, selected.id)
        XCTAssertEqual(records.first?.status, .pending)
        if case .finished(_, _, let importedPhotoIds) = viewModel.state {
            XCTAssertEqual(importedPhotoIds, records.map(\.id))
        } else {
            XCTFail("Expected finished state")
        }
    }

    func testImportSelectedContinuesAfterSingleFailure() async {
        let context = makeOTGTestContext()
        let failed = CameraObject(id: "F|10", filename: "F.JPG", fileSize: 10, capturedAt: Date())
        let success = CameraObject(id: "S|10", filename: "S.JPG", fileSize: 10, capturedAt: Date())
        let driver = MockCameraDriver(objects: [failed, success], failingObjectIds: [failed.id])
        let viewModel = makeImportViewModel(context: context, driver: driver)

        viewModel.loadPhotos()
        await waitUntil {
            if case .loaded(let sections) = viewModel.state {
                sections.first?.photos.count == 2
            } else {
                false
            }
        }
        guard case .loaded(let sections) = viewModel.state, let section = sections.first else {
            return XCTFail("Expected loaded state")
        }
        viewModel.toggleSection(section)
        viewModel.importSelected()
        await waitUntil {
            if case .finished = viewModel.state { return true }
            return false
        }

        XCTAssertEqual(Set(driver.downloadedObjectIds), [failed.id, success.id])
        XCTAssertTrue(viewModel.failedPhotoIds.contains(failed.id))
        XCTAssertEqual(context.store.load(albumId: 9).first?.sourceId, success.id)
        if case .finished(let importedCount, let failedCount, let importedPhotoIds) = viewModel.state {
            XCTAssertEqual(importedCount, 1)
            XCTAssertEqual(failedCount, 1)
            XCTAssertEqual(importedPhotoIds, context.store.load(albumId: 9).map(\.id))
        } else {
            XCTFail("Expected finished state")
        }
    }

    func testSonyEmptyCatalogShowsMTPHelp() async {
        let context = makeOTGTestContext()
        let driver = MockCameraDriver(platform: .sony, objects: [])
        let viewModel = makeImportViewModel(context: context, driver: driver)

        viewModel.loadPhotos()
        await waitUntil {
            if case .failed = viewModel.state { return true }
            return false
        }

        XCTAssertTrue(viewModel.shouldShowSonyMTPHelp)
    }

    private func makeImportViewModel(
        context: OTGTestContext,
        driver: MockCameraDriver
    ) -> TravelAlbumCameraImportViewModel {
        prepareAppStoreForOTGTests()
        return TravelAlbumCameraImportViewModel(
            albumId: 9,
            albumTitle: "相册",
            driver: driver,
            storage: context.store
        )
    }
}

/// OTG 测试存储上下文。
private struct OTGTestContext {
    let root: URL
    let store: TravelAlbumOTGPhotoStore
}

@MainActor
private func makeWiredViewModel(
    albumId: Int = 9,
    context: OTGTestContext,
    manager: MockWiredCameraConnectionManager,
    uploader: MockTravelAlbumOTGUploader? = nil,
    api: TravelAlbumMockAPI? = nil,
    userDefaults: UserDefaults = makeOTGTestDefaults()
) -> WiredCameraTransferViewModel {
    prepareAppStoreForOTGTests()
    return WiredCameraTransferViewModel(
        albumId: albumId,
        albumTitle: "相册",
        headerPhone: "13800138000",
        connectionManager: manager,
        storage: context.store,
        uploader: uploader ?? MockTravelAlbumOTGUploader(),
        api: api ?? TravelAlbumMockAPI(),
        userDefaults: userDefaults
    )
}

private extension TravelAlbumOTGPhotoRecord {
    func withClientPhotoId(_ value: String) -> TravelAlbumOTGPhotoRecord {
        var copy = self
        copy.clientPhotoId = value
        return copy
    }
}

private func makeOTGPhotoItem(
    id: String,
    capturedAt: String,
    status: TravelAlbumOTGUploadStatus = .pending
) -> TravelAlbumOTGPhotoItem {
    TravelAlbumOTGPhotoItem(
        id: id,
        sourceId: id,
        fileName: "\(id).JPG",
        thumbnailURL: nil,
        capturedAt: capturedAt,
        fileSizeText: "1 KB",
        fileSizeBytes: 1024,
        status: status,
        progress: 0,
        errorMessage: nil,
        localPath: "originals/\(id).JPG",
        remoteUrl: ""
    )
}

private func makeOTGRecord(
    id: String,
    capturedAt: String,
    status: TravelAlbumOTGUploadStatus
) -> TravelAlbumOTGPhotoRecord {
    TravelAlbumOTGPhotoRecord(
        id: id,
        fileName: "\(id).JPG",
        localPath: "originals/\(id).JPG",
        capturedAt: capturedAt,
        fileSizeBytes: 1024,
        status: status,
        albumId: 9,
        userId: "u1"
    )
}

private func makePersistedOTGRecord(
    context: OTGTestContext,
    id: String,
    capturedAt: String,
    status: TravelAlbumOTGUploadStatus
) throws -> TravelAlbumOTGPhotoRecord {
    let relativePath = "originals/\(id).JPG"
    let directory = try context.store.originalsDirectory(albumId: 9)
    let url = directory.appendingPathComponent("\(id).JPG")
    try Data([0xFF, 0xD8, 0xFF]).write(to: url)
    return TravelAlbumOTGPhotoRecord(
        id: id,
        fileName: "\(id).JPG",
        localPath: relativePath,
        capturedAt: capturedAt,
        fileSizeBytes: 3,
        status: status,
        albumId: 9,
        userId: "u1"
    )
}

@MainActor
private func makeOTGTestContext() -> OTGTestContext {
    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
    let store = TravelAlbumOTGPhotoStore(
        context: TravelAlbumOTGStorageContext(
            accountCachePrefix: "u1_photog",
            userId: "u1",
            scenicId: 11,
            storeId: 22
        ),
        applicationSupportDirectory: root.appendingPathComponent("ApplicationSupport", isDirectory: true),
        cachesDirectory: root.appendingPathComponent("Caches", isDirectory: true)
    )
    return OTGTestContext(root: root, store: store)
}

private func makeOTGTestDefaults() -> UserDefaults {
    let suiteName = "TravelAlbumOTGTests.\(UUID().uuidString)"
    let defaults = UserDefaults(suiteName: suiteName)!
    defaults.removePersistentDomain(forName: suiteName)
    return defaults
}

@MainActor
private func prepareAppStoreForOTGTests() {
    AppStore.shared.session.userId = "u1"
    AppStore.shared.session.accountType = .unknown("photog")
    AppStore.shared.session.currentScenicId = 11
    AppStore.shared.session.currentStoreId = 22
}

private func waitUntil(
    timeout: TimeInterval = 1.0,
    condition: @escaping @MainActor () -> Bool
) async {
    let deadline = Date().addingTimeInterval(timeout)
    while Date() < deadline {
        if await condition() { return }
        try? await Task.sleep(nanoseconds: 20_000_000)
    }
}

/// 有线相机连接管理测试替身。
@MainActor
private final class MockWiredCameraConnectionManager: WiredCameraConnectionManaging {
    weak var delegate: ConnectionManagerDelegate?
    var state: ConnectionState
    var currentDriver: CameraDriver?
    var isContentCatalogReady: Bool
    private(set) var suspendLiveTransferCallCount = 0

    init(
        state: ConnectionState = .connected(platform: .canon, deviceName: "Canon"),
        driver: CameraDriver?,
        isContentCatalogReady: Bool = true
    ) {
        self.state = state
        self.currentDriver = driver
        self.isContentCatalogReady = isContentCatalogReady
    }

    func configureLiveTransfer(albumID: Int?) {}
    func start() {}
    func unbindDelegate() {}
    func suspendLiveTransfer() {
        suspendLiveTransferCallCount += 1
    }
    func disconnect() {
        state = .disconnected
    }
}

/// 相机 Driver 测试替身。
private final class MockCameraDriver: CameraDriver {
    let platform: CameraPlatform
    let deviceInfo: CameraDeviceInfo
    private let objects: [CameraObject]
    private let failingObjectIds: Set<String>
    private(set) var listCallCount = 0
    private(set) var downloadedObjectIds: [String] = []

    init(
        platform: CameraPlatform = .canon,
        objects: [CameraObject],
        failingObjectIds: Set<String> = []
    ) {
        self.platform = platform
        self.deviceInfo = CameraDeviceInfo(
            name: "Canon",
            manufacturer: "Canon",
            model: "Mock",
            serialNumber: nil,
            usbVendorID: 0x04A9,
            usbProductID: 1
        )
        self.objects = objects
        self.failingObjectIds = failingObjectIds
    }

    func connect() async throws {}
    func disconnect() {}

    func listObjects() async throws -> [CameraObject] {
        listCallCount += 1
        return objects
    }

    func requestThumbnailData(for object: CameraObject, maxPixelSize: Int) async -> Data? {
        nil
    }

    func downloadObject(_ object: CameraObject, to directory: URL) async throws -> URL {
        downloadedObjectIds.append(object.id)
        if failingObjectIds.contains(object.id) {
            throw CameraError.connectionFailed("下载失败")
        }
        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
        let url = directory.appendingPathComponent(object.filename)
        try Data([1, 2, 3]).write(to: url, options: .atomic)
        return url
    }
}

/// OTG 上传测试替身。
@MainActor
private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
    private(set) var uploadCallCount = 0
    private(set) var uploadedRecordIds: [String] = []

    func upload(
        record: TravelAlbumOTGPhotoRecord,
        scenicId: Int,
        progress: @escaping (Int) -> Void
    ) async throws -> TravelAlbumMaterial {
        uploadCallCount += 1
        uploadedRecordIds.append(record.id)
        return TravelAlbumMaterial(id: 1, fileName: record.fileName, fileUrl: "https://cdn.example.com/\(record.fileName)")
    }
}

/// 旅拍相册 API 测试替身。
@MainActor
final class TravelAlbumMockAPI: TravelAlbumServing {
    struct MaterialRequest: Equatable {
        let userEquityTravelId: Int
        let page: Int
        let pageSize: Int
        let orderBy: Int
        let isPurchased: Int?
    }

    var availableOrdersResponse: [TravelAlbumAvailableOrder] = []
    var listResponse = TravelAlbumListResponse<TravelAlbum>()
    var createResponse = TravelAlbumCreateResponse(id: 0)
    var infoResponse = TravelAlbum()
    var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = []
    var uploadMaterialResponse = TravelAlbumMaterial()
    var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: [])
    var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "")
    var createError: Error?

    private(set) var availableOrdersCallCount = 0
    private(set) var createRequests: [TravelAlbumCreateRequest] = []
    private(set) var materialRequests: [MaterialRequest] = []
    private(set) var uploadMaterialRequests: [TravelAlbumUploadMaterialRequest] = []
    private(set) var materialClientPhotoIdsCallCount = 0
    private(set) var deletedAlbumIds: [Int] = []
    private(set) var deletedMaterialIds: [Int] = []

    func availableOrders() async throws -> [TravelAlbumAvailableOrder] {
        availableOrdersCallCount += 1
        return availableOrdersResponse
    }

    func create(_ request: TravelAlbumCreateRequest) async throws -> TravelAlbumCreateResponse {
        createRequests.append(request)
        if let createError { throw createError }
        return createResponse
    }

    func list(page: Int, pageSize: Int) async throws -> TravelAlbumListResponse<TravelAlbum> {
        listResponse
    }

    func info(id: Int) async throws -> TravelAlbum {
        infoResponse
    }

    func materialList(
        userEquityTravelId: Int,
        page: Int,
        pageSize: Int,
        orderBy: Int,
        isPurchased: Int?
    ) async throws -> TravelAlbumListResponse<TravelAlbumMaterial> {
        materialRequests.append(
            MaterialRequest(
                userEquityTravelId: userEquityTravelId,
                page: page,
                pageSize: pageSize,
                orderBy: orderBy,
                isPurchased: isPurchased
            )
        )
        if materialListResponses.isEmpty { return TravelAlbumListResponse() }
        return materialListResponses.removeFirst()
    }

    func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
        uploadMaterialRequests.append(request)
        return uploadMaterialResponse
    }

    func materialClientPhotoIds(userEquityTravelId: Int) async throws -> TravelAlbumMaterialClientPhotoIDsResponse {
        materialClientPhotoIdsCallCount += 1
        return materialClientPhotoIdsResponse
    }

    func deleteAlbum(id: Int) async throws {
        deletedAlbumIds.append(id)
    }

    func deleteMaterial(id: Int) async throws {
        deletedMaterialIds.append(id)
    }

    func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse {
        mpCodeResponse
    }
}
