//
//  CameraTransferPipelineTests.swift
//  suixinkanTests
//
//  Created by Codex on 2026/6/29.
//

import XCTest
@testable import suixinkan

@MainActor
/// 相机传输管道测试，使用 Mock 相机与上传 Sink。
final class CameraTransferPipelineTests: XCTestCase {
    /// 测试新照片下载后触发上传 Sink，并持久化相对路径。
    func testNewAssetTriggersUploadSink() async {
        let camera = MockCameraService()
        let sink = MockUploadSink()
        let pipeline = CameraTransferPipeline(cameraService: camera)
        let scope = makeScope("new_asset")
        defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
        pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)

        var latestTasks: [CameraTransferTask] = []
        attachPipeline(pipeline, onTasksUpdated: { latestTasks = $0 })

        let asset = CameraAsset(id: "ptp_1", filename: "DSC_001.JPG", fileSize: 1024)
        let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("DSC_001.JPG")
        try? Data(repeating: 0xFF, count: 8).write(to: tempURL)

        camera.onNewAsset?(asset)
        try? await Task.sleep(nanoseconds: 300_000_000)

        XCTAssertEqual(sink.uploadCount, 1)
        let savedTask = latestTasks.first { $0.assetID == asset.id }
        XCTAssertEqual(
            savedTask?.localPath?.hasPrefix("CameraDownloads/test_account/\(scope.albumID)/originals/"),
            true
        )
        XCTAssertEqual(savedTask?.localPath?.hasPrefix("/"), false)
        if let localURL = savedTask?.localURL {
            try? FileManager.default.removeItem(at: localURL)
        }
        try? FileManager.default.removeItem(at: tempURL)
    }

    /// 测试重传可从相对路径恢复管道任务并上传。
    func testRetryUploadRehydratesFromRelativePath() async throws {
        let camera = MockCameraService()
        let sink = MockUploadSink()
        let pipeline = CameraTransferPipeline(cameraService: camera)
        let scope = makeScope("retry")
        defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
        pipeline.configure(uploadSink: sink, uploadEnabled: false, downloadScope: scope)

        let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "retry_pipeline.JPG", scope: scope)
        try Data(repeating: 0xAB, count: 16).write(to: fileURL)
        defer { try? FileManager.default.removeItem(at: fileURL) }

        let relativePath = CameraDownloadStorage.relativePath(for: fileURL)
        await pipeline.retryUpload(
            assetID: "asset_retry_001",
            filename: "retry_pipeline.JPG",
            localPath: relativePath
        )

        XCTAssertEqual(sink.uploadCount, 1)
        XCTAssertEqual(pipeline.task(forAssetID: "asset_retry_001")?.status, .uploaded)
    }

    /// 测试手动上传多张照片时按并发上限上传，而不是串行一张张上传。
    func testManualUploadAssetsUsesConcurrentLimit() async throws {
        let camera = MockCameraService()
        let sink = MockSlowUploadSink()
        let pipeline = CameraTransferPipeline(cameraService: camera)
        let scope = makeScope("manual")
        defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
        pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
        camera.listedAssets = (1 ... 6).map { index in
            CameraAsset(id: "manual_\(index)", filename: "MANUAL_\(index).JPG", fileSize: 1024)
        }

        await pipeline.syncExistingPhotos()
        await pipeline.uploadAssets(withIDs: camera.listedAssets.map(\.id))

        XCTAssertEqual(sink.uploadCount, 6)
        XCTAssertGreaterThan(sink.maxActiveUploads, 1)
        XCTAssertLessThanOrEqual(sink.maxActiveUploads, 3)
    }

    /// 测试拍后传输下载的旧照片，切到边拍边传后不会被新照片触发自动上传。
    func testSwitchingToLiveCaptureDoesNotUploadPostShootDownloads() async throws {
        let camera = MockCameraService()
        let sink = MockUploadSink()
        let pipeline = CameraTransferPipeline(cameraService: camera)
        let scope = makeScope("mode_switch")
        defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
        pipeline.configure(uploadSink: sink, uploadEnabled: false, downloadScope: scope)
        attachPipeline(pipeline)

        camera.onNewAsset?(CameraAsset(id: "post_001", filename: "POST_001.JPG", fileSize: 1024))
        try await Task.sleep(nanoseconds: 400_000_000)

        XCTAssertEqual(sink.uploadedFileNames, [])
        XCTAssertEqual(pipeline.task(forAssetID: "post_001")?.status, .downloaded)

        pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
        camera.onNewAsset?(CameraAsset(id: "live_001", filename: "LIVE_001.JPG", fileSize: 1024))
        try await Task.sleep(nanoseconds: 500_000_000)

        XCTAssertEqual(sink.uploadedFileNames, ["LIVE_001.JPG"])
        XCTAssertEqual(pipeline.task(forAssetID: "post_001")?.status, .downloaded)
        XCTAssertEqual(pipeline.task(forAssetID: "live_001")?.status, .uploaded)
    }

    /// 测试边拍边传模式下刷新历史照片只同步到本地，不授予自动上传资格。
    func testSyncExistingPhotosDoesNotAutoUploadInLiveCaptureMode() async throws {
        let camera = MockCameraService()
        let sink = MockUploadSink()
        let pipeline = CameraTransferPipeline(cameraService: camera)
        let scope = makeScope("sync_existing")
        defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
        pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
        camera.listedAssets = [
            CameraAsset(id: "history_001", filename: "HISTORY_001.JPG", fileSize: 1024)
        ]

        await pipeline.syncExistingPhotos()
        try await Task.sleep(nanoseconds: 200_000_000)

        XCTAssertEqual(sink.uploadedFileNames, [])
        XCTAssertEqual(pipeline.task(forAssetID: "history_001")?.status, .downloaded)
    }

    /// 测试边拍边传连拍时，多张照片会按并发上限同时上传。
    func testBurstCaptureUploadsAllAssetsWhileFirstUploadInProgress() async throws {
        let camera = MockBurstCameraService()
        let sink = MockSlowUploadSink()
        let pipeline = CameraTransferPipeline(cameraService: camera)
        let scope = makeScope("burst")
        defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
        pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
        attachPipeline(pipeline)

        for index in 1 ... 10 {
            let filename = "DSC_\(index).JPG"
            let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
            try Data(repeating: UInt8(index), count: 8).write(to: tempURL)
            camera.downloadURLs[filename] = tempURL
            camera.onNewAsset?(
                CameraAsset(id: "ptp_\(index)", filename: filename, fileSize: 1024)
            )
        }

        try await Task.sleep(nanoseconds: 2_000_000_000)

        XCTAssertEqual(sink.uploadCount, 10)
        XCTAssertGreaterThan(sink.maxActiveUploads, 1)
        XCTAssertLessThanOrEqual(sink.maxActiveUploads, 3)
        for index in 1 ... 10 {
            XCTAssertEqual(pipeline.task(forAssetID: "ptp_\(index)")?.status, .uploaded)
        }
    }

    /// 测试高频上传进度会被合并通知，避免连拍时频繁刷新 SwiftUI 列表。
    func testUploadProgressNotificationsAreThrottled() async throws {
        let camera = MockCameraService()
        let sink = MockChattyUploadSink()
        let pipeline = CameraTransferPipeline(cameraService: camera)
        let scope = makeScope("chatty")
        defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
        pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)

        var updateCount = 0
        attachPipeline(pipeline, onTasksUpdated: { _ in updateCount += 1 })

        camera.onNewAsset?(CameraAsset(id: "ptp_chatty", filename: "chatty.JPG", fileSize: 1024))
        try await Task.sleep(nanoseconds: 800_000_000)

        XCTAssertEqual(pipeline.task(forAssetID: "ptp_chatty")?.status, .uploaded)
        XCTAssertLessThan(updateCount, 18)
    }

    /// 测试 shutdown 会调用相机服务完整释放。
    func testShutdownCallsCameraService() async {
        let camera = DisconnectTrackingMockCameraService()
        let pipeline = CameraTransferPipeline(cameraService: camera)

        await pipeline.shutdown()

        XCTAssertEqual(camera.shutdownCount, 1)
    }

    /// 测试 detachFromUI 仅清回调，不 shutdown 相机。
    func testDetachFromUIDoesNotShutdownCameraService() async {
        let camera = DisconnectTrackingMockCameraService()
        let pipeline = CameraTransferPipeline(cameraService: camera)
        attachPipeline(pipeline)

        pipeline.detachFromUI()

        XCTAssertEqual(camera.detachCount, 1)
        XCTAssertEqual(camera.shutdownCount, 0)
    }

    /// 测试删除后管道不会在新片 notify 时再次带回已删 asset。
    func testRemovedAssetStaysExcludedWhenNewAssetArrives() async throws {
        let camera = MockCameraService()
        let pipeline = CameraTransferPipeline(cameraService: camera)
        let scope = makeScope("removed_asset")
        defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
        pipeline.configure(uploadSink: nil, uploadEnabled: false, downloadScope: scope)
        attachPipeline(pipeline)

        let oldURL = FileManager.default.temporaryDirectory.appendingPathComponent("OLD.JPG")
        let newURL = FileManager.default.temporaryDirectory.appendingPathComponent("NEW.JPG")
        try Data(repeating: 0x01, count: 8).write(to: oldURL)
        try Data(repeating: 0x02, count: 8).write(to: newURL)
        defer {
            try? FileManager.default.removeItem(at: oldURL)
            try? FileManager.default.removeItem(at: newURL)
        }

        camera.onNewAsset?(CameraAsset(id: "ptp_old", filename: "OLD.JPG", fileSize: 8))
        try await Task.sleep(nanoseconds: 300_000_000)
        pipeline.removeAsset(assetID: "ptp_old")
        XCTAssertNil(pipeline.task(forAssetID: "ptp_old"))

        camera.onNewAsset?(CameraAsset(id: "ptp_new", filename: "NEW.JPG", fileSize: 8))
        try await Task.sleep(nanoseconds: 300_000_000)

        XCTAssertNil(pipeline.task(forAssetID: "ptp_old"))
        XCTAssertNotNil(pipeline.task(forAssetID: "ptp_new"))
        XCTAssertTrue(pipeline.excludedAssetIDs.contains("ptp_old"))
    }

    private func attachPipeline(
        _ pipeline: CameraTransferPipeline,
        onTasksUpdated: @escaping ([CameraTransferTask]) -> Void = { _ in }
    ) {
        pipeline.attachToUI(onConnectionStateChange: { _ in }, onTasksUpdated: onTasksUpdated)
    }

    private func makeScope(_ albumName: String) -> CameraDownloadStorage.Scope {
        let albumID = albumName.unicodeScalars.reduce(0) { partial, scalar in
            (partial + Int(scalar.value)) % 10_000
        }
        return CameraDownloadStorage.Scope(accountKey: "test_account", albumID: albumID)
    }
}

@MainActor
private final class DisconnectTrackingMockCameraService: CameraServiceProtocol {
    let brand: CameraBrand = .sony
    var connectionState: CameraConnectionState = .disconnected
    var onConnectionStateChange: ((CameraConnectionState) -> Void)?
    var onNewAsset: ((CameraAsset) -> Void)?
    private(set) var detachCount = 0
    private(set) var shutdownCount = 0

    func connect() async {}
    func detachFromUI() { detachCount += 1 }
    func shutdown() async { shutdownCount += 1 }
    func listAssets() async throws -> [CameraAsset] { [] }
    func downloadAsset(_ asset: CameraAsset) async throws -> URL {
        FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename)
    }
}

@MainActor
private final class MockCameraService: CameraServiceProtocol {
    let brand: CameraBrand = .sony
    var connectionState: CameraConnectionState = .connected(deviceName: "Mock")
    var onConnectionStateChange: ((CameraConnectionState) -> Void)?
    var onNewAsset: ((CameraAsset) -> Void)?
    var listedAssets: [CameraAsset] = []

    func connect() async {}
    func detachFromUI() {}
    func shutdown() async {}

    func listAssets() async throws -> [CameraAsset] {
        listedAssets
    }

    func downloadAsset(_ asset: CameraAsset) async throws -> URL {
        let url = FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename)
        if !FileManager.default.fileExists(atPath: url.path) {
            try Data(repeating: 0xFF, count: 8).write(to: url)
        }
        return url
    }
}

@MainActor
private final class MockBurstCameraService: CameraServiceProtocol {
    let brand: CameraBrand = .sony
    var connectionState: CameraConnectionState = .connected(deviceName: "Mock")
    var onConnectionStateChange: ((CameraConnectionState) -> Void)?
    var onNewAsset: ((CameraAsset) -> Void)?
    var downloadURLs: [String: URL] = [:]

    func connect() async {}
    func detachFromUI() {}
    func shutdown() async {}

    func listAssets() async throws -> [CameraAsset] { [] }

    func downloadAsset(_ asset: CameraAsset) async throws -> URL {
        try await Task.sleep(nanoseconds: 30_000_000)
        if let url = downloadURLs[asset.filename] {
            return url
        }
        let url = FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename)
        if !FileManager.default.fileExists(atPath: url.path) {
            try Data(repeating: 0xFF, count: 8).write(to: url)
        }
        return url
    }
}

@MainActor
private final class MockSlowUploadSink: CameraAssetUploadSink {
    var uploadCount = 0
    var activeUploads = 0
    var maxActiveUploads = 0

    func upload(
        localURL: URL,
        fileName: String,
        fileType: Int,
        progress: @escaping @Sendable (Int) -> Void
    ) async throws -> String {
        uploadCount += 1
        activeUploads += 1
        maxActiveUploads = max(maxActiveUploads, activeUploads)
        try await Task.sleep(nanoseconds: 150_000_000)
        activeUploads -= 1
        progress(100)
        return "https://cdn/mock/\(fileName)"
    }
}

@MainActor
private final class MockUploadSink: CameraAssetUploadSink {
    var uploadCount = 0
    var uploadedFileNames: [String] = []

    func upload(
        localURL: URL,
        fileName: String,
        fileType: Int,
        progress: @escaping @Sendable (Int) -> Void
    ) async throws -> String {
        uploadCount += 1
        uploadedFileNames.append(fileName)
        progress(100)
        return "https://cdn/mock/\(fileName)"
    }
}

@MainActor
private final class MockChattyUploadSink: CameraAssetUploadSink {
    func upload(
        localURL: URL,
        fileName: String,
        fileType: Int,
        progress: @escaping @Sendable (Int) -> Void
    ) async throws -> String {
        for value in 0 ... 100 {
            progress(value)
        }
        return "https://cdn/mock/\(fileName)"
    }
}
