//
//  ScenicQueueSettingsViewModel.swift
//  suixinkan
//

import Foundation

/// 排队设置页 ViewModel，对齐 Android `ScenicQueueSettingsViewModel`。
final class ScenicQueueSettingsViewModel {
    private enum Limits {
        static let remarkMaxLen = 255
        static let shootMinute = 0...30
        static let shootSecond = 0...59
        static let ahead = 0...50
        static let broadcastInterval = 40...60
        static let readableThreshold = 10...30
        static let queueDistance = 0...9_999_990
        static let queueTakeLimit = 0...999_999
        static let missCallRequeueOffset = 1...9_999
        static let autoCallAheadCount = 0...5
    }

    private(set) var punchSpotOptions: [ScenicQueuePunchSpotOption] = []
    private(set) var selectedPunchSpotId: String?
    private(set) var selectedPunchSpotLabel: String
    private(set) var shootMinute: Int
    private(set) var shootSecond: Int
    private(set) var firstAheadCount: Int
    private(set) var firstSms: Bool
    private(set) var firstPhone: Bool
    private(set) var secondAheadCount: Int
    private(set) var secondSms: Bool
    private(set) var secondPhone: Bool
    private(set) var broadcastIntervalSec: Int
    private(set) var countdownThresholdSec: Int
    private(set) var queueDistanceMeter: Int
    private(set) var queueTakeLimit: Int
    private(set) var missCallRequeueOffset: Int
    private(set) var showStartShootingButton: Bool
    private(set) var autoCallAheadCount: Int
    private(set) var quickCallButtonEnabled: Bool
    private(set) var prepareCallButtonEnabled: Bool
    private(set) var businessOpen: Bool
    private(set) var businessStartTime: String
    private(set) var businessEndTime: String
    private(set) var customTTSText: String
    private(set) var presetVoices: [String]
    private(set) var settingsTab: ScenicQueueSettingsTab = .basic
    private(set) var highlightedInvalidFieldKeys: Set<String> = []
    private(set) var blockingInvalidNumericFieldKeys: Set<String> = []

    var onStateChange: (() -> Void)?
    var onShowMessage: ((String) -> Void)?
    var onNavigateBack: (() -> Void)?
    var onNavigateLog: (() -> Void)?

    private let appStore: AppStore
    private let ttsManager: ScenicQueueTTSManaging
    private var punchSpotSettingRequestGeneration = 0
    private(set) var ttsLoopAnchorText: String?

    init(appStore: AppStore = .shared, ttsManager: ScenicQueueTTSManaging = AliyunNLSTTSManager.shared) {
        self.appStore = appStore
        self.ttsManager = ttsManager
        let snapshot = appStore.scenicQueue.settingsSnapshot() ?? ScenicQueueSettingsSnapshot()
        selectedPunchSpotId = appStore.scenicQueue.punchSpotId.nonEmptyTrimmed
        selectedPunchSpotLabel = appStore.scenicQueue.punchSpotName
        shootMinute = snapshot.shootMinute.clamped(to: Limits.shootMinute)
        shootSecond = snapshot.shootSecond.clamped(to: Limits.shootSecond)
        firstAheadCount = snapshot.firstAheadCount.clamped(to: Limits.ahead)
        firstSms = snapshot.firstSms
        firstPhone = snapshot.firstPhone
        secondAheadCount = snapshot.secondAheadCount.clamped(to: Limits.ahead)
        secondSms = snapshot.secondSms
        secondPhone = snapshot.secondPhone
        broadcastIntervalSec = snapshot.broadcastIntervalSec.clamped(to: Limits.broadcastInterval)
        countdownThresholdSec = snapshot.countdownThresholdSec.clamped(to: Limits.readableThreshold)
        queueDistanceMeter = snapshot.queueDistanceMeter.clamped(to: Limits.queueDistance)
        queueTakeLimit = snapshot.queueTakeLimit.clamped(to: Limits.queueTakeLimit)
        missCallRequeueOffset = snapshot.missCallRequeueOffset.clamped(to: Limits.missCallRequeueOffset)
        showStartShootingButton = snapshot.showStartShootingButton
        autoCallAheadCount = snapshot.autoCallAheadCount.clamped(to: Limits.autoCallAheadCount)
        quickCallButtonEnabled = snapshot.quickCallButtonEnabled
        prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled
        businessOpen = snapshot.businessOpen
        businessStartTime = Self.normalizedTime(snapshot.businessStartTime, fallback: "10:00")
        businessEndTime = Self.normalizedTime(snapshot.businessEndTime, fallback: "20:00")
        customTTSText = appStore.scenicQueue.remark
        presetVoices = appStore.scenicQueue.presetVoices()
    }

    var selectedPunchSpotDisplayText: String {
        if let selectedPunchSpotId,
           let option = punchSpotOptions.first(where: { $0.id == selectedPunchSpotId }),
           !option.label.isEmpty {
            return option.label
        }
        if !selectedPunchSpotLabel.isEmpty {
            return selectedPunchSpotLabel
        }
        if let selectedPunchSpotId, !selectedPunchSpotId.isEmpty {
            return "打卡点（ID \(selectedPunchSpotId)）"
        }
        return "请选择打卡点"
    }

    /// 是否优先使用离线语音合成。
    var useOfflineTTS: Bool {
        appStore.scenicQueue.useOfflineTTS
    }

    /// 自定义文案或预设文案是否正在循环播报。
    var isCustomTTSLooping: Bool {
        ttsManager.customTextLooping
    }

    /// 将米格式化为 Android 设置页使用的千米文本，最多保留两位小数。
    nonisolated static func kilometersDisplay(fromMeters meters: Int) -> String {
        let value = Double(max(0, meters)) / 1_000
        if value.rounded() == value { return String(format: "%.0f", value) }
        let text = String(format: "%.2f", value)
        return text.replacingOccurrences(of: #"0+$"#, with: "", options: .regularExpression)
            .replacingOccurrences(of: #"\.$"#, with: "", options: .regularExpression)
    }

    /// 将最多两位小数的千米文本转换为米；非法格式或越界时返回 nil。
    nonisolated static func meters(fromKilometersText raw: String) -> Int? {
        let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
        guard text.range(of: #"^\d{1,4}(\.\d{0,2})?$"#, options: .regularExpression) != nil,
              let value = Decimal(string: text, locale: Locale(identifier: "en_US_POSIX")) else { return nil }
        let meters = NSDecimalNumber(decimal: value * 1_000).intValue
        guard Limits.queueDistance.contains(meters) else { return nil }
        return meters
    }

    func openSettingChangeLog() {
        onNavigateLog?()
    }

    /// 打开打卡点选择器前加载全部打卡点。
    func loadPunchSpotOptions(api: ScenicQueueAPIProtocol) async {
        guard appStore.session.currentScenicId > 0 else {
            onShowMessage?("请先选择景区")
            return
        }
        do {
            let response = try await api.scenicSpotListAll(scenicId: String(appStore.session.currentScenicId))
            punchSpotOptions = response.list.map {
                ScenicQueuePunchSpotOption(id: String($0.id), label: $0.name)
            }
            if punchSpotOptions.isEmpty {
                onShowMessage?("暂无可选打卡点")
            }
            notifyStateChange()
        } catch {
            onShowMessage?(error.localizedDescription)
        }
    }

    /// 选择打卡点并尝试拉取服务端设置。
    func setPunchSpot(_ option: ScenicQueuePunchSpotOption, api: ScenicQueueAPIProtocol) async {
        selectedPunchSpotId = option.id
        selectedPunchSpotLabel = option.label.trimmingCharacters(in: .whitespacesAndNewlines)
        highlightedInvalidFieldKeys.remove("punchSpot")
        notifyStateChange()
        await fetchQueueSettingAfterPunchSpotSelected(option.id, api: api)
    }

    func setSettingsTab(_ tab: ScenicQueueSettingsTab) {
        settingsTab = tab
        notifyStateChange()
    }

    /// 记录数字输入框是否存在尚未修正的越界原始值，并同步红框状态。
    func reportOutOfRangeNumericPending(fieldKey: String, pending: Bool) {
        if pending {
            blockingInvalidNumericFieldKeys.insert(fieldKey)
            highlightedInvalidFieldKeys.insert(fieldKey)
        } else {
            blockingInvalidNumericFieldKeys.remove(fieldKey)
            highlightedInvalidFieldKeys.remove(fieldKey)
        }
        notifyStateChange()
    }

    func setShootMinute(_ value: Int) { shootMinute = value.clamped(to: Limits.shootMinute); notifyStateChange() }
    func setShootSecond(_ value: Int) { shootSecond = value.clamped(to: Limits.shootSecond); notifyStateChange() }
    func setFirstAheadCount(_ value: Int) { firstAheadCount = value.clamped(to: Limits.ahead); notifyStateChange() }
    func setSecondAheadCount(_ value: Int) { secondAheadCount = value.clamped(to: Limits.ahead); notifyStateChange() }
    func setBroadcastIntervalSec(_ value: Int) { broadcastIntervalSec = value.clamped(to: Limits.broadcastInterval); notifyStateChange() }
    func setCountdownThresholdSec(_ value: Int) { countdownThresholdSec = value.clamped(to: Limits.readableThreshold); notifyStateChange() }
    func setQueueDistanceMeter(_ value: Int) { queueDistanceMeter = value.clamped(to: Limits.queueDistance); notifyStateChange() }
    func setQueueTakeLimit(_ value: Int) { queueTakeLimit = value.clamped(to: Limits.queueTakeLimit); notifyStateChange() }
    func setMissCallRequeueOffset(_ value: Int) { missCallRequeueOffset = value.clamped(to: Limits.missCallRequeueOffset); notifyStateChange() }
    func setAutoCallAheadCount(_ value: Int) { autoCallAheadCount = value.clamped(to: Limits.autoCallAheadCount); notifyStateChange() }
    func toggleShowStartShootingButton() { showStartShootingButton.toggle(); notifyStateChange() }
    func toggleQuickCallButtonEnabled() { quickCallButtonEnabled.toggle(); notifyStateChange() }
    func togglePrepareCallButtonEnabled() { prepareCallButtonEnabled.toggle(); notifyStateChange() }
    func toggleBusinessOpen() { businessOpen.toggle(); notifyStateChange() }
    func toggleFirstSms() { firstSms.toggle(); notifyStateChange() }
    func toggleFirstPhone() { firstPhone.toggle(); notifyStateChange() }
    func toggleSecondSms() { secondSms.toggle(); notifyStateChange() }
    func toggleSecondPhone() { secondPhone.toggle(); notifyStateChange() }

    func setBusinessStartTime(_ value: String) {
        businessStartTime = Self.normalizedTime(value, fallback: businessStartTime)
        notifyStateChange()
    }

    func setBusinessEndTime(_ value: String) {
        businessEndTime = Self.normalizedTime(value, fallback: businessEndTime)
        notifyStateChange()
    }

    /// 设置离线语音合成偏好。
    func setUseOfflineTTS(_ enabled: Bool) {
        guard enabled else {
            appStore.scenicQueue.useOfflineTTS = false
            notifyStateChange()
            return
        }
        guard ttsManager.supportsOfflineTTS else {
            appStore.scenicQueue.useOfflineTTS = false
            onShowMessage?("离线语音资源未安装")
            notifyStateChange()
            return
        }
        appStore.scenicQueue.useOfflineTTS = true
        notifyStateChange()
    }

    func setCustomTTSText(_ value: String) {
        customTTSText = String(value.prefix(Limits.remarkMaxLen))
        notifyStateChange()
    }

    /// 播放或停止自定义 TTS 文案。
    func toggleCustomTTSPlayback() {
        if ttsManager.customTextLooping {
            ttsManager.stopCustomTextLoop()
            ttsLoopAnchorText = nil
            notifyStateChange()
            return
        }
        let text = customTTSText.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !text.isEmpty else {
            onShowMessage?("请输入要播报的文字")
            return
        }
        Task {
            let ok = await ttsManager.startCustomTextLoop(text)
            if ok {
                ttsLoopAnchorText = text
            } else {
                ttsLoopAnchorText = nil
                onShowMessage?("播报失败，请检查网络与语音服务")
            }
            notifyStateChange()
        }
    }

    /// 循环播放指定预设文案；若已有文案播放则先停止再切换。
    func startPresetVoiceLoop(_ phrase: String) {
        let text = String(phrase.trimmingCharacters(in: .whitespacesAndNewlines).prefix(Limits.remarkMaxLen))
        guard !text.isEmpty else { return }
        Task {
            if ttsManager.customTextLooping {
                ttsManager.stopCustomTextLoop()
            }
            let ok = await ttsManager.startCustomTextLoop(text)
            ttsLoopAnchorText = ok ? text : nil
            if !ok { onShowMessage?("播报失败，请检查网络与语音服务") }
            notifyStateChange()
        }
    }

    /// 播放蓝牙测试音。
    func playBluetoothTestSound() {
        Task {
            let ok = await ttsManager.speakPlainTextOnce("蓝牙音响测试音，请确认外放音量是否合适。")
            if !ok {
                onShowMessage?("测试音播放失败，请检查网络与语音服务")
            }
        }
    }

    /// 保存当前文案到本地预设。
    func saveCurrentTextAsPresetVoiceLocal() {
        guard appStore.session.currentScenicId > 0, selectedPunchSpotId?.nonEmptyTrimmed != nil else {
            onShowMessage?("请先选择打卡点")
            return
        }
        let text = customTTSText.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !text.isEmpty else {
            onShowMessage?("请输入要播报的文字")
            return
        }
        if presetVoices.count >= 5 {
            onShowMessage?("最多保存5个语音")
            return
        }
        if presetVoices.contains(text) {
            onShowMessage?("该文案已在列表中")
            return
        }
        presetVoices.append(text)
        appStore.scenicQueue.savePresetVoices(presetVoices)
        onShowMessage?("已保存到本地")
        notifyStateChange()
    }

    func deletePresetVoice(at index: Int) {
        guard presetVoices.indices.contains(index) else { return }
        let removed = presetVoices.remove(at: index)
        appStore.scenicQueue.savePresetVoices(presetVoices)
        if ttsLoopAnchorText == removed {
            ttsManager.stopCustomTextLoop()
            ttsLoopAnchorText = nil
        }
        notifyStateChange()
    }

    /// 保存排队打卡点二维码到相册。
    func saveShootQueueQRCode(api: ScenicQueueAPIProtocol) async {
        guard let ids = currentScenicAndSpotIdsForSelected() else {
            onShowMessage?("请先选择打卡点")
            return
        }
        do {
            let data = try await api.shootQueueQrcode(scenicId: ids.scenicId, scenicSpotId: ids.scenicSpotId)
            guard let url = data.qrcodeUrl?.trimmingCharacters(in: .whitespacesAndNewlines), !url.isEmpty else {
                onShowMessage?("服务端未返回二维码图片地址")
                return
            }
            try await ScenicQueueQRCodeSaver.saveImage(from: url)
            onShowMessage?("保存成功")
        } catch {
            onShowMessage?((error as? LocalizedError)?.errorDescription ?? error.localizedDescription)
        }
    }

    /// 校验并保存设置。
    func save(api: ScenicQueueAPIProtocol) async {
        highlightedInvalidFieldKeys = blockingInvalidNumericFieldKeys
        guard let ids = currentScenicAndSpotIdsForSelected() else {
            highlightedInvalidFieldKeys = ["punchSpot"]
            settingsTab = .basic
            onShowMessage?("请选择打卡点")
            notifyStateChange()
            return
        }
        guard blockingInvalidNumericFieldKeys.isEmpty else {
            settingsTab = tabForValidationKeys(blockingInvalidNumericFieldKeys)
            onShowMessage?("请修正标红的配置项")
            notifyStateChange()
            return
        }
        let validation = buildSaveValidation()
        guard validation.messages.isEmpty else {
            highlightedInvalidFieldKeys = validation.keys
            settingsTab = tabForValidationKeys(validation.keys)
            onShowMessage?(validation.messages.joined(separator: "\n"))
            notifyStateChange()
            return
        }
        let remark = customTTSText.trimmingCharacters(in: .whitespacesAndNewlines)
        let request = ScenicQueueSaveSettingRequest(
            scenicId: ids.scenicId,
            scenicSpotId: ids.scenicSpotId,
            photoEstimateMin: shootMinute,
            photoEstimateSec: shootSecond,
            firstNoticeThresholdPos: firstAheadCount,
            firstNoticeSmsEnabled: firstSms ? 1 : 0,
            firstNoticeCallEnabled: firstPhone ? 1 : 0,
            secondNoticeThresholdPos: secondAheadCount,
            secondNoticeSmsEnabled: secondSms ? 1 : 0,
            secondNoticeCallEnabled: secondPhone ? 1 : 0,
            countdownBroadcastIntervalSec: broadcastIntervalSec,
            countdownReadableThresholdSec: countdownThresholdSec,
            queueDistanceMeter: queueDistanceMeter,
            queueTakeLimit: queueTakeLimit,
            missCallRequeueOffset: missCallRequeueOffset,
            showStartShootButton: showStartShootingButton ? 1 : 0,
            autoCallNextCount: autoCallAheadCount,
            businessStartTime: businessStartTime,
            businessEndTime: businessEndTime,
            voiceBroadcasts: presetVoices.enumerated().map { ScenicQueueVoiceBroadcastItem(content: $0.element, sortOrder: $0.offset + 1) },
            status: businessOpen ? 1 : 0,
            remark: remark.isEmpty ? nil : remark
        )
        do {
            try await api.saveSetting(request)
            appStore.scenicQueue.punchSpotId = String(ids.scenicSpotId)
            appStore.scenicQueue.punchSpotName = selectedPunchSpotLabel
            appStore.scenicQueue.remark = remark
            appStore.scenicQueue.savePresetVoices(presetVoices)
            appStore.scenicQueue.saveSettingsSnapshot(currentSnapshot())
            _ = try? await api.stats(scenicId: ids.scenicId, scenicSpotId: ids.scenicSpotId)
            onShowMessage?("保存成功")
            onNavigateBack?()
        } catch {
            onShowMessage?(error.localizedDescription)
        }
    }

    private func fetchQueueSettingAfterPunchSpotSelected(_ spotId: String, api: ScenicQueueAPIProtocol) async {
        guard let scenicId = Int64("\(appStore.session.currentScenicId)"),
              let scenicSpotId = Int64(spotId),
              scenicId > 0,
              scenicSpotId > 0 else { return }
        punchSpotSettingRequestGeneration += 1
        let generation = punchSpotSettingRequestGeneration
        do {
            let data = try await api.setting(scenicId: scenicId, scenicSpotId: scenicSpotId)
            guard generation == punchSpotSettingRequestGeneration,
                  selectedPunchSpotId == spotId,
                  data.exists,
                  let setting = data.setting else { return }
            applyServerSettingToForm(setting)
        } catch {
            // Keep local form values if remote setting cannot be loaded.
        }
    }

    private func applyServerSettingToForm(_ setting: ScenicQueueSettingItem) {
        let fallback = ScenicQueueSettingsSnapshot(
            showStartShootingButton: showStartShootingButton,
            autoCallAheadCount: autoCallAheadCount,
            quickCallButtonEnabled: quickCallButtonEnabled,
            prepareCallButtonEnabled: prepareCallButtonEnabled
        )
        let snapshot = setting.toSnapshot(fallback: fallback)
        apply(snapshot)
        customTTSText = String((setting.remark ?? "").prefix(Limits.remarkMaxLen))
        if let voices = setting.voiceBroadcasts {
            presetVoices = voices
                .sorted { ($0.sortOrder ?? Int.max) < ($1.sortOrder ?? Int.max) }
                .compactMap { $0.content?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmptyTrimmed }
                .prefix(5)
                .map { $0 }
        }
        if let name = setting.scenicSpotName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmptyTrimmed {
            selectedPunchSpotLabel = name
        }
        highlightedInvalidFieldKeys = []
        notifyStateChange()
    }

    private func apply(_ snapshot: ScenicQueueSettingsSnapshot) {
        shootMinute = snapshot.shootMinute.clamped(to: Limits.shootMinute)
        shootSecond = snapshot.shootSecond.clamped(to: Limits.shootSecond)
        firstAheadCount = snapshot.firstAheadCount.clamped(to: Limits.ahead)
        firstSms = snapshot.firstSms
        firstPhone = snapshot.firstPhone
        secondAheadCount = snapshot.secondAheadCount.clamped(to: Limits.ahead)
        secondSms = snapshot.secondSms
        secondPhone = snapshot.secondPhone
        broadcastIntervalSec = snapshot.broadcastIntervalSec.clamped(to: Limits.broadcastInterval)
        countdownThresholdSec = snapshot.countdownThresholdSec.clamped(to: Limits.readableThreshold)
        queueDistanceMeter = snapshot.queueDistanceMeter.clamped(to: Limits.queueDistance)
        queueTakeLimit = snapshot.queueTakeLimit.clamped(to: Limits.queueTakeLimit)
        missCallRequeueOffset = snapshot.missCallRequeueOffset.clamped(to: Limits.missCallRequeueOffset)
        showStartShootingButton = snapshot.showStartShootingButton
        autoCallAheadCount = snapshot.autoCallAheadCount.clamped(to: Limits.autoCallAheadCount)
        quickCallButtonEnabled = snapshot.quickCallButtonEnabled
        prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled
        businessOpen = snapshot.businessOpen
        businessStartTime = Self.normalizedTime(snapshot.businessStartTime, fallback: "10:00")
        businessEndTime = Self.normalizedTime(snapshot.businessEndTime, fallback: "20:00")
    }

    private func currentSnapshot() -> ScenicQueueSettingsSnapshot {
        ScenicQueueSettingsSnapshot(
            shootMinute: shootMinute,
            shootSecond: shootSecond,
            firstAheadCount: firstAheadCount,
            firstSms: firstSms,
            firstPhone: firstPhone,
            secondAheadCount: secondAheadCount,
            secondSms: secondSms,
            secondPhone: secondPhone,
            broadcastIntervalSec: broadcastIntervalSec,
            countdownThresholdSec: countdownThresholdSec,
            queueDistanceMeter: queueDistanceMeter,
            queueTakeLimit: queueTakeLimit,
            missCallRequeueOffset: missCallRequeueOffset,
            showStartShootingButton: showStartShootingButton,
            autoCallAheadCount: autoCallAheadCount,
            quickCallButtonEnabled: quickCallButtonEnabled,
            prepareCallButtonEnabled: prepareCallButtonEnabled,
            businessOpen: businessOpen,
            businessStartTime: businessStartTime,
            businessEndTime: businessEndTime
        )
    }

    private func buildSaveValidation() -> (messages: [String], keys: Set<String>) {
        var messages: [String] = []
        var keys: Set<String> = []
        if !Limits.shootMinute.contains(shootMinute) {
            messages.append("拍摄时长：分钟须在 0～30，且按 1 分钟递增")
            keys.insert(ScenicQueueNumericFieldKey.shootMinute.rawValue)
        }
        if !Limits.shootSecond.contains(shootSecond) {
            messages.append("拍摄时长：秒须在 0～59")
            keys.insert(ScenicQueueNumericFieldKey.shootSecond.rawValue)
        }
        if shootMinute == 0 && shootSecond == 0 {
            messages.append("拍摄时长须大于 0")
            keys.insert(ScenicQueueNumericFieldKey.shootMinute.rawValue)
            keys.insert(ScenicQueueNumericFieldKey.shootSecond.rawValue)
        }
        if !Limits.broadcastInterval.contains(broadcastIntervalSec) {
            messages.append("播报间隔时间须在 40～60 秒")
            keys.insert(ScenicQueueNumericFieldKey.broadcastIntervalSec.rawValue)
        }
        if !Limits.readableThreshold.contains(countdownThresholdSec) {
            messages.append("进入读秒倒计时阈值须在 10～30 秒")
            keys.insert(ScenicQueueNumericFieldKey.countdownReadableThresholdSec.rawValue)
        }
        return (messages, keys)
    }

    private func tabForValidationKeys(_ keys: Set<String>) -> ScenicQueueSettingsTab {
        if keys.contains("punchSpot")
            || keys.contains(ScenicQueueNumericFieldKey.queueDistanceMeter.rawValue)
            || keys.contains(ScenicQueueNumericFieldKey.shootMinute.rawValue)
            || keys.contains(ScenicQueueNumericFieldKey.shootSecond.rawValue)
            || keys.contains(ScenicQueueNumericFieldKey.queueTakeLimit.rawValue)
            || keys.contains(ScenicQueueNumericFieldKey.missCallRequeueOffset.rawValue) {
            return .basic
        }
        if keys.contains(ScenicQueueNumericFieldKey.firstNoticeAhead.rawValue)
            || keys.contains(ScenicQueueNumericFieldKey.secondNoticeAhead.rawValue) {
            return .notify
        }
        return .voice
    }

    private func currentScenicAndSpotIdsForSelected() -> (scenicId: Int64, scenicSpotId: Int64)? {
        guard let scenicId = Int64("\(appStore.session.currentScenicId)"), scenicId > 0,
              let spotIdText = selectedPunchSpotId?.trimmingCharacters(in: .whitespacesAndNewlines),
              let scenicSpotId = Int64(spotIdText), scenicSpotId > 0 else { return nil }
        return (scenicId, scenicSpotId)
    }

    private static func normalizedTime(_ raw: String, fallback: String) -> String {
        let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
        let formatter = DateFormatter()
        formatter.locale = Locale(identifier: "zh_CN")
        for format in ["HH:mm", "HH:mm:ss"] {
            formatter.dateFormat = format
            if let date = formatter.date(from: text) {
                formatter.dateFormat = "HH:mm"
                return formatter.string(from: date)
            }
        }
        return fallback
    }

    private func notifyStateChange() {
        onStateChange?()
    }
}

private extension Int {
    func clamped(to range: ClosedRange<Int>) -> Int {
        Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
    }
}

private extension String {
    var nonEmptyTrimmed: String? {
        isEmpty ? nil : self
    }
}
