//
//  TravelAlbumViewModels.swift
//  suixinkan
//
//  Created by Codex on 2026/6/29.
//

import Foundation
import Combine
import UIKit

/// 旅拍相册入口 ViewModel，负责列表、创建任务与小程序码。
@MainActor
final class TravelAlbumEntryViewModel: ObservableObject {
    static let modePreShoot = 1
    static let modePreOrder = 2
    static let apiTypePreShoot = 1
    static let apiTypePreOrder = 2

    @Published var albums: [TravelAlbumItem] = []
    @Published var total = 0
    @Published var isLoading = false
    @Published var isCreating = false
    @Published var showCreateSheet = false
    @Published var bindableOrders: [TravelAlbumAvailableOrder] = []
    @Published var codeSheet: TravelAlbumCodeSheetState?
    @Published var errorMessage: String?

    /// 加载相册列表。
    func loadAlbums(api: any TravelAlbumServing) async {
        isLoading = true
        defer { isLoading = false }
        do {
            let payload = try await api.albumList(page: 1, pageSize: 20)
            albums = payload.list
            total = payload.total
            errorMessage = nil
        } catch {
            errorMessage = error.localizedDescription
        }
    }

    /// 打开创建弹窗并加载可绑定订单。
    func openCreateSheet(api: any TravelAlbumServing, scenicSelected: Bool) async {
        guard !isCreating else { return }
        guard scenicSelected else {
            errorMessage = "请先选择景区"
            return
        }
        showCreateSheet = true
        await loadBindableOrders(api: api)
    }

    /// 加载可绑定订单。
    func loadBindableOrders(api: any TravelAlbumServing) async {
        do {
            bindableOrders = try await api.availableOrders()
        } catch {
            errorMessage = error.localizedDescription
        }
    }

    /// 确认创建旅拍相册任务。
    func confirmCreateTask(
        api: any TravelAlbumServing,
        mode: Int,
        freeCount: String,
        singlePrice: String,
        packagePrice: String,
        order: TravelAlbumAvailableOrder?,
        scenicSelected: Bool
    ) async -> Bool {
        guard !isCreating else { return false }
        guard scenicSelected else {
            errorMessage = "请先选择景区"
            return false
        }

        let apiType = mode == Self.modePreOrder ? Self.apiTypePreOrder : Self.apiTypePreShoot
        let albumName: String
        if mode == Self.modePreOrder {
            albumName = order?.projectName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
                ? order!.projectName
                : Self.buildTodayAlbumName(existingNames: albums.map(\.name))
        } else {
            albumName = Self.buildTodayAlbumName(existingNames: albums.map(\.name))
        }

        if mode == Self.modePreOrder, order == nil {
            errorMessage = "请选择绑定订单"
            return false
        }
        if mode == Self.modePreShoot, singlePrice.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
            errorMessage = "请输入单张照片价格"
            return false
        }

        let materialPrice = Double(singlePrice.trimmingCharacters(in: .whitespacesAndNewlines))
        if mode == Self.modePreShoot, materialPrice == nil {
            errorMessage = "请输入有效的单张照片价格"
            return false
        }

        let request: TravelAlbumCreateRequest
        if apiType == Self.apiTypePreShoot {
            request = TravelAlbumCreateRequest(
                name: albumName,
                type: apiType,
                orderNumber: nil,
                materialNum: Int(freeCount) ?? 0,
                materialPrice: materialPrice,
                materialPackagePrice: Double(packagePrice) ?? 0,
                photoPrice: 0
            )
        } else {
            request = TravelAlbumCreateRequest(
                name: albumName,
                type: apiType,
                orderNumber: order?.orderNumber,
                materialNum: nil,
                materialPrice: nil,
                materialPackagePrice: nil,
                photoPrice: nil
            )
        }

        isCreating = true
        defer { isCreating = false }

        do {
            _ = try await api.createAlbum(request)
            showCreateSheet = false
            await loadAlbums(api: api)
            errorMessage = nil
            return true
        } catch {
            let message = error.localizedDescription
            if message.contains("订单已关联") {
                await loadBindableOrders(api: api)
            }
            errorMessage = message.isEmpty ? "创建失败" : message
            return false
        }
    }

    /// 打开小程序码弹窗。
    func openAlbumCode(api: any TravelAlbumServing, album: TravelAlbumItem) async {
        codeSheet = TravelAlbumCodeSheetState(albumID: album.id, albumName: album.name, isLoading: true, qrImageURL: "")
        do {
            let response = try await api.mpCode(id: album.id)
            codeSheet?.qrImageURL = response.mpCodeOssURL.trimmingCharacters(in: .whitespacesAndNewlines)
            codeSheet?.isLoading = false
        } catch {
            codeSheet?.isLoading = false
            errorMessage = error.localizedDescription
        }
    }

    /// 生成当日相册名称后缀。
    static func buildTodayAlbumName(existingNames: [String]) -> String {
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy-MM-dd"
        let prefix = formatter.string(from: Date())
        let todayNames = existingNames.filter { $0.hasPrefix(prefix) }
        let nextIndex = todayNames.count + 1
        return String(format: "%@-%03d", prefix, nextIndex)
    }
}

/// 小程序码弹窗状态。
struct TravelAlbumCodeSheetState: Identifiable {
    let albumID: Int
    let albumName: String
    var isLoading: Bool
    var qrImageURL: String

    var id: Int { albumID }
}

/// 旅拍相册详情 ViewModel，对齐 Android TravelAlbumDetailViewModel。
@MainActor
final class TravelAlbumDetailViewModel: ObservableObject {
    static let tabAll = 0
    static let tabPurchased = 1
    static let materialStatusUnpurchased = 1

    static let sortOptions: [(value: Int, label: String)] = [
        (1, "创建时间正序"),
        (2, "创建时间倒序"),
        (3, "文件名正序"),
        (4, "文件名倒序")
    ]

    @Published var album: TravelAlbumItem?
    @Published var materials: [TravelAlbumMaterial] = []
    @Published var allPhotoCount = 0
    @Published var selectedTab = tabAll
    @Published var orderBy = 2
    @Published var isSelectionMode = false
    @Published var selectedMaterialIDs: Set<Int> = []
    @Published var isLoading = false
    @Published var isDeleting = false
    @Published var showDeleteAlbumConfirm = false
    @Published var showDeleteMaterialConfirm = false
    @Published var errorMessage: String?

    private var albumID = 0
    private var currentPage = 1
    private var canLoadMore = false
    private var isLoadingMore = false

    private static let pageSize = 30
    private static let loadMoreThreshold = 6
    private static let purchasedFilterYes = 1

    /// 刷新相册详情、总数与素材列表。
    func refreshAll(api: any TravelAlbumServing, albumID: Int) async {
        self.albumID = albumID
        async let infoTask: Void = loadAlbumInfo(api: api, albumID: albumID, showPageLoading: album == nil)
        async let countTask: Void = loadAllPhotoCount(api: api, albumID: albumID)
        async let materialsTask: Void = loadMaterials(api: api, albumID: albumID, reset: true)
        _ = await (infoTask, countTask, materialsTask)
    }

    /// 加载相册详情与素材（兼容旧调用）。
    func load(api: any TravelAlbumServing, albumID: Int) async {
        await refreshAll(api: api, albumID: albumID)
    }

    /// 切换 Tab 并重载素材。
    func selectTab(_ tab: Int, api: any TravelAlbumServing) async {
        guard selectedTab != tab else { return }
        selectedTab = tab
        isSelectionMode = false
        selectedMaterialIDs = []
        await loadMaterials(api: api, albumID: albumID, reset: true)
    }

    /// 设置排序并重载素材。
    func setOrderBy(_ value: Int, api: any TravelAlbumServing) async {
        guard orderBy != value else { return }
        orderBy = value
        await loadMaterials(api: api, albumID: albumID, reset: true)
    }

    /// 切换批量选择模式（仅全部照片 Tab）。
    func toggleSelectionMode() {
        guard selectedTab == Self.tabAll else { return }
        isSelectionMode.toggle()
        if !isSelectionMode {
            selectedMaterialIDs = []
        }
    }

    /// 切换素材选中状态。
    func toggleMaterialSelection(_ material: TravelAlbumMaterial) {
        guard isSelectionMode else { return }
        guard material.status == Self.materialStatusUnpurchased else {
            errorMessage = "仅未购买素材可删除"
            return
        }
        if selectedMaterialIDs.contains(material.id) {
            selectedMaterialIDs.remove(material.id)
        } else {
            selectedMaterialIDs.insert(material.id)
        }
    }

    /// 滚动接近底部时加载更多。
    func loadMoreIfNeeded(lastVisibleIndex: Int, api: any TravelAlbumServing) async {
        guard lastVisibleIndex >= materials.count - Self.loadMoreThreshold else { return }
        await loadMaterials(api: api, albumID: albumID, reset: false)
    }

    /// 请求删除选中素材确认。
    func requestDeleteSelectedMaterials() {
        guard !selectedMaterialIDs.isEmpty else {
            errorMessage = "请选择要删除的素材"
            return
        }
        showDeleteMaterialConfirm = true
    }

    /// 确认删除选中素材。
    @discardableResult
    func confirmDeleteSelectedMaterials(api: any TravelAlbumServing) async -> Bool {
        let ids = Array(selectedMaterialIDs)
        guard !ids.isEmpty else {
            showDeleteMaterialConfirm = false
            return false
        }
        showDeleteMaterialConfirm = false
        isDeleting = true
        defer { isDeleting = false }

        for id in ids {
            do {
                try await api.deleteMaterial(TravelAlbumDeleteMaterialRequest(id: id))
            } catch {
                errorMessage = error.localizedDescription
                return false
            }
        }
        isSelectionMode = false
        selectedMaterialIDs = []
        await refreshAll(api: api, albumID: albumID)
        return true
    }

    /// 构建有线传图上下文。
    func wiredTransferContext() -> WiredTransferContext? {
        guard let album else { return nil }
        return WiredTransferContext(
            albumId: album.id,
            albumName: album.name,
            phone: album.displayPhone,
            orderNumber: album.orderNumber
        )
    }

    /// 删除相册。
    func deleteAlbum(api: any TravelAlbumServing, albumID: Int, photoStore: WiredTransferPhotoStore) async -> Bool {
        isDeleting = true
        defer { isDeleting = false }
        do {
            try await api.deleteAlbum(TravelAlbumDeleteRequest(id: albumID))
            _ = photoStore.clearAlbum(albumID: albumID)
            return true
        } catch {
            errorMessage = error.localizedDescription
            return false
        }
    }

    private func loadAlbumInfo(api: any TravelAlbumServing, albumID: Int, showPageLoading: Bool) async {
        if showPageLoading {
            isLoading = true
        }
        defer {
            if showPageLoading {
                isLoading = false
            }
        }
        do {
            album = try await api.albumInfo(id: albumID)
            errorMessage = nil
        } catch {
            errorMessage = error.localizedDescription
        }
    }

    private func loadAllPhotoCount(api: any TravelAlbumServing, albumID: Int) async {
        do {
            let payload = try await api.materialList(
                userEquityTravelId: albumID,
                page: 1,
                pageSize: 1,
                orderBy: orderBy,
                isPurchased: nil
            )
            allPhotoCount = payload.total
        } catch {
            errorMessage = error.localizedDescription
        }
    }

    private func loadMaterials(api: any TravelAlbumServing, albumID: Int, reset: Bool) async {
        if reset {
            currentPage = 1
            canLoadMore = false
        } else {
            guard canLoadMore, !isLoadingMore else { return }
            isLoadingMore = true
            currentPage += 1
        }

        let isPurchasedFilter: Int? = selectedTab == Self.tabPurchased ? Self.purchasedFilterYes : nil

        do {
            let payload = try await api.materialList(
                userEquityTravelId: albumID,
                page: currentPage,
                pageSize: Self.pageSize,
                orderBy: orderBy,
                isPurchased: isPurchasedFilter
            )
            if reset {
                materials = payload.list
            } else {
                materials.append(contentsOf: payload.list)
            }
            canLoadMore = materials.count < payload.total
            if selectedTab == Self.tabAll {
                allPhotoCount = payload.total
            }
            errorMessage = nil
        } catch {
            if !reset, currentPage > 1 {
                currentPage -= 1
            }
            errorMessage = error.localizedDescription
        }

        if !reset {
            isLoadingMore = false
        }
    }
}

/// 有线传图 ViewModel，编排相机连接、传输管道与本地持久化。
@MainActor
final class WiredCameraTransferViewModel: ObservableObject {
    static let modeLiveCapture = "边拍边传"
    static let modePostShootTransfer = "拍后传输"
    /// 当前仅支持 JPG，后续再扩展 RAW 等格式。
    static let formatJPG = "JPG"
    static let specifyUploadAllPending = "上传所有未上传的照片"
    static let specifyUploadTodayCaptured = "上传所有今日拍摄的照片"
    static let helpDocURL = URL(string: "https://sharesky.feishu.cn/wiki/CaAhwl3Gnin4Kqk0BVocK3SGnab?from=from_copylink")!
    private static let manualUploadConcurrency = 3

    @Published private(set) var photos: [WiredTransferPhotoItem] = []
    @Published private(set) var visiblePhotos: [WiredTransferPhotoItem] = []
    @Published private(set) var sidebarGroups: [WiredTransferDateGroup] = []
    @Published private(set) var photoSections: [WiredTransferPhotoSection] = []
    @Published private(set) var tabCounts: [Int] = [0, 0, 0]
    @Published var selectedTabIndex = 0
    @Published var sidebarExpanded = true
    @Published var selectedTimeSlotID: String?
    @Published var selectUploadMode = false
    @Published var selectedPhotoIDs: Set<String> = []
    @Published var transferModeOption = modeLiveCapture
    @Published var showSpecifyUploadSheet = false
    @Published var showDeviceStorageAlert = false
    @Published var showCameraDisconnectedAlert = false
    @Published var showHelpDoc = false
    @Published var isRefreshingCameraFiles = false
    @Published var isSearchingCamera = false
    @Published var deviceStorageInfo = DeviceStorageHelper.readStorageSnapshot()
    @Published var connectionState: CameraConnectionState = .disconnected
    @Published var errorMessage: String?

    let context: WiredTransferContext
    private let pipeline: CameraTransferPipeline
    private var photoStore: WiredTransferPhotoStore?
    private var userIDProvider: (() -> String)?
    private var downloadScope: CameraDownloadStorage.Scope?
    private var sessionNewPhotoIDs: Set<String> = []
    private var persistedRecordsByID: [String: WiredTransferPhotoRecord] = [:]
    private var lastAppliedPipelineSignatures: [String: PipelineTaskSignature] = [:]
    private var lastProgressPersistDates: [String: Date] = [:]
    private var deletedPhotoIDs: Set<String> = []
    private var wasConnected = false
    private var disconnectAlertTask: Task<Void, Never>?
    private var isTearingDownCamera = false
    /// 用户主动离开页面时为 true，避免误弹「相机已断开」警告。
    private var isLeavingPage = false

    /// 初始化有线传图 ViewModel。
    init(
        context: WiredTransferContext,
        cameraService: any CameraServiceProtocol
    ) {
        self.context = context
        self.pipeline = CameraTransferPipeline(cameraService: cameraService)
    }

    /// 配置上传 Sink 并启动相机连接。
    func start(
        api: any TravelAlbumServing,
        ossService: any OSSUploadServing,
        scenicID: Int,
        accountContext: AccountContext
    ) async {
        isLeavingPage = false
        pipeline.attachToUI(
            onConnectionStateChange: { [weak self] state in
                self?.handleConnectionStateChange(state)
            },
            onTasksUpdated: { [weak self] tasks in
                Task { @MainActor in
                    await self?.applyPipelineTasks(tasks)
                }
            }
        )

        let accountCachePrefix = accountContext.accountCachePrefix ?? "guest_"
        let currentDownloadScope = CameraDownloadStorage.Scope(
            accountKey: accountCachePrefix,
            albumID: context.albumId
        )
        downloadScope = currentDownloadScope

        if photoStore == nil {
            photoStore = WiredTransferPhotoStore(
                accountPrefixProvider: { accountCachePrefix },
                userIDProvider: { accountContext.profile?.userId ?? "" }
            )
            userIDProvider = { accountContext.profile?.userId ?? "" }
            if let cachedMode = photoStore?.loadTransferModeOption() {
                transferModeOption = cachedMode
            }
        }
        let uploader = TravelAlbumMaterialUploader(
            api: api,
            ossService: ossService,
            albumID: context.albumId,
            scenicID: scenicID
        )
        let autoUpload = transferModeOption == Self.modeLiveCapture
        pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload, downloadScope: currentDownloadScope)
        refreshDeviceStorageInfo()
        loadPersistedPhotos()

        do {
            await pipeline.connect()
            connectionState = pipeline.connectionState
            wasConnected = connectionState.isConnected

            while !Task.isCancelled {
                try await Task.sleep(nanoseconds: 1_000_000_000)
            }
        } catch is CancellationError {
            // 用户返回或 task 取消。
        } catch {
            errorMessage = error.localizedDescription
        }
        detachFromUI()
    }

    /// 页面离开：仅 detach UI 回调，保持相机连接。
    func stop() async {
        detachFromUI()
    }

    /// 页面离开：取消 UI 回调，不关闭相机会话。
    func detachFromUI() {
        guard !isTearingDownCamera else { return }
        isTearingDownCamera = true
        isLeavingPage = true
        defer { isTearingDownCamera = false }

        disconnectAlertTask?.cancel()
        showCameraDisconnectedAlert = false
        pipeline.detachFromUI()
    }

    /// 刷新设备存储信息。
    func refreshDeviceStorageInfo() {
        deviceStorageInfo = DeviceStorageHelper.readStorageSnapshot()
    }

    /// 切换 Tab。
    func selectTab(_ index: Int) {
        selectedTabIndex = index
        selectedTimeSlotID = nil
        refreshDerivedPhotoState(rebuildGroups: true)
        if selectUploadMode {
            syncSelectionWithVisiblePhotos()
        }
    }

    /// 折叠/展开时间侧栏。
    func toggleSidebar() {
        sidebarExpanded.toggle()
    }

    /// 选中时间段并滚动定位。
    func selectTimeSlot(_ slotID: String) {
        selectedTimeSlotID = slotID
    }

    /// 打开帮助文档。
    func openHelpDoc() {
        showHelpDoc = true
    }

    /// 点击设备存储面板。
    func onDeviceUsageClick() {
        showDeviceStorageAlert = true
    }

    /// 切换传输模式。
    func selectTransferMode(_ option: String, api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) {
        guard option == Self.modeLiveCapture || option == Self.modePostShootTransfer else { return }
        transferModeOption = option
        photoStore?.saveTransferModeOption(option)
        let autoUpload = option == Self.modeLiveCapture
        let uploader = TravelAlbumMaterialUploader(
            api: api,
            ossService: ossService,
            albumID: context.albumId,
            scenicID: scenicID
        )
        pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload, downloadScope: downloadScope)
    }

    /// 清除错误提示。
    func clearErrorMessage() {
        errorMessage = nil
    }

    /// 刷新相机文件列表；未连接时重新搜索 USB 相机。
    func refreshCameraFiles() async {
        if connectionState.isConnected {
            guard !isRefreshingCameraFiles else { return }
            isRefreshingCameraFiles = true
            defer { isRefreshingCameraFiles = false }
            await pipeline.syncExistingPhotos()
            return
        }
        await restartCameraSearch()
    }

    /// 重新搜索 USB 相机。
    func restartCameraSearch() async {
        guard !connectionState.isConnected, !isSearchingCamera else { return }
        isSearchingCamera = true
        defer { isSearchingCamera = false }
        await pipeline.restartCameraDiscovery()
        connectionState = pipeline.connectionState
    }

    /// 批量上传按钮点击（三态：进入勾选 / 上传选中 / 取消选择）。
    func onBatchUploadButtonClick(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async {
        if !selectUploadMode {
            selectUploadMode = true
            selectedPhotoIDs.removeAll()
            return
        }
        if !selectedPhotoIDs.isEmpty {
            await uploadSelected(api: api, ossService: ossService, scenicID: scenicID)
            return
        }
        exitSelectUploadMode()
    }

    /// 指定上传按钮点击。
    func onSpecifyUploadButtonClick() {
        guard !selectUploadMode else { return }
        showSpecifyUploadSheet = true
    }

    /// 关闭指定上传弹层。
    func dismissSpecifyUploadSheet() {
        showSpecifyUploadSheet = false
    }

    /// 指定上传选项确认。
    func onSpecifyUploadOptionSelected(_ option: String, api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async {
        dismissSpecifyUploadSheet()
        switch option {
        case Self.specifyUploadAllPending:
            await batchUploadAll(api: api, ossService: ossService, scenicID: scenicID)
        case Self.specifyUploadTodayCaptured:
            await uploadAllTodayCaptured(api: api, ossService: ossService, scenicID: scenicID)
        default:
            break
        }
    }

    /// 退出勾选模式。
    func exitSelectUploadMode() {
        selectUploadMode = false
        selectedPhotoIDs.removeAll()
    }

    /// 切换可见 pending 全选状态。
    func toggleSelectAllPendingInVisible() {
        let pendingIDs = visiblePhotos.selectablePendingPhotoIDs
        guard !pendingIDs.isEmpty else { return }
        if pendingIDs.isSubset(of: selectedPhotoIDs) {
            selectedPhotoIDs.subtract(pendingIDs)
        } else {
            selectedPhotoIDs.formUnion(pendingIDs)
        }
    }

    /// 预览 URL。
    func previewURL(for photoID: String) -> String {
        guard let photo = photos.first(where: { $0.id == photoID }) else { return "" }
        if !photo.previewURL.isEmpty { return photo.previewURL }
        return photo.thumbnailURL
    }

    /// 批量上传所有待上传照片。
    func batchUploadAll(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async {
        ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID)
        let pendingIDs = photos.filter { $0.isNotUploaded }.map(\.id)
        guard !pendingIDs.isEmpty else {
            errorMessage = "暂无未上传的照片"
            return
        }
        await uploadPendingPhotoIDs(pendingIDs)
    }

    /// 上传今日拍摄且未上传的照片。
    func uploadAllTodayCaptured(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async {
        ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID)
        let pendingIDs = photos.filter { $0.isCapturedToday() && $0.isNotUploaded }.map(\.id)
        guard !pendingIDs.isEmpty else {
            errorMessage = "暂无今日拍摄且未上传的照片"
            return
        }
        await uploadPendingPhotoIDs(pendingIDs)
    }

    /// 上传选中照片。
    func uploadSelected(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async {
        ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID)
        await uploadPendingPhotoIDs(Array(selectedPhotoIDs))
        exitSelectUploadMode()
    }

    /// 重试单张照片上传。
    func retryPhoto(
        id: String,
        api: any TravelAlbumServing,
        ossService: any OSSUploadServing,
        scenicID: Int
    ) async {
        guard let photo = photos.first(where: { $0.id == id }) else { return }
        guard photo.status == .failed || photo.status == .pending else {
            errorMessage = "当前状态不可重传"
            return
        }

        ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID)
        updatePhotoStatus(id: id, status: .uploading, progress: 0, errorMessage: nil)

        if pipeline.task(forAssetID: id) != nil {
            await pipeline.uploadAssets(withIDs: [id])
            await refreshPhotoFromPipelineTask(id: id)
            return
        }

        guard let record = persistedRecord(for: id),
              let localURL = CameraDownloadStorage.resolveLocalURL(from: record.localPath),
              FileManager.default.fileExists(atPath: localURL.path) else {
            updatePhotoStatus(id: id, status: .failed, progress: 0, errorMessage: "本地文件不存在")
            errorMessage = "本地文件不存在，无法重传"
            persistPhotoStatus(id: id, status: .failed, progress: 0, errorMessage: "本地文件不存在")
            return
        }

        await pipeline.retryUpload(
            assetID: id,
            filename: record.fileName,
            localPath: record.localPath
        )
        await refreshPhotoFromPipelineTask(id: id)
    }

    private func uploadPendingPhotoIDs(_ ids: [String]) async {
        let pendingIDs = ids.filter { id in
            photos.first(where: { $0.id == id })?.isNotUploaded == true
        }
        var currentIndex = 0
        while currentIndex < pendingIDs.count {
            let upperBound = min(currentIndex + Self.manualUploadConcurrency, pendingIDs.count)
            let batch = pendingIDs[currentIndex ..< upperBound]
            let uploadTasks = batch.map { id in
                Task { @MainActor in
                    await self.uploadSinglePendingPhoto(id: id)
                }
            }
            for task in uploadTasks {
                await task.value
            }
            currentIndex = upperBound
        }
    }

    private func uploadSinglePendingPhoto(id: String) async {
        updatePhotoStatus(id: id, status: .uploading, progress: 0, errorMessage: nil)

        if pipeline.task(forAssetID: id) != nil {
            await pipeline.uploadAssets(withIDs: [id])
            await refreshPhotoFromPipelineTask(id: id)
            return
        }

        guard let record = persistedRecord(for: id),
              let localURL = CameraDownloadStorage.resolveLocalURL(from: record.localPath),
              FileManager.default.fileExists(atPath: localURL.path) else {
            let message = "本地文件不存在"
            updatePhotoStatus(id: id, status: .failed, progress: 0, errorMessage: message)
            persistPhotoStatus(id: id, status: .failed, progress: 0, errorMessage: message)
            return
        }

        await pipeline.retryUpload(
            assetID: id,
            filename: record.fileName,
            localPath: record.localPath
        )
        await refreshPhotoFromPipelineTask(id: id)
    }

    /// 删除本地照片记录。
    func deletePhoto(id: String) {
        photoStore?.remove(albumID: context.albumId, photoID: id)
        deletedPhotoIDs.insert(id)
        pipeline.removeAsset(assetID: id)
        persistedRecordsByID.removeValue(forKey: id)
        lastAppliedPipelineSignatures.removeValue(forKey: id)
        lastProgressPersistDates.removeValue(forKey: id)
        sessionNewPhotoIDs.remove(id)
        setPhotos(photos.filter { $0.id != id }, rebuildGroups: true)
    }

    /// 切换照片选中状态。
    func togglePhotoSelection(id: String) {
        guard selectUploadMode else { return }
        guard let photo = photos.first(where: { $0.id == id }), photo.canSelectForSpecifyUpload else { return }
        if selectedPhotoIDs.contains(id) {
            selectedPhotoIDs.remove(id)
        } else {
            selectedPhotoIDs.insert(id)
        }
    }

    /// 同步相机历史照片。
    func syncExistingPhotos() async {
        await refreshCameraFiles()
    }

    /// 重试失败上传。
    func retryFailedUploads() async {
        await pipeline.retryFailedUploads()
    }

    private func ensureUploadEnabled(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) {
        let uploader = TravelAlbumMaterialUploader(
            api: api,
            ossService: ossService,
            albumID: context.albumId,
            scenicID: scenicID
        )
        pipeline.configure(uploadSink: uploader, uploadEnabled: true, downloadScope: downloadScope)
    }

    private func persistedRecord(for photoID: String) -> WiredTransferPhotoRecord? {
        persistedRecordsByID[photoID]
    }

    private func updatePhotoStatus(
        id: String,
        status: WiredTransferUploadStatus,
        progress: Int,
        errorMessage: String?
    ) {
        guard let index = photos.firstIndex(where: { $0.id == id }) else { return }
        let photo = photos[index]
        let updated = WiredTransferPhotoItem(
            id: photo.id,
            sourceId: photo.sourceId,
            fileName: photo.fileName,
            thumbnailURL: photo.thumbnailURL,
            previewURL: photo.previewURL,
            capturedAt: photo.capturedAt,
            fileSizeText: photo.fileSizeText,
            resolutionText: photo.resolutionText,
            status: status,
            progress: progress,
            errorMessage: errorMessage
        )
        replacePhoto(updated, rebuildGroups: photo.status != status)
    }

    private func refreshPhotoFromPipelineTask(id: String) async {
        guard let task = pipeline.task(forAssetID: id) else { return }
        await applyPipelineTasks([task], enforceAlbumBinding: false)
    }

    private func persistPhotoStatus(
        id: String,
        status: WiredTransferUploadStatus,
        progress: Int,
        errorMessage: String?
    ) {
        guard context.albumId > 0,
              let userID = userIDProvider?(), !userID.isEmpty else { return }
        guard let existing = persistedRecordsByID[id] else { return }
        let updated = WiredTransferPhotoRecord(
            id: existing.id,
            sourceId: existing.sourceId,
            fileName: existing.fileName,
            localPath: existing.localPath,
            thumbnailPath: existing.thumbnailPath,
            capturedAt: existing.capturedAt,
            fileSizeBytes: existing.fileSizeBytes,
            status: status.rawValue,
            progress: progress,
            errorMessage: errorMessage,
            albumId: existing.albumId,
            userId: existing.userId,
            remoteURL: existing.remoteURL,
            updatedAt: Date().timeIntervalSince1970
        )
        persistRecord(updated)
    }

    private func syncSelectionWithVisiblePhotos() {
        let visibleIDs = Set(visiblePhotos.map(\.id))
        selectedPhotoIDs = selectedPhotoIDs.intersection(visibleIDs)
    }

    /// 替换照片列表并刷新派生 UI 数据。
    func replacePhotos(_ newPhotos: [WiredTransferPhotoItem]) {
        setPhotos(newPhotos.sortedByCaptureTimeDescending(), rebuildGroups: true)
    }

    private func setPhotos(_ newPhotos: [WiredTransferPhotoItem], rebuildGroups: Bool) {
        photos = newPhotos
        refreshDerivedPhotoState(rebuildGroups: rebuildGroups)
    }

    private func replacePhoto(_ photo: WiredTransferPhotoItem, rebuildGroups: Bool) {
        var updatedPhotos = photos
        if let index = updatedPhotos.firstIndex(where: { $0.id == photo.id }) {
            updatedPhotos[index] = photo
        } else {
            updatedPhotos.insert(photo, at: 0)
        }
        if rebuildGroups {
            updatedPhotos = updatedPhotos.sortedByCaptureTimeDescending()
        }
        setPhotos(updatedPhotos, rebuildGroups: rebuildGroups)
    }

    private func refreshDerivedPhotoState(rebuildGroups: Bool) {
        tabCounts = photos.transferTabCounts
        let filtered = photos.filtered(byTabIndex: selectedTabIndex)
        visiblePhotos = filtered

        if rebuildGroups {
            sidebarGroups = filtered.buildSidebarGroups()
            photoSections = filtered.buildPhotoSections()
            return
        }

        let latestByID = Dictionary(uniqueKeysWithValues: filtered.map { ($0.id, $0) })
        photoSections = photoSections.map { section in
            WiredTransferPhotoSection(
                slotId: section.slotId,
                headerTitle: section.headerTitle,
                photos: section.photos.compactMap { latestByID[$0.id] }
            )
        }.filter { !$0.photos.isEmpty }
    }

    private func persistRecord(_ record: WiredTransferPhotoRecord) {
        guard let photoStore, context.albumId > 0 else { return }
        persistedRecordsByID[record.id] = record
        photoStore.save(albumID: context.albumId, records: Array(persistedRecordsByID.values))
    }

    private func handleConnectionStateChange(_ state: CameraConnectionState) {
        if wasConnected, !state.isConnected, !isLeavingPage {
            triggerCameraDisconnectedAlert()
        }
        wasConnected = state.isConnected
        connectionState = state
    }

    private func triggerCameraDisconnectedAlert() {
        disconnectAlertTask?.cancel()
        showCameraDisconnectedAlert = true
        UINotificationFeedbackGenerator().notificationOccurred(.warning)
        disconnectAlertTask = Task {
            try? await Task.sleep(nanoseconds: 5_000_000_000)
            guard !Task.isCancelled else { return }
            showCameraDisconnectedAlert = false
        }
    }

    /// 相机设备名称。
    var cameraDeviceName: String {
        if case .connected(let name) = connectionState { return name }
        return ""
    }

    /// 是否已连接相机。
    var isCameraConnected: Bool {
        connectionState.isConnected
    }

    private func loadPersistedPhotos() {
        guard context.albumId > 0, let photoStore else { return }
        deletedPhotoIDs = photoStore.loadDeletedIDs(albumID: context.albumId)
        pipeline.setExcludedAssetIDs(deletedPhotoIDs)
        let records = photoStore.load(albumID: context.albumId)
        persistedRecordsByID = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) })
        setPhotos(records.map { $0.toPhotoItem() }.sortedByCaptureTimeDescending(), rebuildGroups: true)
    }

    private func applyPipelineTasks(_ tasks: [CameraTransferTask], enforceAlbumBinding: Bool = true) async {
        guard let userIDProvider else { return }
        let userID = userIDProvider()

        // 保留已有元数据，避免上传进度回调反复重置 capturedAt 导致列表分组抖动甚至崩溃。
        let existingByID = Dictionary(uniqueKeysWithValues: photos.map { ($0.id, $0) })
        let existingByFileName = Dictionary(
            photos.map { ($0.fileName, $0) },
            uniquingKeysWith: { first, _ in first }
        )

        for task in tasks {
            guard !deletedPhotoIDs.contains(task.assetID) else { continue }
            guard !enforceAlbumBinding || belongsToCurrentAlbum(task.assetID) else { continue }

            let signature = PipelineTaskSignature(task: task)
            guard lastAppliedPipelineSignatures[task.assetID] != signature else { continue }

            let status = mapPipelineStatus(task.status)
            let existing = existingByID[task.assetID] ?? existingByFileName[task.filename]
            if shouldIgnoreStalePipelineStatus(existing: existing?.status, incoming: status) {
                lastAppliedPipelineSignatures[task.assetID] = signature
                continue
            }
            let storedLocalPath = task.localPath ?? ""
            let localURL = task.localURL
            let existingRecord = persistedRecordsByID[task.assetID]
            let fileSizeBytes: Int64
            if let existingRecord, existingRecord.fileSizeBytes > 0 {
                fileSizeBytes = existingRecord.fileSizeBytes
            } else {
                fileSizeBytes = await CameraDownloadStorage.fileSizeBytes(for: localURL)
            }
            let capturedAt = await resolveCapturedAt(
                existing: existing,
                taskCapturedAt: task.capturedAt,
                localURL: localURL
            )
            var thumbnailPath = existingRecord?.thumbnailPath ?? ""
            if thumbnailPath.isEmpty,
               let downloadScope,
               let localURL,
               status != .transferring,
               let generatedPath = await CameraThumbnailGenerator.generateThumbnail(
                   for: localURL,
                   assetID: task.assetID,
                   scope: downloadScope
               ) {
                thumbnailPath = generatedPath
            }
            let thumbnailURL = thumbnailURLString(thumbnailPath: thumbnailPath, remoteURL: task.remoteURL ?? existingRecord?.remoteURL ?? "")
            let previewURL = previewURLString(localPath: storedLocalPath, remoteURL: task.remoteURL ?? existingRecord?.remoteURL ?? "", thumbnailPath: thumbnailPath)
            let fileSizeText = fileSizeBytes > 0
                ? WiredTransferPhotoRecord.formatFileSize(fileSizeBytes)
                : (existing?.fileSizeText ?? "--")

            let item = WiredTransferPhotoItem(
                id: task.assetID,
                sourceId: existing?.sourceId ?? task.assetID,
                fileName: task.filename,
                thumbnailURL: thumbnailURL,
                previewURL: previewURL,
                capturedAt: capturedAt,
                fileSizeText: fileSizeText,
                resolutionText: existing?.resolutionText ?? "",
                status: status,
                progress: task.progress,
                errorMessage: task.errorMessage
            )
            let rebuildGroups = existing == nil
                || existing?.capturedAt != item.capturedAt
                || (selectedTabIndex != 0 && existing?.status != item.status)
            replacePhoto(item, rebuildGroups: rebuildGroups)
            sessionNewPhotoIDs.insert(task.assetID)

            guard !userID.isEmpty else { continue }
            let remoteURL = task.remoteURL ?? ""
            if shouldPersistPipelineUpdate(
                photoID: task.assetID,
                status: status,
                localPath: storedLocalPath,
                thumbnailPath: thumbnailPath,
                remoteURL: remoteURL,
                progress: task.progress,
                now: Date()
            ) {
                let record = item.toRecord(
                    albumId: context.albumId,
                    userId: userID,
                    localPath: storedLocalPath,
                    thumbnailPath: thumbnailPath,
                    fileSizeBytes: fileSizeBytes,
                    remoteURL: remoteURL
                )
                persistRecord(record)
            }
            lastAppliedPipelineSignatures[task.assetID] = signature
        }
    }

    /// 解析展示用拍摄时间：优先保留已有值，其次使用相机时间，最后回退到本地文件或当前时间。
    private func thumbnailURLString(thumbnailPath: String, remoteURL: String) -> String {
        if !thumbnailPath.isEmpty {
            let localThumbnailURL = CameraDownloadStorage.previewURLString(from: thumbnailPath)
            if !localThumbnailURL.isEmpty { return localThumbnailURL }
        }
        return remoteURL
    }

    private func previewURLString(localPath: String, remoteURL: String, thumbnailPath: String) -> String {
        if !localPath.isEmpty {
            let localPreviewURL = CameraDownloadStorage.previewURLString(from: localPath)
            if !localPreviewURL.isEmpty { return localPreviewURL }
        }
        if !remoteURL.isEmpty { return remoteURL }
        if !thumbnailPath.isEmpty {
            return CameraDownloadStorage.previewURLString(from: thumbnailPath)
        }
        return ""
    }

    private func resolveCapturedAt(
        existing: WiredTransferPhotoItem?,
        taskCapturedAt: String?,
        localURL: URL?
    ) async -> String {
        if let existing, !existing.capturedAt.isEmpty {
            return existing.capturedAt
        }
        if let taskCapturedAt, !taskCapturedAt.isEmpty {
            return taskCapturedAt
        }
        if let localURL {
            let date = await Task.detached(priority: .utility) {
                let attributes = try? FileManager.default.attributesOfItem(atPath: localURL.path)
                return (attributes?[.creationDate] as? Date)
                    ?? (attributes?[.modificationDate] as? Date)
            }.value
            if let date {
                return CameraTransferTask.capturedAtString(from: date)
            }
        }
        return CameraTransferTask.capturedAtString(from: Date())
    }

    /// 将管道任务状态映射为列表展示状态。
    private func mapPipelineStatus(_ status: CameraTransferStatus) -> WiredTransferUploadStatus {
        switch status {
        case .pending, .downloading:
            return .transferring
        case .downloaded:
            return .pending
        case .uploading:
            return .uploading
        case .uploaded:
            return .uploaded
        case .failed:
            return .failed
        }
    }

    /// 忽略异步通知乱序产生的旧状态，避免上传中或已上传被旧快照回退成待上传。
    private func shouldIgnoreStalePipelineStatus(
        existing: WiredTransferUploadStatus?,
        incoming: WiredTransferUploadStatus
    ) -> Bool {
        switch (existing, incoming) {
        case (.uploaded?, .pending), (.uploaded?, .transferring), (.uploaded?, .uploading), (.uploaded?, .failed):
            return true
        case (.uploading?, .pending), (.uploading?, .transferring):
            return true
        default:
            return false
        }
    }

    /// 仅在状态、路径或远程 URL 变化，或进度跨越大步长时落盘，避免高频 IO。
    private func shouldPersistPipelineUpdate(
        photoID: String,
        status: WiredTransferUploadStatus,
        localPath: String,
        thumbnailPath: String,
        remoteURL: String,
        progress: Int,
        now: Date
    ) -> Bool {
        guard let existing = persistedRecordsByID[photoID] else {
            lastProgressPersistDates[photoID] = now
            return true
        }
        if existing.status != status.rawValue { return true }
        if !localPath.isEmpty, existing.localPath != localPath { return true }
        if !thumbnailPath.isEmpty, existing.thumbnailPath != thumbnailPath { return true }
        if !remoteURL.isEmpty, existing.remoteURL != remoteURL { return true }
        if status == .uploaded || status == .failed { return true }
        if abs(progress - existing.progress) >= 10 {
            lastProgressPersistDates[photoID] = now
            return true
        }
        let lastPersistDate = lastProgressPersistDates[photoID] ?? .distantPast
        if now.timeIntervalSince(lastPersistDate) >= 1 {
            lastProgressPersistDates[photoID] = now
            return true
        }
        return false
    }

    /// 判断照片是否归属当前相册；未绑定的会话新拍默认归属当前页。
    private func belongsToCurrentAlbum(_ photoID: String) -> Bool {
        guard let bound = photoStore?.albumID(forPhoto: photoID) else {
            return true
        }
        return bound == context.albumId
    }
}

private struct PipelineTaskSignature: Equatable {
    let status: CameraTransferStatus
    let progress: Int
    let localPath: String?
    let remoteURL: String?
    let errorMessage: String?

    init(task: CameraTransferTask) {
        status = task.status
        progress = task.progress
        localPath = task.localPath
        remoteURL = task.remoteURL
        errorMessage = task.errorMessage
    }
}
