//
//  QueueManagementViewModels.swift
//  suixinkan
//
//  Created by Codex on 2026/6/25.
//

import Foundation
import Combine
import Photos
import SwiftUI
import UIKit

@MainActor
/// 排队管理列表 ViewModel，负责打卡点门禁、列表分页、动作和实时事件。
final class QueueManagementViewModel: ObservableObject {
    @Published var loading = false
    @Published var loadingMore = false
    @Published var items: [QueueItem] = []
    @Published var scenicSpots: [ScenicSpotItem] = []
    @Published var selectedSpotId: Int?
    @Published var selectedListType: QueueListType = .queueing
    @Published var queueCount = 0
    @Published var avgWaitMin = 0.0
    @Published var totalCount = 0
    @Published var hasMore = false
    @Published var page = 1
    @Published var lastSyncTimeText = "--"
    @Published var queueGatePassed = false
    @Published var selectedSpotName = "--"
    @Published var showStartShootingButton = true
    @Published var autoCallAheadCount = 0
    @Published var quickCallButtonEnabled = false
    @Published var prepareCallButtonEnabled = false
    @Published var remoteCallAnnouncement: ScenicQueueRemoteCallAnnouncement?
    @Published var errorMessage: String?

    private let pageSize = 10
    private let socketClient = ScenicQueueSocketClient()
    @Published private var realtimeSpotId: Int?
    @Published private var realtimeScenicId: Int?
    @Published private var currentUserId: String?
    @Published private var currentScenicId: Int?
    @Published private var pendingRemoteCalledRecordIds = Set<Int64>()
    @Published private var handledRemoteCallEventIds = Set<String>()

    /// 待处理排队数量。
    var pendingCount: Int {
        items.filter { $0.status < 7 && !$0.statusText.contains("完成") }.count
    }

    /// 平均等待时间文案。
    var avgWaitMinText: String {
        avgWaitMin.rounded() == avgWaitMin ? "\(Int(avgWaitMin))" : String(format: "%.1f", avgWaitMin)
    }

    /// 当前打卡点名称。
    var currentSpotName: String {
        scenicSpots.first(where: { $0.id == selectedSpotId })?.name
            ?? selectedSpotName.nonEmptyOrDefault(queueGatePassed ? "--" : "未设置打卡点")
    }

    /// 重新加载排队数据。没有已保存打卡点时只展示设置引导，不自动选第一个。
    func reload(api: any ScenicQueueServing, scenicId: Int?, userId: String?, spots: [ScenicSpotItem]) async {
        guard let scenicId else {
            reset()
            return
        }

        loading = true
        errorMessage = nil
        defer { loading = false }

        currentUserId = userId
        currentScenicId = scenicId
        scenicSpots = spots

        let savedSpotId = ScenicQueueSettingsStore.selectedSpotId(userId: userId, scenicId: scenicId)
        if let savedSpotId, spots.isEmpty || spots.contains(where: { $0.id == savedSpotId }) {
            selectedSpotId = savedSpotId
            selectedSpotName = ScenicQueueSettingsStore.selectedSpotName(userId: userId, scenicId: scenicId)
                .nonEmptyOrDefault(spots.first(where: { $0.id == savedSpotId })?.name ?? "--")
            queueGatePassed = true
            await syncQueueSettingFromServerIfMatchesLocal(api: api, userId: userId, scenicId: scenicId, spotId: savedSpotId)
            refreshShootingCallConfig(userId: userId, scenicId: scenicId, spotId: savedSpotId)
        } else {
            selectedSpotId = nil
            selectedSpotName = "--"
            queueGatePassed = false
            clearQueueData()
            return
        }

        do {
            try await loadPage(api: api, scenicId: scenicId, userId: userId, page: 1)
        } catch {
            clearQueueData()
            errorMessage = error.localizedDescription
        }
    }

    /// 切换列表类型并加载第一页。
    func selectListType(_ type: QueueListType, api: any ScenicQueueServing, scenicId: Int?, userId: String?) async {
        guard selectedListType != type else { return }
        selectedListType = type
        await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId)
    }

    /// 重新加载当前打卡点列表。
    func reloadCurrentSpot(api: any ScenicQueueServing, scenicId: Int?, userId: String?) async {
        guard let scenicId else { return }
        loading = true
        errorMessage = nil
        defer { loading = false }
        do {
            try await loadPage(api: api, scenicId: scenicId, userId: userId, page: 1)
        } catch {
            clearQueueData()
            errorMessage = error.localizedDescription
        }
    }

    /// 加载下一页。
    func loadMore(api: any ScenicQueueServing, scenicId: Int?, userId: String?) async {
        guard let scenicId, hasMore, !loadingMore else { return }
        loadingMore = true
        defer { loadingMore = false }
        do {
            try await loadPage(api: api, scenicId: scenicId, userId: userId, page: page + 1)
        } catch {
            errorMessage = error.localizedDescription
        }
    }

    /// 叫号，成功后仅本地标记为已叫号。
    func callQueue(api: any ScenicQueueServing, id: Int64) async throws {
        let data = try await api.scenicQueueCall(id: id)
        markQueueCalled(id: id, statusText: data.statusText)
    }

    /// 标记过号并刷新列表。
    func passQueue(api: any ScenicQueueServing, scenicId: Int?, userId: String?, id: Int64) async throws {
        _ = try await api.scenicQueuePass(id: id)
        items.removeAll { $0.id == id }
        queueCount = max(queueCount - 1, 0)
        await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId)
    }

    /// 标记完成并刷新列表。
    func finishQueue(api: any ScenicQueueServing, scenicId: Int?, userId: String?, id: Int64) async throws -> ScenicQueueFinishData {
        let data = try await api.scenicQueueFinish(id: id)
        items.removeAll { $0.id == id }
        queueCount = max(queueCount - 1, 0)
        await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId)
        return data
    }

    /// 重新排队并切回当前排队列表。
    func requeue(api: any ScenicQueueServing, scenicId: Int?, userId: String?, id: Int64, operatorId: Int) async throws {
        try await api.scenicQueueRequeueInsertBefore(recordId: id, operatorId: operatorId)
        selectedListType = .queueing
        await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId)
    }

    /// 标记用户身份/限制排队。
    func userMark(api: any ScenicQueueServing, scenicId: Int?, userId: String?, request: ScenicQueueUserMarkRequest) async throws {
        try await api.scenicQueueUserMark(request)
        await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId)
    }

    /// 启动页面前台实时监听。
    func startRealtime(api: any ScenicQueueServing, scenicId: Int?, userId: String?) async {
        guard queueGatePassed, let scenicId, let scenicSpotId = selectedSpotId else {
            stopRealtime()
            return
        }
        guard realtimeScenicId != scenicId || realtimeSpotId != scenicSpotId else { return }
        stopRealtime()
        do {
            let token = try await api.socketToken().socketToken.trimmingCharacters(in: .whitespacesAndNewlines)
            guard !token.isEmpty else { return }
            realtimeScenicId = scenicId
            realtimeSpotId = scenicSpotId
            socketClient.connect(socketToken: token, scenicSpotId: scenicSpotId) { [weak self] message in
                Task { @MainActor in
                    await self?.handleSocketMessage(message, api: api, scenicId: scenicId, userId: userId)
                }
            }
        } catch {
            errorMessage = error.localizedDescription
        }
    }

    /// 停止页面实时监听。
    func stopRealtime() {
        socketClient.disconnect()
        realtimeScenicId = nil
        realtimeSpotId = nil
        pendingRemoteCalledRecordIds.removeAll()
        handledRemoteCallEventIds.removeAll()
    }

    /// 当前拍摄预计时长秒数。
    var shootingDurationSeconds: Int {
        guard let scenicId = currentScenicId, let selectedSpotId,
              let snapshot = ScenicQueueSettingsStore.settingsSnapshot(userId: currentUserId, scenicId: scenicId, spotId: selectedSpotId)
        else {
            return max(UserDefaults.standard.integer(forKey: ScenicQueueLocalSettings.photoEstimateSecondsKey), 0)
        }
        return max(snapshot.shootMinute * 60 + snapshot.shootSecond, 0)
    }

    private func handleSocketMessage(_ message: ScenicQueueSocketMessage, api: any ScenicQueueServing, scenicId: Int, userId: String?) async {
        guard message.isScenicQueueEvent,
              let params = message.data?.params,
              let changedSpotId = params.scenicSpotId,
              let selectedSpotId,
              changedSpotId == Int64(selectedSpotId)
        else { return }

        switch message.data?.action {
        case ScenicQueueSocketMessage.queueUpdatedAction:
            await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId)
        case ScenicQueueSocketMessage.ticketCalledAction:
            await handleRemoteTicketCalled(params: params, api: api, scenicId: scenicId, userId: userId)
        default:
            return
        }
    }

    private func handleRemoteTicketCalled(params: ScenicQueueSocketParams, api: any ScenicQueueServing, scenicId: Int, userId: String?) async {
        guard let recordId = params.recordId, recordId > 0 else { return }
        if let operatorUid = params.operatorUid,
           let myUid = Int64(userId ?? ""),
           operatorUid == myUid {
            return
        }
        let eventId = params.eventId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
        if !eventId.isEmpty, !handledRemoteCallEventIds.insert(eventId).inserted {
            return
        }
        pendingRemoteCalledRecordIds.insert(recordId)
        guard let selectedSpotId else { return }
        _ = try? await api.scenicQueueStats(scenicId: scenicId, scenicSpotId: selectedSpotId)
        let tickets = (try? await loadQueueingTickets(api: api, scenicId: scenicId, scenicSpotId: selectedSpotId)) ?? []
        if selectedListType == .queueing {
            items = tickets
            totalCount = max(totalCount, tickets.count)
            hasMore = tickets.count < totalCount
        }
        consumePendingRemoteCalledTickets(from: tickets)
        await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId)
    }

    private func loadQueueingTickets(api: any ScenicQueueServing, scenicId: Int, scenicSpotId: Int) async throws -> [QueueItem] {
        let home = try await api.scenicQueueHome(
            scenicId: scenicId,
            scenicSpotId: scenicSpotId,
            type: QueueListType.queueing.rawValue,
            page: 1,
            pageSize: pageSize
        )
        return home.list?.list ?? []
    }

    private func consumePendingRemoteCalledTickets(from tickets: [QueueItem]) {
        guard let ticket = tickets.first(where: {
            pendingRemoteCalledRecordIds.contains($0.id) && ($0.isCalled == 1 || $0.status == 1 || $0.statusText.contains("已叫号"))
        }) else {
            return
        }
        pendingRemoteCalledRecordIds.remove(ticket.id)
        remoteCallAnnouncement = ScenicQueueRemoteCallAnnouncement(id: ticket.id, queueCode: ticket.queueCode)
    }

    private func loadPage(api: any ScenicQueueServing, scenicId: Int, userId: String?, page targetPage: Int) async throws {
        guard let scenicSpotId = selectedSpotId else { return }
        let home = try await api.scenicQueueHome(
            scenicId: scenicId,
            scenicSpotId: scenicSpotId,
            type: selectedListType.rawValue,
            page: targetPage,
            pageSize: pageSize
        )
        let stats = (try? await api.scenicQueueStats(scenicId: scenicId, scenicSpotId: scenicSpotId)) ?? ScenicQueueStatsData()
        let block = home.list
        let nextList = block?.list ?? []

        if targetPage <= 1 {
            items = nextList
        } else {
            items.append(contentsOf: nextList)
        }
        page = targetPage
        totalCount = block?.total ?? items.count
        hasMore = page * pageSize < totalCount
        queueCount = stats.queueCount
        avgWaitMin = stats.avgWaitMin
        let syncText = stats.time.isEmpty ? (home.time ?? "") : stats.time
        lastSyncTimeText = syncText.isEmpty ? "--" : syncText
        refreshShootingCallConfig(userId: userId, scenicId: scenicId, spotId: scenicSpotId)
    }

    private func markQueueCalled(id: Int64, statusText: String) {
        let trimmedStatus = statusText.trimmingCharacters(in: .whitespacesAndNewlines)
        items = items.map { item in
            guard item.id == id else { return item }
            return ScenicQueueTicket(
                id: item.id,
                queueCode: item.queueCode,
                mobile: item.mobile,
                status: max(item.status, 1),
                statusText: trimmedStatus.nonEmptyOrDefault(item.statusText.contains("已叫号") ? item.statusText : "已叫号"),
                waitMin: item.waitMin,
                aheadCount: item.aheadCount,
                isCalled: 1,
                createdAt: item.createdAt,
                calledAt: item.calledAt,
                expiredAt: item.expiredAt,
                finishedAt: item.finishedAt,
                queueBanLabel: item.queueBanLabel,
                identityTag: item.identityTag,
                queueTime: item.queueTime,
                queueCountToday: item.queueCountToday,
                uid: item.uid,
                markAsPhotog: item.markAsPhotog,
                markAsFreelancePhotog: item.markAsFreelancePhotog,
                isMissRequeue: item.isMissRequeue,
                missRequeueText: item.missRequeueText
            )
        }
    }

    private func reset() {
        stopRealtime()
        loading = false
        loadingMore = false
        scenicSpots = []
        selectedSpotId = nil
        selectedSpotName = "--"
        queueGatePassed = false
        selectedListType = .queueing
        currentScenicId = nil
        clearQueueData()
    }

    private func clearQueueData() {
        loadingMore = false
        items = []
        queueCount = 0
        avgWaitMin = 0
        totalCount = 0
        hasMore = false
        page = 1
        lastSyncTimeText = "--"
    }

    private func refreshShootingCallConfig(userId: String?, scenicId: Int, spotId: Int) {
        let snapshot = ScenicQueueSettingsStore.settingsSnapshot(userId: userId, scenicId: scenicId, spotId: spotId) ?? ScenicQueueSettingsSnapshot()
        showStartShootingButton = snapshot.showStartShootingButton
        autoCallAheadCount = min(max(snapshot.autoCallAheadCount, 0), 5)
        quickCallButtonEnabled = snapshot.quickCallButtonEnabled
        prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled
    }

    private func syncQueueSettingFromServerIfMatchesLocal(api: any ScenicQueueServing, userId: String?, scenicId: Int, spotId: Int) async {
        guard let data = try? await api.scenicQueueSetting(scenicId: scenicId, scenicSpotId: spotId),
              data.exists,
              let setting = data.setting
        else { return }
        if let settingScenicId = setting.scenicId, settingScenicId != scenicId { return }
        if let settingSpotId = setting.scenicSpotId, settingSpotId != spotId { return }

        let local = ScenicQueueSettingsStore.settingsSnapshot(userId: userId, scenicId: scenicId, spotId: spotId) ?? ScenicQueueSettingsSnapshot()
        let snapshot = ScenicQueueSettingsSnapshot(
            shootMinute: min(max(setting.photoEstimateMin, 0), 30),
            shootSecond: min(max(setting.photoEstimateSec, 0), 59),
            firstAheadCount: min(max(setting.firstNoticeThresholdPos, 0), 50),
            firstSms: setting.firstNoticeSmsEnabled == 1,
            firstPhone: setting.firstNoticeCallEnabled == 1,
            secondAheadCount: min(max(setting.secondNoticeThresholdPos, 0), 50),
            secondSms: setting.secondNoticeSmsEnabled == 1,
            secondPhone: setting.secondNoticeCallEnabled == 1,
            broadcastIntervalSec: ScenicQueueSettingsViewModel.coercedBroadcastInterval(setting.countdownBroadcastIntervalSec),
            countdownThresholdSec: ScenicQueueSettingsViewModel.coercedCountdownThreshold(setting.countdownReadableThresholdSec),
            queueDistanceMeter: min(max(setting.queueDistanceMeter, 0), 9_999_990),
            queueTakeLimit: min(max(setting.queueTakeLimit, 0), 999_999),
            missCallRequeueOffset: min(max(setting.missCallRequeueOffset, 1), 9_999),
            showStartShootingButton: (setting.showStartShootButton ?? (local.showStartShootingButton ? 1 : 0)) == 1,
            autoCallAheadCount: min(max(setting.autoCallNextCount ?? local.autoCallAheadCount, 0), 5),
            quickCallButtonEnabled: local.quickCallButtonEnabled,
            prepareCallButtonEnabled: local.prepareCallButtonEnabled,
            businessOpen: setting.status == 1,
            businessStartTime: setting.businessStartTime,
            businessEndTime: setting.businessEndTime
        )
        ScenicQueueSettingsStore.saveSettingsSnapshot(snapshot, userId: userId, scenicId: scenicId, spotId: spotId)
        ScenicQueueSettingsStore.saveSelectedSpot(
            id: spotId,
            name: setting.scenicSpotName.nonEmptyOrDefault(selectedSpotName),
            userId: userId,
            scenicId: scenicId
        )
        if !setting.remark.isEmpty, setting.remark != "A0001" {
            ScenicQueueSettingsStore.saveCustomTtsText(setting.remark, userId: userId, scenicId: scenicId, spotId: spotId)
        }
        let voices = setting.voiceBroadcasts
            .sorted { $0.sortOrder < $1.sortOrder }
            .map { $0.content.trimmingCharacters(in: .whitespacesAndNewlines) }
            .filter { !$0.isEmpty }
        if !voices.isEmpty {
            ScenicQueueSettingsStore.savePresetVoices(Array(voices.prefix(5)), userId: userId, scenicId: scenicId, spotId: spotId)
        }
        selectedSpotName = setting.scenicSpotName.nonEmptyOrDefault(selectedSpotName)
    }
}

@MainActor
/// 排队设置 ViewModel，负责设置读取、校验、保存、二维码和日志。
final class ScenicQueueSettingsViewModel: ObservableObject {
    @Published var loading = false
    @Published var message: String?
    @Published var scenicSpots: [ScenicSpotItem] = []
    @Published var selectedSpotId: Int?
    @Published var photoEstimateMin = "0"
    @Published var photoEstimateSec = "30"
    @Published var firstAhead = "0"
    @Published var firstSms = false
    @Published var firstPhone = false
    @Published var secondAhead = "0"
    @Published var secondSms = false
    @Published var secondPhone = false
    @Published var broadcastIntervalSec = "50"
    @Published var countdownThresholdSec = "15"
    @Published var maxQueueRangeKm = "0.00"
    @Published var localQueueLimit = "0"
    @Published var localPassDelay = "1"
    @Published var showStartShootingButton = true
    @Published var autoCallAheadCount = "0"
    @Published var queueOpen = true
    @Published var businessStartTime = ScenicQueueSettingsViewModel.businessTime(hour: 10, minute: 0)
    @Published var businessEndTime = ScenicQueueSettingsViewModel.businessTime(hour: 20, minute: 0)
    @Published var customTtsText = ""
    @Published var presetVoices: [String] = []
    @Published var quickCallButtonEnabled = false
    @Published var prepareCallButtonEnabled = false
    @Published var logs: [ScenicQueueSettingChangeLogItem] = []
    @Published var qrcodeURL = ""

    private let speaker = ScenicQueueSpeechService()
    @Published private var currentUserId: String?
    @Published private var currentScenicId: Int?

    /// 加载设置。设置页允许自动选中第一个打卡点。
    func load(api: any ScenicQueueServing, scenicId: Int?, userId: String?, spots: [ScenicSpotItem]) async {
        guard let scenicId else {
            resetSettingsState()
            message = "请先选择景区"
            return
        }
        currentUserId = userId
        currentScenicId = scenicId
        scenicSpots = spots
        message = nil
        loading = true
        defer { loading = false }
        logs = []

        let savedSpotId = ScenicQueueSettingsStore.selectedSpotId(userId: userId, scenicId: scenicId)
        selectedSpotId = savedSpotId.flatMap { id in spots.first(where: { $0.id == id })?.id ?? id } ?? selectedSpotId ?? spots.first?.id
        guard selectedSpotId != nil else { return }
        await loadSelectedSpotSetting(api: api, scenicId: scenicId, userId: userId)
    }

    /// 加载当前选中打卡点设置。
    func loadSelectedSpotSetting(api: any ScenicQueueServing, scenicId: Int?, userId: String?) async {
        guard let scenicId, let selectedSpotId else { return }
        currentUserId = userId
        currentScenicId = scenicId
        presetVoices = ScenicQueueSettingsStore.presetVoices(userId: userId, scenicId: scenicId, spotId: selectedSpotId)
        let savedCustomText = ScenicQueueSettingsStore.customTtsText(userId: userId, scenicId: scenicId, spotId: selectedSpotId)
        if !savedCustomText.isEmpty, savedCustomText != "A0001" {
            customTtsText = savedCustomText
        }
        if let snapshot = ScenicQueueSettingsStore.settingsSnapshot(userId: userId, scenicId: scenicId, spotId: selectedSpotId) {
            apply(snapshot)
        }
        do {
            let setting = try await api.scenicQueueSetting(scenicId: scenicId, scenicSpotId: selectedSpotId)
            if setting.exists, let item = setting.setting {
                apply(item)
                ScenicQueueSettingsStore.saveSettingsSnapshot(makeSnapshot(), userId: userId, scenicId: scenicId, spotId: selectedSpotId)
            }
        } catch {
            message = error.localizedDescription
        }
    }

    /// 加载设置变更日志。
    func loadSettingChangeLogs(api: any ScenicQueueServing, scenicId: Int?) async {
        guard let scenicId else {
            message = "请先选择景区"
            return
        }
        do {
            let data = try await api.scenicQueueSettingChangeLog(scenicId: scenicId, scenicSpotId: selectedSpotId, page: 1, pageSize: 20)
            logs = data.list
        } catch {
            message = error.localizedDescription
        }
    }

    /// 获取排队二维码 URL。
    func fetchQRCode(api: any ScenicQueueServing, scenicId: Int?) async throws -> String {
        guard let scenicId else { throw APIError.networkFailed("请先选择景区") }
        guard let selectedSpotId else { throw APIError.networkFailed("请选择打卡点") }
        let data = try await api.scenicQueueShootQueueQRCode(scenicId: scenicId, scenicSpotId: selectedSpotId)
        let url = data.qrcodeUrl.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !url.isEmpty else { throw APIError.networkFailed("服务端未返回二维码图片地址") }
        qrcodeURL = url
        return url
    }

    /// 保存二维码图片到系统相册。
    func saveQRCodeToPhotoLibrary(qrcodeURL: String) async throws {
        let trimmed = qrcodeURL.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else {
            throw APIError.networkFailed("服务端未返回二维码图片地址")
        }
        let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
        guard status == .authorized || status == .limited else {
            throw APIError.networkFailed("请在系统设置中允许保存图片到相册")
        }
        let image = try await loadQRCodeImage(from: trimmed)
        try await PHPhotoLibrary.shared().performChanges {
            PHAssetChangeRequest.creationRequestForAsset(from: image)
        }
    }

    /// 保存排队设置。
    func save(api: any ScenicQueueServing, scenicId: Int?, userId: String?) async throws {
        guard let scenicId else { throw APIError.networkFailed("请先选择景区") }
        guard let selectedSpotId else { throw APIError.networkFailed("请选择打卡点") }
        currentUserId = userId
        currentScenicId = scenicId
        let min = try AppFormValidator.rangedInteger(photoEstimateMin, name: "拍摄时长分钟", range: 0...30)
        let sec = try AppFormValidator.rangedInteger(photoEstimateSec, name: "拍摄时长秒", range: 0...59)
        guard min > 0 || sec > 0 else { throw APIError.networkFailed("拍摄时长须大于 0") }
        let first = try AppFormValidator.rangedInteger(firstAhead, name: "第一次通知阈值", range: 0...50)
        let second = try AppFormValidator.rangedInteger(secondAhead, name: "第二次通知阈值", range: 0...50)
        try AppFormValidator.validateQueueNoticeThresholds(first: first, second: second)
        let interval = try AppFormValidator.rangedInteger(broadcastIntervalSec, name: "播报间隔时间", range: 40...60)
        let countdown = try AppFormValidator.rangedInteger(countdownThresholdSec, name: "进入读秒倒计时阈值", range: 10...30)
        let autoCallAhead = try AppFormValidator.rangedInteger(autoCallAheadCount, name: "自动叫号后续几位", range: 0...5)
        maxQueueRangeKm = try normalizedMaxQueueRangeForDisplay()
        let queueDistanceMeter = try queueDistanceMeterForSubmit()
        let queueTakeLimit = try AppFormValidator.rangedInteger(localQueueLimit, name: "排队次数限制", range: 0...999_999)
        let missCallRequeueOffset = try AppFormValidator.rangedInteger(localPassDelay, name: "过号顺延", range: 1...9_999)
        guard customTtsText.trimmingCharacters(in: .whitespacesAndNewlines).count <= 255 else {
            throw APIError.networkFailed("自定义播报文本最多 255 个字")
        }

        loading = true
        defer { loading = false }
        try await api.scenicQueueSaveSetting(ScenicQueueSaveSettingRequest(
            scenicId: scenicId,
            scenicSpotId: selectedSpotId,
            photoEstimateMin: min,
            photoEstimateSec: sec,
            firstNoticeThresholdPos: first,
            firstNoticeSmsEnabled: firstSms ? 1 : 0,
            firstNoticeCallEnabled: firstPhone ? 1 : 0,
            secondNoticeThresholdPos: second,
            secondNoticeSmsEnabled: secondSms ? 1 : 0,
            secondNoticeCallEnabled: secondPhone ? 1 : 0,
            countdownBroadcastIntervalSec: interval,
            countdownReadableThresholdSec: countdown,
            queueDistanceMeter: queueDistanceMeter,
            queueTakeLimit: queueTakeLimit,
            missCallRequeueOffset: missCallRequeueOffset,
            showStartShootButton: showStartShootingButton ? 1 : 0,
            autoCallNextCount: autoCallAhead,
            businessStartTime: Self.businessTimePayload(businessStartTime),
            businessEndTime: Self.businessTimePayload(businessEndTime),
            voiceBroadcasts: voiceBroadcastsForSavePayload(),
            status: queueOpen ? 1 : 0,
            remark: customTtsText.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
        ))
        let spotName = scenicSpots.first(where: { $0.id == selectedSpotId })?.name ?? ""
        ScenicQueueSettingsStore.saveSelectedSpot(id: selectedSpotId, name: spotName, userId: userId, scenicId: scenicId)
        saveTimingSnapshot(min: min, sec: sec, interval: interval, countdown: countdown)
        saveShootingCallSettings(autoCallAhead: autoCallAhead)
        saveCustomTextLocally()
        ScenicQueueSettingsStore.savePresetVoices(presetVoices, userId: userId, scenicId: scenicId, spotId: selectedSpotId)
        ScenicQueueSettingsStore.saveSettingsSnapshot(makeSnapshot(
            min: min,
            sec: sec,
            first: first,
            second: second,
            interval: interval,
            countdown: countdown,
            queueDistanceMeter: queueDistanceMeter,
            queueTakeLimit: queueTakeLimit,
            missCallRequeueOffset: missCallRequeueOffset,
            autoCallAhead: autoCallAhead
        ), userId: userId, scenicId: scenicId, spotId: selectedSpotId)
    }

    /// 保存自定义文本为本地预设。
    func saveCustomTextAsPreset() {
        let text = customTtsText.trimmingCharacters(in: .whitespacesAndNewlines)
        guard text.count <= 255 else {
            message = "自定义播报文本最多 255 个字"
            return
        }
        saveCustomTextLocally()
        if !text.isEmpty, !presetVoices.contains(text), presetVoices.count < 5 {
            presetVoices.append(text)
        }
        if let scenicId = currentScenicId, let selectedSpotId {
            ScenicQueueSettingsStore.savePresetVoices(presetVoices, userId: currentUserId, scenicId: scenicId, spotId: selectedSpotId)
        }
    }

    /// 更新最大排队范围输入。
    func updateMaxQueueRange(_ value: String) {
        maxQueueRangeKm = AppFormValidator.sanitizedMoneyInput(value, maxDecimalPlaces: 2)
    }

    /// 播放测试音。
    func playTestSound() {
        speaker.speak("蓝牙音响测试音，请确认外放音量是否合适。")
    }

    /// 播放自定义播报文本。
    func playCustomText() {
        let text = customTtsText.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !text.isEmpty else {
            message = "请输入要播报的文字"
            return
        }
        speaker.speak(text)
    }

    /// 删除预设语音。
    func deletePresetVoice(at index: Int) {
        guard presetVoices.indices.contains(index) else { return }
        presetVoices.remove(at: index)
    }

    private func resetSettingsState() {
        loading = false
        scenicSpots = []
        selectedSpotId = nil
        photoEstimateMin = "0"
        photoEstimateSec = "30"
        firstAhead = "0"
        firstSms = false
        firstPhone = false
        secondAhead = "0"
        secondSms = false
        secondPhone = false
        broadcastIntervalSec = "50"
        countdownThresholdSec = "15"
        maxQueueRangeKm = "0.00"
        localQueueLimit = "0"
        localPassDelay = "1"
        showStartShootingButton = true
        autoCallAheadCount = "0"
        queueOpen = true
        businessStartTime = Self.businessTime(hour: 10, minute: 0)
        businessEndTime = Self.businessTime(hour: 20, minute: 0)
        customTtsText = ""
        logs = []
        presetVoices = []
        quickCallButtonEnabled = false
        prepareCallButtonEnabled = false
        qrcodeURL = ""
    }

    private func apply(_ setting: ScenicQueueSettingItem?) {
        guard let setting else { return }
        if let spotId = setting.scenicSpotId { selectedSpotId = spotId }
        photoEstimateMin = "\(setting.photoEstimateMin)"
        photoEstimateSec = "\(setting.photoEstimateSec)"
        firstAhead = "\(setting.firstNoticeThresholdPos)"
        firstSms = setting.firstNoticeSmsEnabled == 1
        firstPhone = setting.firstNoticeCallEnabled == 1
        secondAhead = "\(setting.secondNoticeThresholdPos)"
        secondSms = setting.secondNoticeSmsEnabled == 1
        secondPhone = setting.secondNoticeCallEnabled == 1
        broadcastIntervalSec = "\(Self.coercedBroadcastInterval(setting.countdownBroadcastIntervalSec))"
        countdownThresholdSec = "\(Self.coercedCountdownThreshold(setting.countdownReadableThresholdSec))"
        maxQueueRangeKm = Self.queueDistanceDisplay(meters: setting.queueDistanceMeter)
        localQueueLimit = "\(min(max(setting.queueTakeLimit, 0), 999_999))"
        localPassDelay = "\(min(max(setting.missCallRequeueOffset, 1), 9_999))"
        if let showStartShootButton = setting.showStartShootButton { showStartShootingButton = showStartShootButton == 1 }
        if let autoCallNextCount = setting.autoCallNextCount { autoCallAheadCount = "\(min(max(autoCallNextCount, 0), 5))" }
        queueOpen = setting.status == 1
        businessStartTime = Self.parseBusinessTime(setting.businessStartTime, fallback: Self.businessTime(hour: 10, minute: 0))
        businessEndTime = Self.parseBusinessTime(setting.businessEndTime, fallback: Self.businessTime(hour: 20, minute: 0))
        if !setting.remark.isEmpty, setting.remark != "A0001" { customTtsText = setting.remark }
        let voices = setting.voiceBroadcasts
            .sorted { $0.sortOrder < $1.sortOrder }
            .map { $0.content.trimmingCharacters(in: .whitespacesAndNewlines) }
            .filter { !$0.isEmpty }
        if !voices.isEmpty { presetVoices = Array(voices.prefix(5)) }
    }

    private func apply(_ snapshot: ScenicQueueSettingsSnapshot) {
        photoEstimateMin = "\(min(max(snapshot.shootMinute, 0), 30))"
        photoEstimateSec = "\(min(max(snapshot.shootSecond, 0), 59))"
        firstAhead = "\(min(max(snapshot.firstAheadCount, 0), 50))"
        firstSms = snapshot.firstSms
        firstPhone = snapshot.firstPhone
        secondAhead = "\(min(max(snapshot.secondAheadCount, 0), 50))"
        secondSms = snapshot.secondSms
        secondPhone = snapshot.secondPhone
        broadcastIntervalSec = "\(Self.coercedBroadcastInterval(snapshot.broadcastIntervalSec))"
        countdownThresholdSec = "\(Self.coercedCountdownThreshold(snapshot.countdownThresholdSec))"
        maxQueueRangeKm = Self.queueDistanceDisplay(meters: snapshot.queueDistanceMeter)
        localQueueLimit = "\(min(max(snapshot.queueTakeLimit, 0), 999_999))"
        localPassDelay = "\(min(max(snapshot.missCallRequeueOffset, 1), 9_999))"
        showStartShootingButton = snapshot.showStartShootingButton
        autoCallAheadCount = "\(min(max(snapshot.autoCallAheadCount, 0), 5))"
        quickCallButtonEnabled = snapshot.quickCallButtonEnabled
        prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled
        queueOpen = snapshot.businessOpen
        businessStartTime = Self.parseBusinessTime(snapshot.businessStartTime, fallback: Self.businessTime(hour: 10, minute: 0))
        businessEndTime = Self.parseBusinessTime(snapshot.businessEndTime, fallback: Self.businessTime(hour: 20, minute: 0))
    }

    private func normalizedMaxQueueRangeForDisplay() throws -> String {
        let normalized = AppFormValidator.normalizedMoneyForSubmit(maxQueueRangeKm)
        guard !normalized.isEmpty,
              normalized.range(of: #"^\d+(\.\d{1,2})?$"#, options: .regularExpression) != nil,
              let value = Decimal(string: normalized, locale: Locale(identifier: "en_US_POSIX"))
        else {
            throw APIError.networkFailed("允许排队的最大范围格式不正确")
        }
        let max = Decimal(999_999) / Decimal(100)
        guard value >= 0, value <= max else {
            throw APIError.networkFailed("允许排队的最大范围须在 0～9999.99")
        }
        return String(format: "%.2f", NSDecimalNumber(decimal: value).doubleValue)
    }

    private func queueDistanceMeterForSubmit() throws -> Int {
        _ = try normalizedMaxQueueRangeForDisplay()
        let value = Decimal(string: maxQueueRangeKm, locale: Locale(identifier: "en_US_POSIX")) ?? 0
        return NSDecimalNumber(decimal: value * Decimal(1000)).intValue
    }

    private func loadQRCodeImage(from text: String) async throws -> UIImage {
        if let url = URL(string: text), ["http", "https"].contains(url.scheme?.lowercased()) {
            let (data, _) = try await URLSession.shared.data(from: url)
            if let image = UIImage(data: data) { return image }
        }
        if let generated = Self.generateQRCode(from: text) { return generated }
        throw APIError.networkFailed("二维码图片生成失败")
    }

    private static func generateQRCode(from string: String) -> UIImage? {
        let filter = CIFilter.qrCodeGenerator()
        filter.message = Data(string.utf8)
        guard let outputImage = filter.outputImage else { return nil }
        let transformed = outputImage.transformed(by: CGAffineTransform(scaleX: 8, y: 8))
        let context = CIContext()
        guard let cgImage = context.createCGImage(transformed, from: transformed.extent) else { return nil }
        return UIImage(cgImage: cgImage)
    }

    private func saveTimingSnapshot(min: Int, sec: Int, interval: Int, countdown: Int) {
        UserDefaults.standard.set(max(min * 60 + sec, 0), forKey: ScenicQueueLocalSettings.photoEstimateSecondsKey)
        UserDefaults.standard.set(interval, forKey: ScenicQueueLocalSettings.broadcastIntervalSecondsKey)
        UserDefaults.standard.set(countdown, forKey: ScenicQueueLocalSettings.countdownThresholdSecondsKey)
    }

    private func saveShootingCallSettings(autoCallAhead: Int) {
        UserDefaults.standard.set(showStartShootingButton, forKey: ScenicQueueLocalSettings.showStartShootingButtonKey)
        UserDefaults.standard.set(autoCallAhead, forKey: ScenicQueueLocalSettings.autoCallAheadCountKey)
        UserDefaults.standard.set(quickCallButtonEnabled, forKey: ScenicQueueLocalSettings.quickCallButtonEnabledKey)
        UserDefaults.standard.set(prepareCallButtonEnabled, forKey: ScenicQueueLocalSettings.prepareCallButtonEnabledKey)
    }

    private func saveCustomTextLocally() {
        ScenicQueueSettingsStore.saveCustomTtsText(customTtsText, userId: currentUserId, scenicId: currentScenicId, spotId: selectedSpotId)
    }

    private func voiceBroadcastsForSavePayload() -> [ScenicQueueVoiceBroadcastItem] {
        Array(presetVoices
            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
            .filter { !$0.isEmpty }
            .prefix(5)
            .enumerated()
            .map { index, content in ScenicQueueVoiceBroadcastItem(content: content, sortOrder: index + 1) })
    }

    private func makeSnapshot(
        min: Int? = nil,
        sec: Int? = nil,
        first: Int? = nil,
        second: Int? = nil,
        interval: Int? = nil,
        countdown: Int? = nil,
        queueDistanceMeter: Int? = nil,
        queueTakeLimit: Int? = nil,
        missCallRequeueOffset: Int? = nil,
        autoCallAhead: Int? = nil
    ) -> ScenicQueueSettingsSnapshot {
        ScenicQueueSettingsSnapshot(
            shootMinute: min ?? (Int(photoEstimateMin) ?? 0),
            shootSecond: sec ?? (Int(photoEstimateSec) ?? 30),
            firstAheadCount: first ?? (Int(firstAhead) ?? 0),
            firstSms: firstSms,
            firstPhone: firstPhone,
            secondAheadCount: second ?? (Int(secondAhead) ?? 0),
            secondSms: secondSms,
            secondPhone: secondPhone,
            broadcastIntervalSec: interval ?? (Int(broadcastIntervalSec) ?? 50),
            countdownThresholdSec: countdown ?? (Int(countdownThresholdSec) ?? 15),
            queueDistanceMeter: queueDistanceMeter ?? ((Decimal(string: maxQueueRangeKm) ?? 0) as NSDecimalNumber).intValue * 1000,
            queueTakeLimit: queueTakeLimit ?? (Int(localQueueLimit) ?? 0),
            missCallRequeueOffset: missCallRequeueOffset ?? (Int(localPassDelay) ?? 1),
            showStartShootingButton: showStartShootingButton,
            autoCallAheadCount: autoCallAhead ?? (Int(autoCallAheadCount) ?? 0),
            quickCallButtonEnabled: quickCallButtonEnabled,
            prepareCallButtonEnabled: prepareCallButtonEnabled,
            businessOpen: queueOpen,
            businessStartTime: Self.businessTimePayload(businessStartTime),
            businessEndTime: Self.businessTimePayload(businessEndTime)
        )
    }

    static func coercedBroadcastInterval(_ raw: Int) -> Int {
        (40...60).contains(raw) ? raw : 50
    }

    static func coercedCountdownThreshold(_ raw: Int) -> Int {
        (10...30).contains(raw) ? raw : 15
    }

    static func businessTime(hour: Int, minute: Int) -> Date {
        Calendar.current.date(from: DateComponents(hour: hour, minute: minute)) ?? Date()
    }

    static func parseBusinessTime(_ raw: String, fallback: Date) -> Date {
        let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !text.isEmpty else { return fallback }
        for format in ["HH:mm", "HH:mm:ss"] {
            let formatter = DateFormatter()
            formatter.locale = Locale(identifier: "en_US_POSIX")
            formatter.dateFormat = format
            if let date = formatter.date(from: text) { return date }
        }
        return fallback
    }

    static func businessTimePayload(_ date: Date) -> String {
        let formatter = DateFormatter()
        formatter.locale = Locale(identifier: "en_US_POSIX")
        formatter.dateFormat = "HH:mm"
        return formatter.string(from: date)
    }

    private static func queueDistanceDisplay(meters: Int) -> String {
        let clamped = min(max(meters, 0), 9_999_990)
        return String(format: "%.2f", Double(clamped) / 1000)
    }
}

private extension String {
    var nilIfEmpty: String? {
        let text = trimmingCharacters(in: .whitespacesAndNewlines)
        return text.isEmpty ? nil : text
    }

    func nonEmptyOrDefault(_ fallback: String) -> String {
        let text = trimmingCharacters(in: .whitespacesAndNewlines)
        return text.isEmpty ? fallback : text
    }
}
