//
//  OrderLongTailTests.swift
//  suixinkanTests
//
//  Created by Codex on 2026/6/25.
//

import XCTest
@testable import suixinkan

@MainActor
/// 订单长尾测试，覆盖押金订单、退款和历史拍摄的 API 与 ViewModel 规则。
final class OrderLongTailTests: XCTestCase {
    /// 测试押金订单接口使用旧工程路径、分页兜底和请求体。
    func testDepositOrderAPIRequestsAndDecodesLossyFields() async throws {
        let session = LongTailRecordingURLSession(responses: [
            try TestFixture.data(named: "deposit_order_list_success"),
            try TestFixture.data(named: "empty_success"),
            try TestFixture.data(named: "empty_success")
        ])
        let api = OrdersAPI(client: APIClient(session: session))

        let list = try await api.depositOrderList(scenicId: 88, page: 0, pageSize: 0)
        try await api.depositOrderWriteOff(orderNumber: "DEP001")
        try await api.depositOrderRefund(orderNumber: "DEP002", refundReason: "游客取消行程")

        XCTAssertEqual(session.requests.map { $0.url?.path }, [
            "/api/yf-handset-app/photog/order/deposit-list",
            "/api/yf-handset-app/photog/order/deposit-writeoff",
            "/api/yf-handset-app/photog/order/deposit-refund"
        ])
        let query = longTailQueryItems(from: try XCTUnwrap(session.requests.first))
        XCTAssertEqual(query["scenic_id"], "88")
        XCTAssertEqual(query["page"], "1")
        XCTAssertEqual(query["page_size"], "1")
        XCTAssertEqual(list.total, 2)
        XCTAssertEqual(list.list[1].id, 4102)
        XCTAssertEqual(list.list[1].userPhone, "13900000000")

        let writeOffBody = try longTailBodyObject(from: session.requests[1])
        XCTAssertEqual(writeOffBody["order_number"] as? String, "DEP001")
        let refundBody = try longTailBodyObject(from: session.requests[2])
        XCTAssertEqual(refundBody["order_number"] as? String, "DEP002")
        XCTAssertEqual(refundBody["refund_reason"] as? String, "游客取消行程")
    }

    /// 测试拍摄信息、普通退款和历史拍摄接口 path、query、body 与宽松解码。
    func testShootingRefundAndHistoryAPIRequestsAndDecodes() async throws {
        let session = LongTailRecordingURLSession(responses: [
            try TestFixture.data(named: "store_order_shooting_detail_success"),
            try TestFixture.data(named: "multi_travel_shoot_history_success"),
            try TestFixture.data(named: "empty_success")
        ])
        let api = OrdersAPI(client: APIClient(session: session))

        let shooting = try await api.storeOrderShootingDetail(
            storeId: 66,
            orderNumber: "DEP001",
            scenicSpotId: 901,
            photogUid: 42
        )
        let history = try await api.multiTravelShootHistory(orderNumber: "MT001")
        try await api.orderRefund(
            orderNumber: "STORE001",
            refundType: .partial,
            refundAmount: "10.50",
            refundReason: "游客取消部分项目"
        )

        XCTAssertEqual(session.requests.map { $0.url?.path }, [
            "/api/app/store/order/shooting-detail",
            "/api/yf-handset-app/photog/order/multi-travel/shoot-history",
            "/api/yf-handset-app/photog/order/refund"
        ])
        let shootingQuery = longTailQueryItems(from: session.requests[0])
        XCTAssertEqual(shootingQuery["store_id"], "66")
        XCTAssertEqual(shootingQuery["order_number"], "DEP001")
        XCTAssertEqual(shootingQuery["scenic_spot_id"], "901")
        XCTAssertEqual(shootingQuery["photog_uid"], "42")
        XCTAssertEqual(shooting.orderComment?.starShooting, 5)
        XCTAssertEqual(shooting.materialList.first?.fileSize, 10_485_760)
        XCTAssertTrue(shooting.materialList.first?.isVideo == true)

        XCTAssertEqual(longTailQueryItems(from: session.requests[1])["order_number"], "MT001")
        XCTAssertEqual(history.projectType, 19)
        XCTAssertEqual(history.photogSpotList.first?.photographerDisplayName, "小景")
        XCTAssertEqual(history.photogSpotList.first?.files.count, 2)

        let refundBody = try longTailBodyObject(from: session.requests[2])
        XCTAssertEqual(refundBody["order_number"] as? String, "STORE001")
        XCTAssertEqual(refundBody["refund_type"] as? Int, OrderRefundMode.partial.rawValue)
        XCTAssertEqual(refundBody["refund_amount"] as? String, "10.50")
        XCTAssertNil(refundBody["store_id"])
    }

    /// 测试已核销打卡点和多点旅拍素材提交接口 path、query、body。
    func testMultiTravelTaskUploadAPIRequestsAndDecodes() async throws {
        let session = LongTailRecordingURLSession(responses: [
            try TestFixture.data(named: "multi_travel_verified_spots_success"),
            try TestFixture.data(named: "empty_success")
        ])
        let api = OrdersAPI(client: APIClient(session: session))

        let spots = try await api.multiTravelVerifiedScenicSpotList(orderNumber: "MT001")
        try await api.multiTravelUploadMaterial(
            MultiTravelUploadMaterialRequest(
                orderNumber: "MT001",
                scenicSpotId: 901,
                cloudFile: [MultiTravelCloudFileItem(fileId: 7001)],
                uploadFile: [MultiTravelUploadFileItem(fileName: "spot.jpg", fileUrl: "https://cdn.example.com/spot.jpg")]
            )
        )

        XCTAssertEqual(session.requests.map { $0.url?.path }, [
            "/api/yf-handset-app/photog/order/multi-travel/verified-scenic-spot-list",
            "/api/yf-handset-app/photog/order/multi-travel/upload-material"
        ])
        XCTAssertEqual(longTailQueryItems(from: session.requests[0])["order_number"], "MT001")
        XCTAssertEqual(spots.map(\.id), [901, 902])
        XCTAssertEqual(spots.last?.name, "观景台")
        let body = try longTailBodyObject(from: session.requests[1])
        XCTAssertEqual(body["order_number"] as? String, "MT001")
        XCTAssertEqual(body["scenic_spot_id"] as? Int, 901)
        XCTAssertEqual((body["cloud_file"] as? [[String: Any]])?.first?["file_id"] as? Int, 7001)
        XCTAssertEqual((body["upload_file"] as? [[String: Any]])?.first?["file_url"] as? String, "https://cdn.example.com/spot.jpg")
    }

    /// 测试押金订单无景区时清空旧数据且不请求接口。
    func testDepositListClearsWithoutScenicAndStopsAtLastPage() async throws {
        let service = LongTailMockOrderService()
        service.depositListResponses = [try Self.depositPayload()]
        let viewModel = DepositOrderListViewModel()

        await viewModel.reload(api: service, scenicId: 88, reset: true)
        await viewModel.reload(api: service, scenicId: 88, reset: false)
        await viewModel.reload(api: service, scenicId: nil, reset: true)

        XCTAssertTrue(viewModel.orders.isEmpty)
        XCTAssertEqual(viewModel.total, 0)
        XCTAssertEqual(viewModel.page, 1)
        XCTAssertEqual(service.depositListCalls, [
            .init(scenicId: 88, page: 1, pageSize: 10)
        ])
    }

    /// 测试押金核销和押金退款成功后刷新列表，失败时保留旧列表并清理操作状态。
    func testDepositWriteOffAndRefundRefreshOrKeepListOnFailure() async throws {
        let service = LongTailMockOrderService()
        service.depositListResponses = [
            try Self.depositPayload(),
            try Self.depositPayload(),
            try Self.depositPayload()
        ]
        let viewModel = DepositOrderListViewModel()
        await viewModel.reload(api: service, scenicId: 88, reset: true)

        let writeOffSuccess = await viewModel.writeOff(api: service, scenicId: 88, orderNumber: "DEP001")
        let emptyReason = await viewModel.refund(api: service, scenicId: 88, orderNumber: "DEP001", reason: " ")
        service.depositRefundError = APIError.serverCode(500, "退款失败")
        let refundFailure = await viewModel.refund(api: service, scenicId: 88, orderNumber: "DEP001", reason: "游客取消")

        XCTAssertTrue(writeOffSuccess)
        XCTAssertFalse(emptyReason)
        XCTAssertFalse(refundFailure)
        XCTAssertEqual(service.depositWriteOffNumbers, ["DEP001"])
        XCTAssertEqual(service.depositRefundCalls, [.init(orderNumber: "DEP001", reason: "游客取消")])
        XCTAssertEqual(service.depositListCalls.count, 2)
        XCTAssertEqual(viewModel.orders.count, 2)
        XCTAssertNil(viewModel.operatingOrderNumber)
        XCTAssertEqual(viewModel.errorMessage, "退款失败")
    }

    /// 测试押金详情和拍摄信息缺少门店时不请求接口。
    func testDepositDetailAndShootingSkipWithoutStore() async {
        let service = LongTailMockOrderService()
        let detailViewModel = DepositOrderDetailViewModel()
        let shootingViewModel = DepositOrderShootingInfoViewModel()

        await detailViewModel.load(api: service, storeId: nil, orderNumber: "DEP001")
        await shootingViewModel.load(api: service, storeId: nil, orderNumber: "DEP001", scenicSpotId: 901, photogUid: 42)

        XCTAssertTrue(service.storeDetailCalls.isEmpty)
        XCTAssertTrue(service.storeShootingCalls.isEmpty)
        XCTAssertEqual(detailViewModel.errorMessage, "当前账号缺少门店信息")
        XCTAssertEqual(shootingViewModel.errorMessage, "当前账号缺少门店信息")
    }

    /// 测试普通退款入口规则、金额校验、重复提交保护和成功提交参数。
    func testOrderRefundValidationAndSubmit() async throws {
        let service = LongTailMockOrderService()
        let viewModel = OrderRefundViewModel()
        let refundable = try Self.orderEntity(orderStatus: 18, orderType: 4, actualPayAmount: "99.50")
        let unavailable = try Self.orderEntity(orderStatus: 50, orderType: 4, actualPayAmount: "99.50")

        XCTAssertTrue(viewModel.canRefund(refundable))
        XCTAssertFalse(viewModel.canRefund(unavailable))
        viewModel.begin(item: refundable)
        XCTAssertEqual(viewModel.amount, "99.50")
        let missingReasonSuccess = await viewModel.submit(api: service, item: refundable)
        XCTAssertFalse(missingReasonSuccess)
        XCTAssertEqual(viewModel.errorMessage, "请输入退款原因")

        viewModel.reason = "部分退款"
        viewModel.mode = .partial
        viewModel.amount = "12.345"
        let invalidAmountSuccess = await viewModel.submit(api: service, item: refundable)
        XCTAssertFalse(invalidAmountSuccess)
        XCTAssertEqual(viewModel.errorMessage, "请输入有效的退款金额")

        viewModel.amount = "120.00"
        let overLimitSuccess = await viewModel.submit(api: service, item: refundable)
        XCTAssertFalse(overLimitSuccess)
        XCTAssertEqual(viewModel.errorMessage, "退款金额不能大于可退金额")

        viewModel.amount = "12.3"
        let submitSuccess = await viewModel.submit(api: service, item: refundable)
        XCTAssertTrue(submitSuccess)
        XCTAssertEqual(service.orderRefundCalls, [
            .init(orderNumber: "STORE001", mode: .partial, amount: "12.30", reason: "部分退款")
        ])
        XCTAssertNil(viewModel.errorMessage)
        XCTAssertFalse(viewModel.submitting)
    }

    /// 测试历史拍摄空订单不请求，成功后展示项目和媒体，失败后清空旧数据。
    func testHistoricalShootingLoadSuccessAndFailure() async throws {
        let service = LongTailMockOrderService()
        service.historyResponses = [try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success")]
        let viewModel = HistoricalShootingInfoViewModel()

        await viewModel.load(api: service, orderNumber: " ")
        XCTAssertTrue(service.historyCalls.isEmpty)
        XCTAssertEqual(viewModel.errorMessage, "订单号不能为空")

        await viewModel.load(api: service, orderNumber: "MT001")
        XCTAssertEqual(viewModel.projectName, "多点旅拍")
        XCTAssertEqual(viewModel.spots.first?.scenicSpotName, "湖心亭")
        XCTAssertEqual(viewModel.spots.first?.files.count, 2)

        service.historyError = APIError.serverCode(500, "历史拍摄失败")
        await viewModel.load(api: service, orderNumber: "MT001")
        XCTAssertTrue(viewModel.spots.isEmpty)
        XCTAssertEqual(viewModel.errorMessage, "历史拍摄失败")
    }

    /// 测试任务上传空订单不请求，加载成功默认选中首个打卡点，加载失败清空状态。
    func testMultiTravelTaskUploadLoadsAndRepairsSpotSelection() async throws {
        let service = LongTailMockOrderService()
        service.verifiedSpotResponses = [
            try Self.verifiedSpots(),
            [try Self.verifiedSpot(id: 902, name: "观景台")]
        ]
        let viewModel = MultiTravelTaskUploadViewModel()

        await viewModel.loadSpots(api: service)
        XCTAssertTrue(service.verifiedSpotCalls.isEmpty)

        viewModel.orderNumber = "MT001"
        await viewModel.loadSpots(api: service)
        XCTAssertEqual(viewModel.selectedSpotId, 901)
        viewModel.selectSpot(id: 901)
        await viewModel.loadSpots(api: service)
        XCTAssertEqual(viewModel.selectedSpotId, 902)

        service.verifiedSpotError = APIError.serverCode(500, "打卡点失败")
        await viewModel.loadSpots(api: service)
        XCTAssertTrue(viewModel.spots.isEmpty)
        XCTAssertNil(viewModel.selectedSpotId)
        XCTAssertEqual(viewModel.errorMessage, "打卡点失败")
    }

    /// 测试任务上传提交校验和仅云盘文件直接提交。
    func testMultiTravelTaskUploadValidatesAndSubmitsCloudFiles() async throws {
        let service = LongTailMockOrderService()
        service.verifiedSpotResponses = [try Self.verifiedSpots()]
        let uploader = LongTailMockUploader()
        let viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: "MT001")

        let missingScenicSuccess = await viewModel.submit(api: service, uploadService: uploader, scenicId: nil)
        XCTAssertFalse(missingScenicSuccess)
        XCTAssertEqual(viewModel.errorMessage, "请先选择景区")

        await viewModel.loadSpots(api: service)
        let missingFileSuccess = await viewModel.submit(api: service, uploadService: uploader, scenicId: 88)
        XCTAssertFalse(missingFileSuccess)
        XCTAssertEqual(viewModel.errorMessage, "请至少选择一个素材文件")

        viewModel.mergeCloudFiles([TaskCloudSelectionItem(id: 7001, fileName: "cloud.jpg", fileType: 2, remark: "")])
        let submitSuccess = await viewModel.submit(api: service, uploadService: uploader, scenicId: 88)

        XCTAssertTrue(submitSuccess)
        XCTAssertTrue(viewModel.didSubmitSuccessfully)
        XCTAssertEqual(service.uploadMaterialRequests, [
            MultiTravelUploadMaterialRequest(
                orderNumber: "MT001",
                scenicSpotId: 901,
                cloudFile: [MultiTravelCloudFileItem(fileId: 7001)],
                uploadFile: []
            )
        ])
        XCTAssertTrue(uploader.uploadTaskFiles.isEmpty)
    }

    /// 测试任务上传本地文件先上传 OSS，上传失败不提交业务接口。
    func testMultiTravelTaskUploadUploadsLocalFilesBeforeSubmit() async throws {
        let service = LongTailMockOrderService()
        service.verifiedSpotResponses = [try Self.verifiedSpots()]
        let uploader = LongTailMockUploader()
        uploader.uploadTaskFileURLs = ["https://cdn.example.com/local.jpg"]
        let viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: "MT001")
        await viewModel.loadSpots(api: service)
        viewModel.addLocalFile(data: Data([1, 2, 3]), fileName: "local.jpg")

        let success = await viewModel.submit(api: service, uploadService: uploader, scenicId: 88)

        XCTAssertTrue(success)
        XCTAssertEqual(uploader.uploadTaskFiles.map(\.fileName), ["local.jpg"])
        XCTAssertEqual(service.uploadMaterialRequests.first?.uploadFile, [
            MultiTravelUploadFileItem(fileName: "local.jpg", fileUrl: "https://cdn.example.com/local.jpg")
        ])

        let failingService = LongTailMockOrderService()
        failingService.verifiedSpotResponses = [try Self.verifiedSpots()]
        let failingUploader = LongTailMockUploader()
        failingUploader.uploadError = APIError.serverCode(500, "上传失败")
        let failingViewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: "MT001")
        await failingViewModel.loadSpots(api: failingService)
        failingViewModel.addLocalFile(data: Data([1]), fileName: "bad.mp4")

        let failure = await failingViewModel.submit(api: failingService, uploadService: failingUploader, scenicId: 88)

        XCTAssertFalse(failure)
        XCTAssertTrue(failingService.uploadMaterialRequests.isEmpty)
        XCTAssertEqual(failingViewModel.errorMessage, "上传失败")
    }

    /// 读取押金订单列表 fixture。
    private static func depositPayload() throws -> ListPayload<DepositOrderListItem> {
        try TestFixture.payload(ListPayload<DepositOrderListItem>.self, named: "deposit_order_list_success")
    }

    /// 构建最小普通订单实体，用于退款规则测试。
    private static func orderEntity(orderStatus: Int, orderType: Int, actualPayAmount: String) throws -> OrderEntity {
        let json = """
        {
          "order_number": "STORE001",
          "order_status": "\(orderStatus)",
          "order_type": "\(orderType)",
          "actual_pay_amount": "\(actualPayAmount)",
          "actual_refund_amount": "0",
          "refund_amount": "0",
          "order_type_label": "旅拍订单",
          "order_status_name": "待退款"
        }
        """
        return try JSONDecoder().decode(OrderEntity.self, from: Data(json.utf8))
    }

    /// 读取已核销打卡点 fixture。
    private static func verifiedSpots() throws -> [MultiTravelVerifiedScenicSpotItem] {
        try TestFixture.payload([MultiTravelVerifiedScenicSpotItem].self, named: "multi_travel_verified_spots_success")
    }

    /// 构建已核销打卡点测试实体。
    private static func verifiedSpot(id: Int, name: String) throws -> MultiTravelVerifiedScenicSpotItem {
        let json = #"{"id":"\#(id)","name":"\#(name)"}"#
        return try JSONDecoder().decode(MultiTravelVerifiedScenicSpotItem.self, from: Data(json.utf8))
    }
}

/// 长尾订单 URLSession 测试替身，按调用顺序返回响应并记录请求。
private final class LongTailRecordingURLSession: URLSessionProtocol {
    private var responses: [Data]
    private(set) var requests: [URLRequest] = []

    init(responses: [Data]) {
        self.responses = responses
    }

    /// 返回队列中的下一个响应。
    func data(for request: URLRequest) async throws -> (Data, URLResponse) {
        requests.append(request)
        let data = responses.isEmpty ? Data(#"{"code":100000,"msg":"success"}"#.utf8) : responses.removeFirst()
        return (
            data,
            HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
        )
    }
}

@MainActor
/// 长尾订单服务测试替身，记录押金、退款和历史拍摄调用。
private final class LongTailMockOrderService: OrderServing {
    struct DepositListCall: Equatable {
        let scenicId: Int
        let page: Int
        let pageSize: Int
    }

    struct DepositRefundCall: Equatable {
        let orderNumber: String
        let reason: String
    }

    struct OrderRefundCall: Equatable {
        let orderNumber: String
        let mode: OrderRefundMode
        let amount: String
        let reason: String
    }

    var depositListResponses: [ListPayload<DepositOrderListItem>] = []
    var detailResponses: [StoreOrderDetailResponse] = []
    var shootingResponses: [StoreOrderShootingDetailResponse] = []
    var historyResponses: [MultiTravelShootHistoryResponse] = []
    var verifiedSpotResponses: [[MultiTravelVerifiedScenicSpotItem]] = []
    var depositWriteOffError: Error?
    var depositRefundError: Error?
    var orderRefundError: Error?
    var historyError: Error?
    var verifiedSpotError: Error?
    var uploadMaterialError: Error?

    private(set) var depositListCalls: [DepositListCall] = []
    private(set) var depositWriteOffNumbers: [String] = []
    private(set) var depositRefundCalls: [DepositRefundCall] = []
    private(set) var orderRefundCalls: [OrderRefundCall] = []
    private(set) var storeDetailCalls: [(storeId: Int, orderNumber: String)] = []
    private(set) var storeShootingCalls: [(storeId: Int, orderNumber: String, scenicSpotId: Int, photogUid: Int)] = []
    private(set) var historyCalls: [String] = []
    private(set) var verifiedSpotCalls: [String] = []
    private(set) var uploadMaterialRequests: [MultiTravelUploadMaterialRequest] = []

    /// 基础订单列表接口在长尾测试中不使用。
    func orderList(
        scenicId: Int,
        page: Int,
        pageSize: Int,
        orderStatus: Int?,
        userPhone: String?,
        startTime: String?,
        endTime: String?,
        isRefined: Int?,
        isScenicAdmin: Bool
    ) async throws -> ListPayload<OrderEntity> {
        ListPayload(total: 0, list: [])
    }

    /// 核销列表接口在长尾测试中不使用。
    func writeOffList(
        scenicId: Int,
        storeId: Int?,
        page: Int,
        pageSize: Int,
        orderStatus: Int?,
        orderVerifyStatus: Int?,
        startTime: String?,
        endTime: String?,
        userPhone: String?
    ) async throws -> ListPayload<WriteOffOrderItem> {
        ListPayload(total: 0, list: [])
    }

    func updateRefinedChoice(orderNumber: String, isRefined: Int) async throws {}
    func cancelOrder(orderNumber: String) async throws {}
    func sendGift(orderNumber: String, photoNum: Int, videoNum: Int) async throws {}
    func storeOrderList(
        storeId: Int?,
        page: Int,
        pageSize: Int,
        userPhone: String?,
        orderStatus: Int?,
        orderType: Int?,
        startTime: String?,
        endTime: String?
    ) async throws -> ListPayload<StoreOrderListItem> {
        ListPayload(total: 0, list: [])
    }
    func storeOrderRefund(
        storeId: Int,
        orderNumber: String,
        refundType: OrderRefundMode,
        refundAmount: String,
        refundReason: String
    ) async throws {}

    /// 普通核销接口在长尾测试中不使用。
    func writeOff(orderNumber: String) async throws {}

    /// 返回门店订单详情。
    func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse {
        storeDetailCalls.append((storeId: storeId, orderNumber: orderNumber))
        return detailResponses.isEmpty ? try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") : detailResponses.removeFirst()
    }

    /// 返回押金订单列表。
    func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload<DepositOrderListItem> {
        depositListCalls.append(.init(scenicId: scenicId, page: page, pageSize: pageSize))
        return depositListResponses.isEmpty ? ListPayload(total: 0, list: []) : depositListResponses.removeFirst()
    }

    /// 记录押金核销订单号。
    func depositOrderWriteOff(orderNumber: String) async throws {
        depositWriteOffNumbers.append(orderNumber)
        if let depositWriteOffError {
            throw depositWriteOffError
        }
    }

    /// 记录押金退款参数。
    func depositOrderRefund(orderNumber: String, refundReason: String) async throws {
        depositRefundCalls.append(.init(orderNumber: orderNumber, reason: refundReason))
        if let depositRefundError {
            throw depositRefundError
        }
    }

    /// 返回拍摄信息详情。
    func storeOrderShootingDetail(
        storeId: Int,
        orderNumber: String,
        scenicSpotId: Int,
        photogUid: Int
    ) async throws -> StoreOrderShootingDetailResponse {
        storeShootingCalls.append((storeId: storeId, orderNumber: orderNumber, scenicSpotId: scenicSpotId, photogUid: photogUid))
        return shootingResponses.isEmpty ? try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success") : shootingResponses.removeFirst()
    }

    /// 记录普通退款参数。
    func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws {
        orderRefundCalls.append(.init(orderNumber: orderNumber, mode: refundType, amount: refundAmount, reason: refundReason))
        if let orderRefundError {
            throw orderRefundError
        }
    }

    /// 返回历史拍摄信息。
    func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
        historyCalls.append(orderNumber)
        if let historyError {
            throw historyError
        }
        return historyResponses.isEmpty ? try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success") : historyResponses.removeFirst()
    }

    /// 返回已核销打卡点列表。
    func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] {
        verifiedSpotCalls.append(orderNumber)
        if let verifiedSpotError {
            throw verifiedSpotError
        }
        return verifiedSpotResponses.isEmpty ? [] : verifiedSpotResponses.removeFirst()
    }

    /// 记录多点旅拍素材提交请求。
    func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws {
        uploadMaterialRequests.append(request)
        if let uploadMaterialError {
            throw uploadMaterialError
        }
    }
}

@MainActor
/// 长尾订单 OSS 上传测试替身，只记录任务附件上传。
private final class LongTailMockUploader: OSSUploadServing {
    struct UploadTaskFileCall: Equatable {
        let fileName: String
        let fileType: Int
        let scenicId: Int
        let dataSize: Int
    }

    var uploadTaskFileURLs: [String] = []
    var uploadError: Error?
    private(set) var uploadTaskFiles: [UploadTaskFileCall] = []

    /// 返回预设任务附件 URL。
    func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
        uploadTaskFiles.append(.init(fileName: fileName, fileType: fileType, scenicId: scenicId, dataSize: data.count))
        if let uploadError {
            throw uploadError
        }
        onProgress(100)
        return uploadTaskFileURLs.isEmpty ? "https://cdn.example.com/\(fileName)" : uploadTaskFileURLs.removeFirst()
    }

    /// 其他上传入口在本测试中不使用。
    func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }

    /// 其他上传入口在本测试中不使用。
    func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }

    /// 其他上传入口在本测试中不使用。
    func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }

    /// 其他上传入口在本测试中不使用。
    func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }

    /// 其他上传入口在本测试中不使用。
    func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }

    /// 其他上传入口在本测试中不使用。
    func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }

    /// 其他上传入口在本测试中不使用。
    func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }

    /// 其他上传入口在本测试中不使用。
    func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
}

/// 提取请求 query 方便断言。
private func longTailQueryItems(from request: URLRequest) -> [String: String] {
    guard let url = request.url,
          let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
        return [:]
    }
    return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in
        item.value.map { (item.name, $0) }
    })
}

/// 将 JSON body 解成字典方便断言。
private func longTailBodyObject(from request: URLRequest) throws -> [String: Any] {
    let data = try XCTUnwrap(request.httpBody)
    let object = try JSONSerialization.jsonObject(with: data)
    return try XCTUnwrap(object as? [String: Any])
}
