//
//  ScenicQueueFeatureTests.swift
//  suixinkanTests
//

import XCTest
import UIKit
@testable import suixinkan

@MainActor
/// 排队管理同步功能测试。
final class ScenicQueueFeatureTests: XCTestCase {

    func testModelDecodingAndMapping() throws {
        let homeJSON = """
        {
          "stats":{"type":1},
          "list":{
            "list":[{
              "id":101,
              "queue_code":"A12",
              "mobile":"13800138000",
              "status":1,
              "status_text":"待叫号",
              "wait_min":8,
              "ahead_count":2,
              "is_called":0,
              "created_at":"2026-07-07 10:20:00",
              "identity_tag":"VIP",
              "queue_ban_label":"限制1天",
              "queue_time":"2026-07-07 10:20:00",
              "queue_count_today":3,
              "uid":88,
              "mark_as_photog":0,
              "mark_as_freelance_photog":1,
              "is_miss_requeue":1,
              "is_miss_requeue_text":"过号重排"
            }],
            "total":1,
            "page":1,
            "page_size":10
          },
          "time":"2026-07-07 10:21:00"
        }
        """.data(using: .utf8)!

        let home = try JSONDecoder().decode(ScenicQueueHomeData.self, from: homeJSON)
        let ticket = try XCTUnwrap(home.list?.list.first?.toCurrentTicket())

        XCTAssertEqual(ticket.recordId, 101)
        XCTAssertEqual(ticket.queueNo, "A12")
        XCTAssertEqual(ticket.phoneMasked, "138****8000")
        XCTAssertEqual(ticket.dialPhoneDigits, "13800138000")
        XCTAssertEqual(ticket.status, .waiting)
        XCTAssertTrue(ticket.shouldShowIdentityTag)
        XCTAssertEqual(ticket.missRequeueText, "过号重排")
    }

    func testHomeDecodingAcceptsBackendStatsWithoutNestedType() throws {
        let json = """
        {
          "stats":{"avg_wait_min":0,"queue_count":0},
          "list":{"list":[],"page":1,"page_size":10,"total":0},
          "type":1
        }
        """.data(using: .utf8)!

        let home = try JSONDecoder().decode(ScenicQueueHomeData.self, from: json)

        XCTAssertEqual(home.stats?.type, 0)
        XCTAssertEqual(home.list?.list, [])
        XCTAssertEqual(home.list?.page, 1)
        XCTAssertEqual(home.list?.pageSize, 10)
        XCTAssertEqual(home.list?.total, 0)
    }

    func testSettingDecodingClampsSnapshotAndSocketMessage() throws {
        let settingJSON = """
        {
          "exists":true,
          "setting":{
            "id":7,
            "scenic_id":11,
            "scenic_spot_id":22,
            "scenic_spot_name":"东门打卡点",
            "photo_estimate_min":40,
            "photo_estimate_sec":70,
            "first_notice_threshold_pos":60,
            "first_notice_sms_enabled":1,
            "first_notice_call_enabled":0,
            "second_notice_threshold_pos":2,
            "second_notice_sms_enabled":0,
            "second_notice_call_enabled":1,
            "countdown_broadcast_interval_sec":99,
            "countdown_readable_threshold_sec":2,
            "queue_distance_meter":10000000,
            "queue_take_limit":1000000,
            "miss_call_requeue_offset":0,
            "show_start_shoot_button":0,
            "auto_call_next_count":9,
            "business_start_time":"09:30",
            "business_end_time":"18:00",
            "voice_broadcasts":[{"content":"请提前准备","sort_order":1}],
            "status":0,
            "remark":"自定义播报"
          }
        }
        """.data(using: .utf8)!
        let data = try JSONDecoder().decode(ScenicQueueSettingData.self, from: settingJSON)
        let snapshot = try XCTUnwrap(data.setting).toSnapshot(fallback: ScenicQueueSettingsSnapshot())

        XCTAssertEqual(snapshot.shootMinute, 30)
        XCTAssertEqual(snapshot.shootSecond, 59)
        XCTAssertEqual(snapshot.firstAheadCount, 50)
        XCTAssertEqual(snapshot.broadcastIntervalSec, 50)
        XCTAssertEqual(snapshot.countdownThresholdSec, 15)
        XCTAssertEqual(snapshot.queueDistanceMeter, 9_999_990)
        XCTAssertEqual(snapshot.queueTakeLimit, 999_999)
        XCTAssertEqual(snapshot.missCallRequeueOffset, 1)
        XCTAssertFalse(snapshot.showStartShootingButton)
        XCTAssertEqual(snapshot.autoCallAheadCount, 5)
        XCTAssertFalse(snapshot.businessOpen)

        let socketJSON = """
        {"code":100000,"data":{"action":"scenic_queue_ticket_called","params":{"scenic_spot_id":22,"record_id":101,"operator_uid":99,"event_id":"evt-1"}}}
        """.data(using: .utf8)!
        let message = try JSONDecoder().decode(ScenicQueueSocketMessage.self, from: socketJSON)
        XCTAssertTrue(message.isQueueEvent)
        XCTAssertEqual(message.data?.params?.eventId, "evt-1")

        let sts = try MockScenicQueueAPI.makeSTS()
        XCTAssertEqual(sts.bucket, "vipsky")
        XCTAssertEqual(sts.credentials.securityToken, "security-token")
    }

    func testSettingsValidationSaveRequestAndPresetLimit() async {
        let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.settings")!
        defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.settings")
        let store = AppStore(defaults: defaults)
        store.session.userId = "u1"
        store.session.accountType = .unknown("photog")
        store.session.currentScenicId = 11
        let api = MockScenicQueueAPI()
        let tts = MockScenicQueueTTSManager()
        let viewModel = ScenicQueueSettingsViewModel(appStore: store, ttsManager: tts)
        var messages: [String] = []
        viewModel.onShowMessage = { messages.append($0) }

        await viewModel.save(api: api)
        XCTAssertEqual(viewModel.settingsTab, .basic)
        XCTAssertTrue(viewModel.highlightedInvalidFieldKeys.contains("punchSpot"))
        XCTAssertEqual(messages.last, "请选择打卡点")

        await viewModel.setPunchSpot(ScenicQueuePunchSpotOption(id: "22", label: "东门打卡点"), api: api)
        viewModel.setShootMinute(9)
        viewModel.setShootSecond(30)
        viewModel.setFirstAheadCount(4)
        viewModel.toggleFirstSms()
        viewModel.toggleSecondPhone()
        viewModel.setBroadcastIntervalSec(40)
        viewModel.setCountdownThresholdSec(30)
        viewModel.setQueueDistanceMeter(500)
        viewModel.setQueueTakeLimit(6)
        viewModel.setMissCallRequeueOffset(2)
        viewModel.setAutoCallAheadCount(3)
        viewModel.setCustomTTSText("请准备拍摄")
        viewModel.saveCurrentTextAsPresetVoiceLocal()
        viewModel.setCustomTTSText("第二条")
        viewModel.saveCurrentTextAsPresetVoiceLocal()
        await viewModel.save(api: api)

        let request = api.savedSetting
        XCTAssertEqual(request?.scenicId, 11)
        XCTAssertEqual(request?.scenicSpotId, 22)
        XCTAssertEqual(request?.photoEstimateMin, 9)
        XCTAssertEqual(request?.photoEstimateSec, 30)
        XCTAssertEqual(request?.firstNoticeThresholdPos, 4)
        XCTAssertEqual(request?.firstNoticeSmsEnabled, 1)
        XCTAssertEqual(request?.secondNoticeCallEnabled, 1)
        XCTAssertEqual(request?.countdownBroadcastIntervalSec, 40)
        XCTAssertEqual(request?.countdownReadableThresholdSec, 30)
        XCTAssertEqual(request?.queueDistanceMeter, 500)
        XCTAssertEqual(request?.queueTakeLimit, 6)
        XCTAssertEqual(request?.missCallRequeueOffset, 2)
        XCTAssertEqual(request?.autoCallNextCount, 3)
        XCTAssertEqual(request?.voiceBroadcasts.map(\.content), ["请准备拍摄", "第二条"])
        XCTAssertEqual(store.scenicQueue.punchSpotName, "东门打卡点")
        XCTAssertEqual(store.scenicQueue.settingsSnapshot()?.shootMinute, 9)
    }

    func testMainViewModelGatePagingActionsTTSAndSocketFiltering() async {
        let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.main")!
        defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.main")
        let store = AppStore(defaults: defaults)
        store.session.userId = "9"
        store.session.accountType = .unknown("photog")
        store.session.currentScenicId = 11
        store.scenicQueue.punchSpotId = "22"
        store.scenicQueue.punchSpotName = "东门打卡点"
        store.scenicQueue.saveSettingsSnapshot(
            ScenicQueueSettingsSnapshot(showStartShootingButton: true, quickCallButtonEnabled: true, prepareCallButtonEnabled: true)
        )
        let api = MockScenicQueueAPI()
        api.homePages[1] = .page(
            items: [.item(id: 101, queueCode: "A12", isCalled: 0), .item(id: 102, queueCode: "A13", isCalled: 0)],
            total: 3,
            page: 1
        )
        api.homePages[2] = .page(
            items: [.item(id: 102, queueCode: "A13", isCalled: 0), .item(id: 103, queueCode: "A14", isCalled: 0)],
            total: 3,
            page: 2
        )
        let tts = MockScenicQueueTTSManager()
        let socket = ScenicQueueSocketClient()
        let viewModel = ScenicQueueViewModel(appStore: store, ttsManager: tts, socketClient: socket)

        await viewModel.refreshQueueGate(api: api)
        XCTAssertTrue(viewModel.queueGatePassed)
        XCTAssertEqual(viewModel.selectedPunchSpotLine, "东门打卡点")
        XCTAssertEqual(viewModel.summary, ScenicQueueSummary(peopleInQueue: 3, avgWaitMinutes: 6))
        XCTAssertEqual(viewModel.currentQueue.map(\.recordId), [101, 102])

        await viewModel.loadMoreCurrentQueue(api: api)
        XCTAssertEqual(viewModel.currentQueue.map(\.recordId), [101, 102, 103])

        viewModel.requestCallConfirm(recordId: 101, queueNo: "A12")
        await viewModel.confirmCallTicket(api: api)
        XCTAssertEqual(viewModel.currentQueue.first?.status, .called)
        XCTAssertEqual(tts.events.last, "start:A12")

        viewModel.requestSkipTicket(recordId: 101, queueNo: "A12")
        await viewModel.confirmSkipTicket(api: api)
        XCTAssertFalse(viewModel.currentQueue.contains { $0.recordId == 101 })
        XCTAssertTrue(tts.events.contains("stopQueue"))

        let calledMessage = ScenicQueueSocketMessage(
            code: 100000,
            data: ScenicQueueSocketData(
                action: ScenicQueueSocketAction.ticketCalled.rawValue,
                params: ScenicQueueSocketParams(scenicSpotId: 22, recordId: 103, operatorUid: 88, eventId: "evt-remote")
            )
        )
        socket.onMessage?(calledMessage)
        api.homePages[1] = .page(items: [.item(id: 103, queueCode: "A14", isCalled: 1)], total: 1, page: 1)
        await viewModel.refreshCurrentQueue(api: api)
        XCTAssertEqual(tts.events.last, "start:A14")

        socket.onMessage?(calledMessage)
        await viewModel.refreshCurrentQueue(api: api)
        XCTAssertEqual(tts.events.filter { $0 == "start:A14" }.count, 1)

        let ownMessage = ScenicQueueSocketMessage(
            code: 100000,
            data: ScenicQueueSocketData(
                action: ScenicQueueSocketAction.ticketCalled.rawValue,
                params: ScenicQueueSocketParams(scenicSpotId: 22, recordId: 103, operatorUid: 9, eventId: "evt-own")
            )
        )
        socket.onMessage?(ownMessage)
        await viewModel.refreshCurrentQueue(api: api)
        XCTAssertEqual(tts.events.filter { $0 == "start:A14" }.count, 1)
    }

    func testAppStoreQueueCacheIsAccountScopedAndClearedOnLogout() {
        let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.store")!
        defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.store")
        let store = AppStore(defaults: defaults)

        store.session.userId = "1"
        store.session.accountType = .unknown("photog")
        store.session.currentScenicId = 11
        store.scenicQueue.punchSpotId = "22"
        store.scenicQueue.punchSpotName = "东门打卡点"
        store.scenicQueue.remark = "播报"
        store.scenicQueue.saveSettingsSnapshot(ScenicQueueSettingsSnapshot(shootMinute: 3))
        store.scenicQueue.savePresetVoices(["a", "b", "c", "d", "e", "f"])

        store.session.userId = "2"
        store.session.accountType = .unknown("photog")
        store.session.currentScenicId = 11
        XCTAssertEqual(store.scenicQueue.punchSpotId, "")
        XCTAssertNil(store.scenicQueue.settingsSnapshot())

        store.session.userId = "1"
        store.session.accountType = .unknown("photog")
        store.session.currentScenicId = 11
        XCTAssertEqual(store.scenicQueue.punchSpotId, "22")
        XCTAssertEqual(store.scenicQueue.presetVoices(), ["a", "b", "c", "d", "e"])
        store.logout()
        store.session.userId = "1"
        store.session.accountType = .unknown("photog")
        store.session.currentScenicId = 11
        XCTAssertEqual(store.scenicQueue.punchSpotId, "")
        XCTAssertNil(store.scenicQueue.settingsSnapshot())
    }

    func testAliyunNLSTokenProviderBuildsSignedCreateTokenRequest() async throws {
        let credentials = try MockScenicQueueAPI.makeSTS().credentials
        let provider = AliyunNLSTokenProvider(
            dateProvider: { Date(timeIntervalSince1970: 1_704_067_200) },
            nonceProvider: { "nonce-1" },
            dataLoader: { request in
                let url = try XCTUnwrap(request.url)
                let query = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems)
                XCTAssertEqual(query.first(where: { $0.name == "Action" })?.value, "CreateToken")
                XCTAssertEqual(query.first(where: { $0.name == "AccessKeyId" })?.value, "ak")
                XCTAssertEqual(query.first(where: { $0.name == "SecurityToken" })?.value, "security-token")
                XCTAssertEqual(query.first(where: { $0.name == "SignatureMethod" })?.value, "HMAC-SHA1")
                XCTAssertNotNil(query.first(where: { $0.name == "Signature" })?.value)
                return #"{"Token":{"Id":"nls-token","ExpireTime":1704068200}}"#.data(using: .utf8)!
            }
        )

        let token = try await provider.createToken(credentials: credentials)

        XCTAssertEqual(token.id, "nls-token")
        XCTAssertEqual(token.expireTime, 1_704_068_200)
    }

    func testAliyunTTSManagerUsesNativeBridgeAndPlayerWhenPrepared() async {
        let api = MockScenicQueueAPI()
        let tokenProvider = MockAliyunNLSTokenProvider()
        let bridge = MockAliyunNuiTTSBridge()
        let player = MockAliyunPCMStreamingPlayer()
        let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.onlineTTS")!
        defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.onlineTTS")
        let store = AppStore(defaults: defaults)
        store.session.userId = "u1"
        store.session.accountType = .unknown("photog")
        store.session.currentScenicId = 11
        store.scenicQueue.useOfflineTTS = false
        let manager = AliyunNLSTTSManager(
            appStore: store,
            tokenProvider: tokenProvider,
            bridge: bridge,
            streamingPlayer: player
        )

        await manager.prepareQueueTTSEngine(api: api)
        let ok = await manager.speakPlainTextOnce("A12号，请到拍摄区域，准备拍摄。")

        XCTAssertTrue(ok)
        XCTAssertTrue(bridge.events.contains { $0.hasPrefix("initialize:") })
        XCTAssertTrue(bridge.events.contains("set:speech_rate=-120"))
        XCTAssertTrue(bridge.events.contains("set:volume=2.0"))
        XCTAssertTrue(bridge.events.contains("play:A12号，请到拍摄区域，准备拍摄。"))
        XCTAssertEqual(Array(player.events.suffix(3)), ["start:16000", "write:4", "drain"])
    }

    func testAliyunTTSManagerInitializesOfflineEngineWithBundledVoice() async throws {
        let api = MockScenicQueueAPI()
        let tokenProvider = MockAliyunNLSTokenProvider()
        let bridge = MockAliyunNuiTTSBridge()
        bridge.paramValues["model_sample_rate"] = "24000"
        let player = MockAliyunPCMStreamingPlayer()
        let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.nativeOfflineTTS")!
        defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.nativeOfflineTTS")
        let store = AppStore(defaults: defaults)
        store.session.userId = "u1"
        store.session.accountType = .unknown("photog")
        store.session.currentScenicId = 11
        store.scenicQueue.useOfflineTTS = true

        let workspace = FileManager.default.temporaryDirectory
            .appendingPathComponent("ScenicQueueFeatureTests-\(UUID().uuidString)", isDirectory: true)
        let voiceDirectory = workspace.appendingPathComponent("tts/voices", isDirectory: true)
        try FileManager.default.createDirectory(at: voiceDirectory, withIntermediateDirectories: true)
        try Data([0]).write(to: workspace.appendingPathComponent("tts/languagedata_embedded.bin"))
        try Data([0]).write(to: workspace.appendingPathComponent("tts/parameter.cfg"))
        try Data([0]).write(to: voiceDirectory.appendingPathComponent("aijia"))
        defer { try? FileManager.default.removeItem(at: workspace) }

        let manager = AliyunNLSTTSManager(
            appStore: store,
            tokenProvider: tokenProvider,
            bridge: bridge,
            streamingPlayer: player,
            workspacePathProvider: { workspace.path }
        )

        XCTAssertTrue(manager.supportsOfflineTTS)
        await manager.prepareQueueTTSEngine(api: api)
        let ok = await manager.speakPlainTextOnce("请到拍摄区域")

        XCTAssertTrue(ok)
        let ticketData = try XCTUnwrap(bridge.initializedTickets.last?.data(using: .utf8))
        let ticket = try XCTUnwrap(JSONSerialization.jsonObject(with: ticketData) as? [String: String])
        XCTAssertEqual(ticket["app_key"], "gfWimmOuTku1AbIV")
        XCTAssertEqual(ticket["sdk_code"], "software_nls_tts_offline_standard")
        XCTAssertEqual(ticket["mode_type"], "0")
        XCTAssertEqual(ticket["workspace"], workspace.path)
        XCTAssertEqual(ticket["ak_id"], "ak")
        XCTAssertEqual(ticket["sts_token"], "security-token")
        XCTAssertTrue(bridge.events.contains { $0.hasPrefix("set:extend_font_name=") && $0.hasSuffix("/tts/voices/aijia") })
        XCTAssertTrue(bridge.events.contains("set:encode_type=pcm"))
        XCTAssertTrue(bridge.events.contains("set:sample_rate=24000"))
        XCTAssertTrue(bridge.events.contains("set:speed_level=1.0"))
        XCTAssertFalse(bridge.events.contains("set:mode_type=2"))
        XCTAssertEqual(Array(player.events.suffix(3)), ["start:24000", "write:4", "drain"])
    }

    func testSettingsOfflineTTSSwitchReportsUnavailable() {
        let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.offlineTTS")!
        defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.offlineTTS")
        let store = AppStore(defaults: defaults)
        store.session.userId = "u1"
        store.session.accountType = .unknown("photog")
        store.session.currentScenicId = 11
        XCTAssertTrue(store.scenicQueue.useOfflineTTS)
        let tts = MockScenicQueueTTSManager()
        let viewModel = ScenicQueueSettingsViewModel(appStore: store, ttsManager: tts)
        var messages: [String] = []
        viewModel.onShowMessage = { messages.append($0) }

        viewModel.setUseOfflineTTS(true)

        XCTAssertFalse(store.scenicQueue.useOfflineTTS)
        XCTAssertEqual(messages.last, "离线语音资源未安装")
    }

    func testQueueDistanceKilometerConversionAndInvalidFormats() {
        XCTAssertEqual(ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: 0), "0")
        XCTAssertEqual(ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: 500), "0.5")
        XCTAssertEqual(ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: 1_230), "1.23")
        XCTAssertEqual(ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: 9_999_990), "9999.99")
        XCTAssertEqual(ScenicQueueSettingsViewModel.meters(fromKilometersText: "0.50"), 500)
        XCTAssertEqual(ScenicQueueSettingsViewModel.meters(fromKilometersText: "9999.99"), 9_999_990)
        XCTAssertNil(ScenicQueueSettingsViewModel.meters(fromKilometersText: "1.234"))
        XCTAssertNil(ScenicQueueSettingsViewModel.meters(fromKilometersText: "10000"))
        XCTAssertNil(ScenicQueueSettingsViewModel.meters(fromKilometersText: "公里"))
    }

    func testInvalidNumericPendingBlocksSaveAndSelectsMatchingTab() async {
        let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.invalidNumeric")!
        defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.invalidNumeric")
        let store = AppStore(defaults: defaults)
        store.session.userId = "u1"
        store.session.accountType = .unknown("photog")
        store.session.currentScenicId = 11
        let api = MockScenicQueueAPI()
        let viewModel = ScenicQueueSettingsViewModel(appStore: store, ttsManager: MockScenicQueueTTSManager())
        await viewModel.setPunchSpot(ScenicQueuePunchSpotOption(id: "22", label: "东门打卡点"), api: api)

        viewModel.reportOutOfRangeNumericPending(fieldKey: ScenicQueueNumericFieldKey.firstNoticeAhead.rawValue, pending: true)
        await viewModel.save(api: api)

        XCTAssertNil(api.savedSetting)
        XCTAssertEqual(viewModel.settingsTab, .notify)
        XCTAssertTrue(viewModel.highlightedInvalidFieldKeys.contains(ScenicQueueNumericFieldKey.firstNoticeAhead.rawValue))
    }

    func testPresetVoicePlayStopAndDeleteState() async {
        let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.presetPlayback")!
        defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.presetPlayback")
        let store = AppStore(defaults: defaults)
        store.session.userId = "u1"
        store.session.accountType = .unknown("photog")
        store.session.currentScenicId = 11
        store.scenicQueue.punchSpotId = "22"
        store.scenicQueue.punchSpotName = "东门打卡点"
        store.scenicQueue.savePresetVoices(["请到拍摄区域"])
        let tts = MockScenicQueueTTSManager()
        let viewModel = ScenicQueueSettingsViewModel(appStore: store, ttsManager: tts)

        viewModel.startPresetVoiceLoop("请到拍摄区域")
        for _ in 0..<20 where viewModel.ttsLoopAnchorText == nil {
            await Task.yield()
        }
        XCTAssertTrue(viewModel.isCustomTTSLooping)
        XCTAssertEqual(viewModel.ttsLoopAnchorText, "请到拍摄区域")
        XCTAssertTrue(tts.events.contains("custom:请到拍摄区域"))

        viewModel.deletePresetVoice(at: 0)
        XCTAssertTrue(viewModel.presetVoices.isEmpty)
        XCTAssertFalse(viewModel.isCustomTTSLooping)
        XCTAssertNil(viewModel.ttsLoopAnchorText)
        XCTAssertEqual(tts.events.last, "stopCustom")
    }

    func testTicketActionStateMatchesAndroidCardMatrix() {
        let waiting = makeTicket(status: .waiting, showCompleteAction: false)
        let waitingState = ScenicQueueTicketActionState.make(ticket: waiting, shootingQueueNo: nil, remainSeconds: 0, showStartShootingButton: true)
        XCTAssertTrue(waitingState.skipEnabled)
        XCTAssertEqual(waitingState.primaryTitle, "叫号")
        XCTAssertTrue(waitingState.primaryVisible)
        XCTAssertFalse(waitingState.recallVisible)
        XCTAssertFalse(waitingState.completeVisible)

        let called = makeTicket(status: .called, showCompleteAction: true)
        let hiddenStartState = ScenicQueueTicketActionState.make(ticket: called, shootingQueueNo: nil, remainSeconds: 0, showStartShootingButton: false)
        XCTAssertFalse(hiddenStartState.primaryVisible)
        XCTAssertTrue(hiddenStartState.recallVisible)
        XCTAssertTrue(hiddenStartState.completeVisible)

        let shootingState = ScenicQueueTicketActionState.make(ticket: called, shootingQueueNo: called.queueNo, remainSeconds: 17, showStartShootingButton: true)
        XCTAssertFalse(shootingState.skipEnabled)
        XCTAssertFalse(shootingState.primaryEnabled)
        XCTAssertEqual(shootingState.primaryTitle, "剩余 17 秒")
        XCTAssertFalse(shootingState.recallVisible)
        XCTAssertTrue(shootingState.completeVisible)
    }

    func testQueueVisualTokensAndChangeLogPresentationContract() {
        XCTAssertEqual(ScenicQueueTokens.radiusCard, 12)
        XCTAssertEqual(ScenicQueueTokens.radiusDialog, 16)
        XCTAssertEqual(ScenicQueueTokens.radiusPrimaryRowButton, 10)
        XCTAssertEqual(ScenicQueueTokens.ticketIdFont.pointSize, 20)
        XCTAssertEqual(ScenicQueueTokens.dialogTicketFont.pointSize, 28)
        XCTAssertEqual(ScenicQueueTokens.listHorizontalInset, 12)
        XCTAssertTrue(ScenicQueueTokens.timelineDot.isEqualToRGB(0x1677FF))
        XCTAssertTrue(ScenicQueueTokens.tagWaitingForeground.isEqualToRGB(0x10B981))

        let distanceField = ScenicQueueNumericFieldView(key: "queueDistanceMeter", unit: "千米", keyboard: .decimalPad)
        XCTAssertEqual(distanceField.subviews.compactMap { ($0 as? UILabel)?.text }, ["千米"])

        let item = ScenicQueueSettingChangeLogItem(
            id: 1,
            scenicId: 11,
            scenicSpotId: 22,
            scenicSpotName: "东门打卡点",
            operatorId: nil,
            operatorName: " ",
            operatorPhoneTail: "8000",
            summary: " ",
            displayText: "拍摄时长由 5 分钟改为 8 分钟",
            createdAt: "2026-07-07T10:20:30+08:00"
        )
        XCTAssertEqual(item.metadataLine, "2026-07-07 10:20 未知 尾号8000")
        XCTAssertEqual(item.cardSummary, "拍摄时长由 5 分钟改为 8 分钟")
    }

    func testPendingDialogDismissMethodsClearEveryOperationState() async {
        let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.pendingDialogs")!
        defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.pendingDialogs")
        let store = AppStore(defaults: defaults)
        store.session.userId = "9"
        store.session.accountType = .unknown("photog")
        store.session.currentScenicId = 11
        store.scenicQueue.punchSpotId = "22"
        store.scenicQueue.punchSpotName = "东门打卡点"
        let api = MockScenicQueueAPI()
        api.homePages[1] = .page(items: [.item(id: 1, queueCode: "A01", isCalled: 0)], total: 1, page: 1)
        let viewModel = ScenicQueueViewModel(appStore: store, ttsManager: MockScenicQueueTTSManager())
        await viewModel.refreshQueueGate(api: api)

        viewModel.requestCallConfirm(recordId: 1, queueNo: "A01")
        viewModel.requestSkipTicket(recordId: 1, queueNo: "A01")
        viewModel.requestFinishConfirm(recordId: 1, queueNo: "A01")
        viewModel.requestRequeueConfirm(recordId: 1, queueNo: "A01")
        viewModel.onTicketMarkClick(recordId: 1, queueNo: "A01", uid: 1)
        XCTAssertNotNil(viewModel.pendingCall)
        XCTAssertNotNil(viewModel.pendingSkip)
        XCTAssertNotNil(viewModel.pendingFinish)
        XCTAssertNotNil(viewModel.pendingRequeue)
        XCTAssertNotNil(viewModel.pendingMark)

        viewModel.dismissCallConfirmDialog()
        viewModel.dismissSkipDialog()
        viewModel.dismissFinishDialog()
        viewModel.dismissRequeueDialog()
        viewModel.dismissMarkDialog()
        XCTAssertNil(viewModel.pendingCall)
        XCTAssertNil(viewModel.pendingSkip)
        XCTAssertNil(viewModel.pendingFinish)
        XCTAssertNil(viewModel.pendingRequeue)
        XCTAssertNil(viewModel.pendingMark)
    }

    private func makeTicket(status: ScenicQueueCallStatus, showCompleteAction: Bool) -> ScenicQueueTicket {
        ScenicQueueTicket(
            recordId: 1,
            queueNo: "A01",
            phoneMasked: "138****8000",
            dialPhoneDigits: "13800138000",
            status: status,
            statusText: status == .waiting ? "待叫号" : "已叫号",
            waitMinutes: 5,
            peopleAhead: 2,
            showCompleteAction: showCompleteAction,
            queueBanLabel: "",
            identityTag: "",
            queueTimeDisplay: "07-07 10:20",
            queueCountToday: 1,
            uid: 8,
            markAsPhotog: 0,
            markAsFreelancePhotog: 0,
            isMissRequeue: false,
            missRequeueText: ""
        )
    }
}

private extension UIColor {
    func isEqualToRGB(_ rgb: UInt32) -> Bool {
        var red: CGFloat = 0
        var green: CGFloat = 0
        var blue: CGFloat = 0
        var alpha: CGFloat = 0
        guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return false }
        let expectedRed = CGFloat((rgb >> 16) & 0xFF) / 255
        let expectedGreen = CGFloat((rgb >> 8) & 0xFF) / 255
        let expectedBlue = CGFloat(rgb & 0xFF) / 255
        return abs(red - expectedRed) < 0.001 && abs(green - expectedGreen) < 0.001 && abs(blue - expectedBlue) < 0.001 && abs(alpha - 1) < 0.001
    }
}

@MainActor
private final class MockScenicQueueAPI: ScenicQueueAPIProtocol {
    var savedSetting: ScenicQueueSaveSettingRequest?
    var homePages: [Int: ScenicQueueHomeData] = [:]

    func saveSetting(_ request: ScenicQueueSaveSettingRequest) async throws {
        savedSetting = request
    }

    func call(recordId: Int64) async throws -> ScenicQueueCallData {
        ScenicQueueCallData(id: recordId, status: 2, statusText: "已叫号", calledAt: "2026-07-07 10:30:00")
    }

    func pass(recordId: Int64) async throws -> ScenicQueuePassData {
        ScenicQueuePassData(id: recordId, status: 3, statusText: "已过号")
    }

    func finish(recordId: Int64) async throws -> ScenicQueueFinishData {
        ScenicQueueFinishData(id: recordId, status: 4, statusText: "已完成")
    }

    func userMark(_ request: ScenicQueueUserMarkRequest) async throws {}

    func requeueInsertBefore(recordId: Int64, operatorId: Int) async throws {}

    func shootQueueQrcode(scenicId: Int64, scenicSpotId: Int64) async throws -> ScenicQueueShootQueueQrcodeData {
        ScenicQueueShootQueueQrcodeData(qrcodeUrl: nil, scene: nil, page: nil, time: nil)
    }

    func setting(scenicId: Int64?, scenicSpotId: Int64?) async throws -> ScenicQueueSettingData {
        ScenicQueueSettingData(exists: false, setting: nil, time: nil)
    }

    func stats(scenicId: Int64, scenicSpotId: Int64) async throws -> ScenicQueueStatsData {
        ScenicQueueStatsData(queueCount: 3, avgWaitMin: 5.6, time: "2026-07-07 10:20:00")
    }

    func home(scenicId: Int64, scenicSpotId: Int64, type: Int, page: Int, pageSize: Int) async throws -> ScenicQueueHomeData {
        if type == 2 {
            return .page(items: [], total: 0, page: page)
        }
        return homePages[page] ?? .page(items: [], total: 0, page: page)
    }

    func settingChangeLog(scenicId: Int64, scenicSpotId: Int64?, page: Int, pageSize: Int) async throws -> ScenicQueueSettingChangeLogData {
        ScenicQueueSettingChangeLogData(total: 0, page: page, pageSize: pageSize, list: [])
    }

    func scenicSpotListAll(scenicId: String) async throws -> ScenicSpotListResponse {
        ScenicSpotListResponse(list: [ScenicSpotItem(id: 22, name: "东门打卡点")], total: 1)
    }

    func socketToken() async throws -> SocketTokenResponse {
        SocketTokenResponse(socketToken: "socket-token")
    }

    func aliyunSTS(bucket: String) async throws -> AliyunOSSResponse {
        try Self.makeSTS(bucket: bucket)
    }

    static func makeSTS(bucket: String = "vipsky") throws -> AliyunOSSResponse {
        let json = """
        {
          "base_url":"https://vipsky.oss-cn-hangzhou.aliyuncs.com",
          "endpoint":"oss-cn-hangzhou.aliyuncs.com",
          "region":"cn-hangzhou",
          "bucket":"\(bucket)",
          "expire_seconds":3600,
          "credentials":{
            "access_key_id":"ak",
            "access_key_secret":"secret",
            "security_token":"security-token"
          }
        }
        """.data(using: .utf8)!
        return try JSONDecoder().decode(AliyunOSSResponse.self, from: json)
    }
}

private final class MockScenicQueueTTSManager: ScenicQueueTTSManaging {
    private(set) var events: [String] = []
    private(set) var customTextLooping = false
    let supportsOfflineTTS = false

    func prepareQueueTTSEngine(api: ScenicQueueAPIProtocol?) async {
        events.append("prepare")
    }

    func startQueueCallLoop(queueNo: String) {
        events.append("start:\(queueNo)")
    }

    func stopQueueCallLoop() {
        events.append("stopQueue")
    }

    func speakPlainTextOnce(_ text: String) async -> Bool {
        events.append("speak:\(text)")
        return true
    }

    func startCustomTextLoop(_ text: String) async -> Bool {
        customTextLooping = true
        events.append("custom:\(text)")
        return true
    }

    func stopCustomTextLoop() {
        customTextLooping = false
        events.append("stopCustom")
    }

    func interruptShootingAnnouncement() {
        events.append("interrupt")
    }

    func estimateAnnouncementDurationMillis(_ text: String) -> Int64 {
        1_000
    }
}

private final class MockAliyunNLSTokenProvider: AliyunNLSTokenProviding {
    func createToken(credentials: AliyunOSSCredentials) async throws -> AliyunNLSToken {
        AliyunNLSToken(id: "mock-nls-token", expireTime: 1_704_068_200)
    }
}

private final class MockAliyunNuiTTSBridge: AliyunNuiTTSBridging {
    weak var delegate: AliyunNuiTTSBridgeDelegate?
    var sdkAvailable = true
    var paramValues: [String: String] = [:]
    private(set) var events: [String] = []
    private(set) var initializedTickets: [String] = []
    private let callbackBridge = AliyunNuiTTSBridge()

    func initialize(ticket: String, logLevel: Int, saveLog: Bool) -> Int {
        initializedTickets.append(ticket)
        events.append("initialize:\(ticket.contains("mock-nls-token"))")
        return 0
    }

    func setParam(_ param: String, value: String) -> Int {
        events.append("set:\(param)=\(value)")
        return 0
    }

    func play(priority: String, taskId: String, text: String) -> Int {
        events.append("play:\(text)")
        delegate?.aliyunNuiTTSBridge(
            callbackBridge,
            didReceiveAudioData: Data([0, 0, 1, 0]),
            taskId: taskId,
            info: nil
        )
        delegate?.aliyunNuiTTSBridge(callbackBridge, didReceiveEvent: 1, taskId: taskId, code: 0)
        return 0
    }

    func cancel(taskId: String?) -> Int {
        events.append("cancel:\(taskId ?? "")")
        return 0
    }

    func releaseEngine() -> Int {
        events.append("release")
        return 0
    }

    func paramValue(for key: String) -> String? {
        paramValues[key]
    }
}

private final class MockAliyunPCMStreamingPlayer: AliyunPCMStreamingPlaying {
    private(set) var events: [String] = []

    func start(sampleRate: Double) {
        events.append("start:\(Int(sampleRate))")
    }

    func write(_ data: Data) {
        events.append("write:\(data.count)")
    }

    func drain(_ completion: @escaping () -> Void) {
        events.append("drain")
        completion()
    }

    func stop() {
        events.append("stop")
    }
}

private extension ScenicQueueHomeData {
    static func page(items: [ScenicQueueHomeItem], total: Int, page: Int) -> ScenicQueueHomeData {
        ScenicQueueHomeData(
            stats: ScenicQueueHomeStats(type: 1),
            list: ScenicQueueHomeListBlock(list: items, total: total, page: page, pageSize: 10),
            time: "2026-07-07 10:20:00"
        )
    }
}

private extension ScenicQueueHomeItem {
    static func item(id: Int64, queueCode: String, isCalled: Int) -> ScenicQueueHomeItem {
        ScenicQueueHomeItem(
            id: id,
            queueCode: queueCode,
            mobile: "13800138000",
            status: isCalled == 1 ? 2 : 1,
            statusText: isCalled == 1 ? "已叫号" : "待叫号",
            waitMin: 5,
            aheadCount: 1,
            isCalled: isCalled,
            createdAt: "2026-07-07 10:20:00",
            calledAt: isCalled == 1 ? "2026-07-07 10:30:00" : nil,
            expiredAt: nil,
            finishedAt: nil,
            identityTag: "",
            queueBanLabel: "",
            queueBanDays: nil,
            queueTime: "2026-07-07 10:20:00",
            queueCountToday: 1,
            uid: 88,
            markAsPhotog: 0,
            markAsFreelancePhotog: 0,
            isMissRequeue: 0,
            isMissRequeueText: nil
        )
    }
}
